“提交项目”

This commit is contained in:
2026-03-03 14:41:39 +08:00
commit afffb8340c
127 changed files with 12504 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# IDEs
.idea/
.vscode/
*.iml
*.iws
*.ipr
.classpath
.project
.settings/
.factorypath
.trae
# OS
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# Maven
target/
.mvn/
mvnw
mvnw.cmd
*.log
# Frontend (Vue/Vite)
ui/node_modules/
ui/dist/
ui/npm-debug.log*
ui/yarn-debug.log*
ui/yarn-error.log*
ui/pnpm-debug.log*
ui/.env.local
ui/.env.*.local
ui/.DS_Store
ui/coverage/
ui/.nyc_output/
# Application Logs
logs/
*.log
# Temp files
*.tmp
*.bak
*.swp
uploads/

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 hertz_admin_springboot
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

116
README.md Normal file
View File

@@ -0,0 +1,116 @@
# Hertz Admin
Hertz Admin 是一个基于 Spring Boot 3 和 Vue 3 的前后端分离架构的轻量级权限管理平台。系统集成了用户管理、角色管理、菜单管理等核心功能,采用 RBACRole-Based Access Control模型实现细粒度的权限控制。
## 🛠 技术栈
### 后端 (Backend)
- **核心框架**: Spring Boot 3.4.1
- **持久层**: MyBatis-Plus 3.5.8
- **安全认证**: Spring Security + JJWT 0.12.6 (Stateless JWT)
- **数据库**: MySQL 8.0+
- **构建工具**: Maven
- **运行环境**: JDK 21
### 前端 (Frontend)
- **核心框架**: Vue 3.5.24
- **构建工具**: Vite 7.2.4
- **UI 组件库**: Element Plus 2.13.1
- **状态管理**: Pinia 3.0.4
- **路由管理**: Vue Router 4.6.4
- **HTTP 客户端**: Axios 1.13.2
## ✨ 主要功能
- **用户管理**: 用户增删改查、角色分配、状态控制。
- **角色管理**: 角色创建与权限分配(菜单/按钮级)。
- **菜单管理**: 动态路由配置,支持目录、菜单、按钮三种类型。
- **个人中心**: 用户资料更新、密码修改。
- **文件上传**: 头像上传与静态资源访问(默认存放在项目 `./uploads`)。
- **监控模块**: 系统资源CPU、内存、磁盘、JVM实时监控。
- **AI 助手**: 集成 Spring AI支持智能对话、历史记录与知识库RAG
## 🚀 快速开始
### 环境要求
- JDK 21+
- Node.js 18+
- MySQL 8.0+
- Ollama本地大模型运行时用于 AI 对话与知识库向量化)
### 后端启动
1. 数据库配置:
- 创建数据库 `hertz_springboot`
- 导入初始化脚本 `db/init.sql`
- ~~额外导入以下脚本以启用监控与知识库功能:~~
- ~~`src/main/resources/schema/monitor_schema.sql`~~
- ~~`src/main/resources/schema/knowledge_schema.sql`~~
- 修改 `src/main/resources/application.yml` 中的数据库连接配置。
2. 启动服务:
```bash
mvn spring-boot:run
```
服务默认运行在 `http://localhost:8088`。
### AIOllama准备
本项目的 AI 模块依赖 Ollama
- **聊天模型**:由 `spring.ai.ollama.chat.model` 指定(例如 `deepseek-llm:7b`
- **向量化模型Embedding**:由 `spring.ai.ollama.embedding.model` 指定(默认 `nomic-embed-text`
首次使用知识库RAG请在运行后端的机器上拉取向量化模型
```bash
ollama pull nomic-embed-text
```
如需拉取聊天模型(根据你的配置决定):
```bash
ollama pull deepseek-llm:7b
```
### 前端启动
1. 进入前端目录:
```bash
cd ui
```
2. 安装依赖:
```bash
npm install
```
3. 启动开发服务:
```bash
npm run dev
```
服务默认运行在 `http://localhost:5173`。
## 👤 初始账号
| 角色 | 用户名 | 密码 | 权限 |
| :--- | :--- | :--- | :--- |
| **管理员** | `hertz` | `hertz` | 拥有所有系统权限 |
| **普通用户** | `demo` | `123456` | 仅拥有基本查看权限 |
## 📂 项目结构
```text
HertzAdmin-SpringBoot/
├── db/ # 数据库初始化脚本
├── src/ # 后端源码 (Spring Boot)
├── ui/ # 前端源码 (Vue 3 + Vite)
├── pom.xml # Maven 依赖配置
├── 项目说明文档.md # 详细项目文档
├── 数据库说明文档.md # 数据库设计文档
└── README.md # 项目概览 (本文档)
```
## 📄 许可证
本项目采用 MIT 许可证。

230
db/init.sql Normal file
View File

@@ -0,0 +1,230 @@
/*
Navicat Premium Dump SQL
Source Server : demo
Source Server Type : MySQL
Source Server Version : 80040 (8.0.40)
Source Host : localhost:3306
Source Schema : hertz_springboot
Target Server Type : MySQL
Target Server Version : 80040 (8.0.40)
File Encoding : 65001
Date: 02/02/2026 14:04:10
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for ai_conversations
-- ----------------------------
DROP TABLE IF EXISTS `ai_conversations`;
CREATE TABLE `ai_conversations` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '对话标题',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 51 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '对话记录表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of ai_conversations
-- ----------------------------
-- ----------------------------
-- Table structure for ai_messages
-- ----------------------------
DROP TABLE IF EXISTS `ai_messages`;
CREATE TABLE `ai_messages` (
`id` bigint NOT NULL AUTO_INCREMENT,
`conversation_id` bigint NOT NULL COMMENT '所属对话ID',
`role` enum('user','assistant') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '消息角色',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '消息内容',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_conversation_id`(`conversation_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 249 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '对话消息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of ai_messages
-- ----------------------------
-- ----------------------------
-- Table structure for knowledge_base
-- ----------------------------
DROP TABLE IF EXISTS `knowledge_base`;
CREATE TABLE `knowledge_base` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '知识库名称',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '知识库描述',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_by` bigint NULL DEFAULT NULL COMMENT '创建人ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of knowledge_base
-- ----------------------------
-- ----------------------------
-- Table structure for knowledge_document
-- ----------------------------
DROP TABLE IF EXISTS `knowledge_document`;
CREATE TABLE `knowledge_document` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`kb_id` bigint NOT NULL COMMENT '知识库ID',
`original_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '原始文件名',
`stored_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '存储文件名',
`stored_path` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '存储相对路径',
`content_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件类型',
`size_bytes` bigint NOT NULL COMMENT '文件大小(字节)',
`sha256` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件SHA256摘要',
`status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '处理状态(PROCESSING/READY/FAILED)',
`chunk_count` int NULL DEFAULT 0 COMMENT '分片数量',
`error_message` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '失败原因',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`deleted` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除(软删除标记)',
`deleted_time` datetime NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_kb_id`(`kb_id` ASC) USING BTREE,
INDEX `idx_kb_deleted`(`kb_id` ASC, `deleted` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 11 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of knowledge_document
-- ----------------------------
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`parent_id` bigint NULL DEFAULT 0 COMMENT '父菜单ID',
`type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'D-目录 M-菜单 B-按钮',
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '菜单名称',
`path` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '路由路径',
`component` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '组件路径',
`perms` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单图标',
`sort` int NULL DEFAULT 0 COMMENT '排序',
`visible` tinyint NULL DEFAULT 1 COMMENT '0-隐藏 1-显示',
`status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
`created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 24 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统菜单表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES (1, 0, 'M', '仪表盘', '/admin/dashboard', 'admin/Dashboard', NULL, 'House', 0, 1, 1, '2026-01-19 17:30:21', '2026-01-30 18:06:30');
INSERT INTO `sys_menu` VALUES (2, 0, 'D', '系统管理', '/admin/system', NULL, NULL, 'Setting', 10, 1, 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21');
INSERT INTO `sys_menu` VALUES (3, 2, 'M', '用户管理', '/admin/system/user', 'admin/system/User', 'system:user:view', 'User', 0, 1, 1, '2026-01-19 17:30:21', '2026-01-30 18:06:21');
INSERT INTO `sys_menu` VALUES (4, 2, 'M', '角色管理', '/admin/system/role', 'admin/system/Role', 'system:role:view', 'Avatar', 1, 1, 1, '2026-01-19 17:30:21', '2026-01-30 17:05:27');
INSERT INTO `sys_menu` VALUES (5, 2, 'M', '菜单管理', '/admin/system/menu', 'admin/system/Menu', 'system:menu:view', 'Menu', 2, 1, 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21');
INSERT INTO `sys_menu` VALUES (6, 0, 'M', '知识库管理', '/admin/ai/knowledge-base', 'admin/ai/KnowledgeBase', '', 'Document', 0, 1, 1, '2026-01-26 16:18:39', '2026-01-30 18:06:27');
INSERT INTO `sys_menu` VALUES (7, 3, 'B', '用户新增', '', '', 'system:user:add', '', 0, 1, 1, '2026-01-30 16:05:29', '2026-01-30 17:12:03');
INSERT INTO `sys_menu` VALUES (8, 3, 'B', '用户删除', '', '', 'system:user:remove', '', 0, 1, 1, '2026-01-30 16:27:10', '2026-01-30 17:12:09');
INSERT INTO `sys_menu` VALUES (9, 3, 'B', '用户查询', '', '', 'system:user:view', '', 0, 1, 1, '2026-01-30 17:12:33', '2026-01-30 17:12:33');
INSERT INTO `sys_menu` VALUES (10, 3, 'B', '用户修改', '', '', 'system:user:edit', '', 0, 1, 1, '2026-01-30 17:12:53', '2026-01-30 17:12:53');
INSERT INTO `sys_menu` VALUES (15, 4, 'B', '角色查询', '', '', 'system:role:view', '', 0, 1, 1, '2026-01-30 17:31:55', '2026-01-30 17:31:55');
INSERT INTO `sys_menu` VALUES (16, 4, 'B', '角色新增', '', '', 'system:role:add', '', 0, 1, 1, '2026-01-30 17:32:10', '2026-01-30 17:32:10');
INSERT INTO `sys_menu` VALUES (17, 4, 'B', '角色修改', '', '', 'system:role:edit', '', 0, 1, 1, '2026-01-30 17:32:22', '2026-01-30 17:32:22');
INSERT INTO `sys_menu` VALUES (18, 4, 'B', '角色删除', '', '', 'system:role:remove', '', 0, 1, 1, '2026-01-30 17:32:35', '2026-01-30 17:32:35');
INSERT INTO `sys_menu` VALUES (19, 4, 'B', '分配权限', '', '', 'system:role:assign', '', 0, 1, 1, '2026-01-30 17:33:05', '2026-01-30 17:34:32');
INSERT INTO `sys_menu` VALUES (20, 3, 'B', '角色分配', '', '', 'system:user:assign', '', 0, 1, 1, '2026-01-30 17:33:29', '2026-01-30 17:33:29');
INSERT INTO `sys_menu` VALUES (21, 5, 'B', '菜单新增', '', '', 'system:menu:add', '', 0, 1, 1, '2026-01-30 17:34:56', '2026-01-30 17:34:56');
INSERT INTO `sys_menu` VALUES (22, 5, 'B', '菜单修改', '', '', 'system:menu:edit', '', 0, 1, 1, '2026-01-30 17:35:10', '2026-01-30 17:35:10');
INSERT INTO `sys_menu` VALUES (23, 5, 'B', '菜单删除', '', '', 'system:menu:remove', '', 0, 1, 1, '2026-01-30 17:35:21', '2026-01-30 17:35:21');
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_key` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色标识',
`role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
`status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
`created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_role_key`(`role_key` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'ADMIN', '管理员', 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21');
-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint NOT NULL COMMENT '角色ID',
`menu_id` bigint NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_role_menu`(`role_id` ASC, `menu_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 87 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色菜单关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES (1, 1, 1);
INSERT INTO `sys_role_menu` VALUES (2, 1, 2);
INSERT INTO `sys_role_menu` VALUES (3, 1, 3);
INSERT INTO `sys_role_menu` VALUES (4, 1, 4);
INSERT INTO `sys_role_menu` VALUES (5, 1, 5);
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '加密密码',
`nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户昵称',
`avatar_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像路径',
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
`email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
`gender` tinyint(1) NULL DEFAULT 0 COMMENT '0-未知 1-男 2-女',
`status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
`created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 26 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '赫兹', '', '18888888888', 'hertz@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-27 11:33:55');
INSERT INTO `sys_user` VALUES (2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', 'demo', '', '13888888888', 'demo@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-26 18:13:29');
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_role`(`user_id` ASC, `role_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES (1, 1, 1);
SET FOREIGN_KEY_CHECKS = 1;

176
pom.xml Normal file
View File

@@ -0,0 +1,176 @@
<?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 https://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.4.1</version>
<relativePath/>
</parent>
<groupId>com.hertz</groupId>
<artifactId>hertz-springboot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>hertz-springboot</name>
<description>Hertz 权限管理系统后端</description>
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<mybatis-plus.version>3.5.8</mybatis-plus.version>
<jjwt.version>0.12.6</jjwt.version>
<spring-ai.version>1.0.0-M5</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- System Monitor -->
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>6.6.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>enforce-java</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireJavaVersion>
<version>[21,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,14 @@
package com.hertz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.hertz.**.mapper")
public class HertzApplication {
public static void main(String[] args) {
SpringApplication.run(HertzApplication.class, args);
}
}

View File

@@ -0,0 +1,26 @@
/**
* 统一接口响应体。
*
* @param code 业务状态码
* @param message 提示信息
* @param data 返回数据
*/
package com.hertz.common.api;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(int code, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "ok", data);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(0, "ok", null);
}
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -0,0 +1,20 @@
/**
* 业务异常。
*
* <p>用于在业务流程中主动抛出可预期的错误,并携带业务错误码。</p>
*/
package com.hertz.common.exception;
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,74 @@
/**
* 全局异常处理器。
*
* <p>将各类异常统一转换为标准的接口响应结构,并设置对应的 HTTP 状态码。</p>
*/
package com.hertz.common.exception;
import com.hertz.common.api.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
var status = HttpStatus.BAD_REQUEST;
if (e.getCode() == 40100) {
status = HttpStatus.UNAUTHORIZED;
} else if (e.getCode() == 40300) {
status = HttpStatus.FORBIDDEN;
} else if (e.getCode() == 50300) {
status = HttpStatus.SERVICE_UNAVAILABLE;
}
return ResponseEntity.status(status).body(ApiResponse.fail(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
var first = e.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);
var message = first == null ? "参数错误" : first.getDefaultMessage();
return ApiResponse.fail(40001, message);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleConstraintViolation(ConstraintViolationException e) {
return ApiResponse.fail(40001, e.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> handleAuthentication(AuthenticationException e) {
return ApiResponse.fail(40100, "未登录或登录已过期");
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
return ApiResponse.fail(40300, "无权限");
}
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNoResourceFoundException(NoResourceFoundException e) {
return ApiResponse.fail(40400, "资源未找到");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleException(Exception e) {
e.printStackTrace(); // 打印堆栈信息到控制台
return ApiResponse.fail(50000, "系统异常");
}
}

View File

@@ -0,0 +1,41 @@
/**
* 请求日志过滤器。
*
* <p>记录每次 HTTP 请求的方法、URI、响应状态码与耗时。</p>
*/
package com.hertz.common.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Slf4j
public class RequestLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
throws ServletException, IOException {
long startTime = System.currentTimeMillis();
String method = request.getMethod();
String uri = request.getRequestURI();
try {
filterChain.doFilter(request, response);
} finally {
long endTime = System.currentTimeMillis();
long timeTaken = endTime - startTime;
int status = response.getStatus();
log.info("Request: [{}] {} | Status: {} | Time: {}ms", method, uri, status, timeTaken);
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* 应用路径解析器。
*
* <p>用于将配置中的相对路径解析为基于应用工作目录的绝对路径。</p>
*/
package com.hertz.config;
import java.io.File;
import org.springframework.stereotype.Service;
@Service
public class AppPathResolver {
private final File baseDir;
public AppPathResolver() {
String userDir = System.getProperty("user.dir");
this.baseDir = userDir == null || userDir.isBlank() ? new File(".") : new File(userDir);
}
public String resolve(String path) {
if (path == null || path.isBlank()) {
return path;
}
File file = new File(path);
if (file.isAbsolute()) {
return file.getAbsolutePath();
}
return new File(baseDir, path).getAbsolutePath();
}
}

View File

@@ -0,0 +1,32 @@
/**
* 应用自定义配置属性。
*
* <p>对应 application.yml 中以 {@code app} 为前缀的配置项。</p>
*/
package com.hertz.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private Jwt jwt = new Jwt();
private Upload upload = new Upload();
@Data
public static class Jwt {
private String secret;
private long expireSeconds;
}
@Data
public static class Upload {
private String rootPath;
private String avatarPath;
private String knowledgePath = "knowledge/";
}
}

View File

@@ -0,0 +1,22 @@
/**
* MyBatis-Plus 配置。
*
* <p>主要用于注册分页等拦截器。</p>
*/
package com.hertz.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
var interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

View File

@@ -0,0 +1,33 @@
/**
* Web MVC 配置。
*
* <p>用于配置静态资源映射,例如上传文件的访问路径。</p>
*/
package com.hertz.config;
import java.io.File;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${app.upload.root-path}")
private String uploadRootPath;
private final AppPathResolver pathResolver;
public WebMvcConfig(AppPathResolver pathResolver) {
this.pathResolver = pathResolver;
}
@Override
public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) {
String absoluteUploadRootPath = new File(pathResolver.resolve(uploadRootPath)).getAbsolutePath().replace("\\", "/");
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + absoluteUploadRootPath + "/");
}
}

View File

@@ -0,0 +1,27 @@
package com.hertz.modules.ai.config;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.File;
@Configuration
public class VectorStoreConfig {
@Value("${spring.ai.vectorstore.simple.store.path:vector-store.json}")
private String vectorStorePath;
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
SimpleVectorStore simpleVectorStore = SimpleVectorStore.builder(embeddingModel).build();
File file = new File(vectorStorePath);
if (file.exists() && file.isFile()) {
simpleVectorStore.load(file);
}
return simpleVectorStore;
}
}

View File

@@ -0,0 +1,117 @@
package com.hertz.modules.ai.controller;
import com.hertz.modules.ai.dto.ChatRequest;
import com.hertz.modules.ai.entity.Conversation;
import com.hertz.modules.ai.entity.Message;
import com.hertz.modules.ai.service.AiService;
import com.hertz.modules.ai.service.ConversationService;
import com.hertz.common.api.ApiResponse;
import com.hertz.common.exception.BusinessException;
import com.hertz.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiController {
private final AiService aiService;
private final ConversationService conversationService;
@PostMapping("/chat")
public ApiResponse<String> chat(@RequestBody ChatRequest request) {
Long userId = null;
if (request.getConversationId() != null) {
userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), userId);
}
String response = aiService.chat(
request.getMessage(),
request.getTemperature(),
request.getKnowledgeBaseId(),
request.getConversationId(),
userId
);
if (request.getConversationId() != null) {
conversationService.saveMessage(request.getConversationId(), "assistant", response, userId);
}
return ApiResponse.ok(response);
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatRequest request) {
// Frontend handles message persistence
Long userId = null;
if (request.getConversationId() != null) {
userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
}
return aiService.streamChat(
request.getMessage(),
request.getTemperature(),
request.getKnowledgeBaseId(),
request.getConversationId(),
userId
);
}
@PostMapping("/conversations")
public ApiResponse<Conversation> createConversation(@RequestBody Conversation conversation) {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.createConversation(conversation.getTitle(), userId));
}
@GetMapping("/conversations")
public ApiResponse<List<Conversation>> listConversations() {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.getConversations(userId));
}
@DeleteMapping("/conversations/{id}")
public ApiResponse<Void> deleteConversation(@PathVariable Long id) {
Long userId = SecurityUtils.getCurrentUserId();
conversationService.deleteConversation(id, userId);
return ApiResponse.ok();
}
@PutMapping("/conversations/{id}")
public ApiResponse<Void> updateConversation(@PathVariable Long id, @RequestBody Conversation conversation) {
Long userId = SecurityUtils.getCurrentUserId();
conversationService.updateConversationTitle(id, conversation.getTitle(), userId);
return ApiResponse.ok();
}
@GetMapping("/conversations/search")
public ApiResponse<List<Conversation>> searchConversations(@RequestParam String query) {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.searchConversations(query, userId));
}
@PostMapping("/conversations/{id}/messages")
public ApiResponse<Message> saveMessage(@PathVariable Long id, @RequestBody Message message) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
return ApiResponse.ok(conversationService.saveMessage(id, message.getRole(), message.getContent(), userId));
}
@GetMapping("/conversations/{id}/messages")
public ApiResponse<List<Message>> getMessages(@PathVariable Long id) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
return ApiResponse.ok(conversationService.getMessages(id, userId));
}
}

View File

@@ -0,0 +1,11 @@
package com.hertz.modules.ai.dto;
import lombok.Data;
@Data
public class ChatRequest {
private String message;
private Double temperature;
private Long conversationId;
private Long knowledgeBaseId;
}

View File

@@ -0,0 +1,18 @@
package com.hertz.modules.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("ai_conversations")
public class Conversation {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String title;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,21 @@
package com.hertz.modules.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("ai_messages")
public class Message {
@TableId(type = IdType.AUTO)
private Long id;
private Long conversationId;
/**
* user or assistant
*/
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,9 @@
package com.hertz.modules.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.ai.entity.Conversation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ConversationMapper extends BaseMapper<Conversation> {
}

View File

@@ -0,0 +1,9 @@
package com.hertz.modules.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.ai.entity.Message;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
}

View File

@@ -0,0 +1,8 @@
package com.hertz.modules.ai.service;
import reactor.core.publisher.Flux;
public interface AiService {
String chat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId);
Flux<String> streamChat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId);
}

View File

@@ -0,0 +1,17 @@
package com.hertz.modules.ai.service;
import com.hertz.modules.ai.entity.Conversation;
import com.hertz.modules.ai.entity.Message;
import java.util.List;
public interface ConversationService {
Conversation createConversation(String title, Long userId);
List<Conversation> getConversations(Long userId);
void deleteConversation(Long id, Long userId);
List<Conversation> searchConversations(String query, Long userId);
Message saveMessage(Long conversationId, String role, String content, Long userId);
List<Message> getMessages(Long conversationId, Long userId);
List<Message> getRecentMessages(Long conversationId, Long userId, int limit);
Conversation getConversation(Long id, Long userId);
void updateConversationTitle(Long id, String title, Long userId);
}

View File

@@ -0,0 +1,164 @@
package com.hertz.modules.ai.service.impl;
import com.hertz.modules.ai.service.AiService;
import com.hertz.modules.ai.service.ConversationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import com.hertz.modules.knowledge.service.KnowledgeVectorStoreManager;
@Service
@Slf4j
@RequiredArgsConstructor
public class AiServiceImpl implements AiService {
private final OllamaChatModel chatModel;
private final KnowledgeVectorStoreManager vectorStoreManager;
private final ConversationService conversationService;
private static final String SYSTEM_PROMPT = "你是赫兹官网的AI助手。赫兹是一个基于Spring Boot的权限控制管理系统框架用于构建高性能、可扩展的应用程序。" +
"你可以回答关于赫兹的问题,如:赫兹的架构、性能优化、微服务设计模式等。" +
"如果用户有其他问题,也请尽力回答。";
private static final int CHAT_MEMORY_MAX_MESSAGES = 10;
@Override
public String chat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId) {
long startTime = System.currentTimeMillis();
try {
log.info("Starting AI chat request. Message length: {}", message.length());
String systemPromptContent = SYSTEM_PROMPT;
if (knowledgeBaseId != null) {
systemPromptContent = getRAGSystemPrompt(message, knowledgeBaseId);
}
OllamaOptions options = OllamaOptions.builder()
.temperature(temperature != null ? temperature : 0.7)
.build();
List<Message> messages = buildPromptMessages(systemPromptContent, conversationId, userId, message);
String response = chatModel.call(new Prompt(messages, options)).getResult().getOutput().getContent();
log.info("AI chat request completed in {}ms", System.currentTimeMillis() - startTime);
return response;
} catch (Exception e) {
log.error("AI chat request failed", e);
throw new RuntimeException("AI service unavailable: " + e.getMessage());
}
}
@Override
public Flux<String> streamChat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId) {
OllamaOptions options = OllamaOptions.builder()
.temperature(temperature != null ? temperature : 0.7)
.build();
String systemPromptContent = SYSTEM_PROMPT;
if (knowledgeBaseId != null) {
systemPromptContent = getRAGSystemPrompt(message, knowledgeBaseId);
}
List<Message> messages = buildPromptMessages(systemPromptContent, conversationId, userId, message);
return chatModel.stream(new Prompt(messages, options))
.map(response -> {
String content = response.getResult().getOutput().getContent();
return content != null ? content : "";
})
.doOnError(e -> log.error("Error in AI stream", e))
.onErrorResume(e -> Flux.just(" [Error: " + e.getMessage() + "]"));
}
private List<Message> buildPromptMessages(String systemPromptContent, Long conversationId, Long userId, String userMessage) {
List<com.hertz.modules.ai.entity.Message> history = List.of();
if (conversationId != null && userId != null) {
history = conversationService.getRecentMessages(conversationId, userId, CHAT_MEMORY_MAX_MESSAGES);
}
return assemblePromptMessages(systemPromptContent, history, userMessage);
}
List<Message> assemblePromptMessages(String systemPromptContent, List<com.hertz.modules.ai.entity.Message> history, String userMessage) {
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPromptContent));
if (history != null && !history.isEmpty()) {
for (com.hertz.modules.ai.entity.Message historyMessage : history) {
Message mapped = mapStoredMessage(historyMessage);
if (mapped != null) {
promptMessages.add(mapped);
}
}
}
if (!isDuplicateLastUserMessage(history, userMessage)) {
promptMessages.add(new UserMessage(userMessage));
}
return promptMessages;
}
boolean isDuplicateLastUserMessage(List<com.hertz.modules.ai.entity.Message> history, String userMessage) {
if (history == null || history.isEmpty()) {
return false;
}
com.hertz.modules.ai.entity.Message last = history.get(history.size() - 1);
if (last == null) {
return false;
}
if (!"user".equalsIgnoreCase(last.getRole())) {
return false;
}
return Objects.equals(last.getContent(), userMessage);
}
Message mapStoredMessage(com.hertz.modules.ai.entity.Message stored) {
if (stored == null || stored.getContent() == null || stored.getContent().isBlank()) {
return null;
}
String role = stored.getRole() == null ? "" : stored.getRole().trim().toLowerCase();
return switch (role) {
case "assistant" -> new AssistantMessage(stored.getContent());
case "system" -> new SystemMessage(stored.getContent());
default -> new UserMessage(stored.getContent());
};
}
private String getRAGSystemPrompt(String message, Long knowledgeBaseId) {
try {
SearchRequest request = SearchRequest.builder().query(message).topK(3).build();
List<Document> similarDocuments = vectorStoreManager.get(knowledgeBaseId).similaritySearch(request);
log.info("RAG retrieved {} documents for kbId={}", similarDocuments.size(), knowledgeBaseId);
if (similarDocuments.isEmpty()) {
return SYSTEM_PROMPT;
}
String context = similarDocuments.stream()
.map(doc -> doc.getText())
.collect(Collectors.joining("\n\n"));
return SYSTEM_PROMPT + "\n\n以下是相关背景知识\n" + context
+ "\n\n请优先依据背景知识回答如果背景知识不足以回答请明确说明“知识库未检索到相关内容”再给出你的通用建议。";
} catch (Exception e) {
log.error("RAG retrieval failed", e);
return SYSTEM_PROMPT;
}
}
}

View File

@@ -0,0 +1,129 @@
package com.hertz.modules.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hertz.common.exception.BusinessException;
import com.hertz.modules.ai.entity.Conversation;
import com.hertz.modules.ai.entity.Message;
import com.hertz.modules.ai.mapper.ConversationMapper;
import com.hertz.modules.ai.mapper.MessageMapper;
import com.hertz.modules.ai.service.ConversationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ConversationServiceImpl implements ConversationService {
private final ConversationMapper conversationMapper;
private final MessageMapper messageMapper;
@Override
@Transactional
public Conversation createConversation(String title, Long userId) {
Conversation conversation = new Conversation();
conversation.setUserId(userId);
conversation.setTitle(title != null && !title.isEmpty() ? title : "New Chat");
conversation.setCreatedAt(LocalDateTime.now());
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.insert(conversation);
return conversation;
}
@Override
public List<Conversation> getConversations(Long userId) {
return conversationMapper.selectList(new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getUserId, userId)
.orderByDesc(Conversation::getUpdatedAt));
}
@Override
@Transactional
public void deleteConversation(Long id, Long userId) {
Conversation conversation = requireOwnedConversation(id, userId);
messageMapper.delete(new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, id));
conversationMapper.deleteById(conversation.getId());
}
@Override
public List<Conversation> searchConversations(String query, Long userId) {
return conversationMapper.selectList(new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getUserId, userId)
.like(Conversation::getTitle, query)
.orderByDesc(Conversation::getUpdatedAt));
}
@Override
@Transactional
public Message saveMessage(Long conversationId, String role, String content, Long userId) {
Conversation conversation = requireOwnedConversation(conversationId, userId);
Message message = new Message();
message.setConversationId(conversationId);
message.setRole(role);
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.updateById(conversation);
return message;
}
@Override
public List<Message> getMessages(Long conversationId, Long userId) {
requireOwnedConversation(conversationId, userId);
return messageMapper.selectList(new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, conversationId)
.orderByAsc(Message::getCreatedAt));
}
@Override
public List<Message> getRecentMessages(Long conversationId, Long userId, int limit) {
requireOwnedConversation(conversationId, userId);
if (limit <= 0) {
return List.of();
}
List<Message> messages = messageMapper.selectList(new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, conversationId)
.orderByDesc(Message::getCreatedAt)
.last("LIMIT " + limit));
List<Message> copy = new ArrayList<>(messages);
Collections.reverse(copy);
return copy;
}
@Override
public Conversation getConversation(Long id, Long userId) {
return requireOwnedConversation(id, userId);
}
@Override
@Transactional
public void updateConversationTitle(Long id, String title, Long userId) {
Conversation conversation = requireOwnedConversation(id, userId);
conversation.setTitle(title);
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.updateById(conversation);
}
private Conversation requireOwnedConversation(Long conversationId, Long userId) {
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
throw new BusinessException(40400, "会话不存在");
}
if (!userId.equals(conversation.getUserId())) {
throw new BusinessException(40300, "无权限访问该会话");
}
return conversation;
}
}

View File

@@ -0,0 +1,65 @@
package com.hertz.modules.knowledge.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.common.api.ApiResponse;
import com.hertz.modules.knowledge.entity.KnowledgeBase;
import com.hertz.modules.knowledge.entity.KnowledgeDocument;
import com.hertz.modules.knowledge.service.KnowledgeBaseService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/knowledge-bases")
@RequiredArgsConstructor
public class KnowledgeBaseController {
private final KnowledgeBaseService knowledgeBaseService;
@PostMapping
public ApiResponse<KnowledgeBase> createKnowledgeBase(@RequestBody KnowledgeBase kb) {
return ApiResponse.ok(knowledgeBaseService.createKnowledgeBase(kb.getName(), kb.getDescription()));
}
@GetMapping
public ApiResponse<IPage<KnowledgeBase>> page(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(200) int size,
@RequestParam(required = false) String keyword
) {
return ApiResponse.ok(knowledgeBaseService.pageKnowledgeBases(page, size, keyword));
}
@PostMapping("/{id}/upload")
public ApiResponse<Void> uploadDocument(@PathVariable Long id, @RequestParam("file") MultipartFile file) {
knowledgeBaseService.uploadDocument(id, file);
return ApiResponse.ok();
}
@GetMapping("/{id}/documents")
public ApiResponse<List<KnowledgeDocument>> listDocuments(@PathVariable Long id) {
return ApiResponse.ok(knowledgeBaseService.listDocuments(id));
}
@DeleteMapping("/{id}/documents/{docId}")
public ApiResponse<Void> deleteDocument(@PathVariable Long id, @PathVariable Long docId) {
knowledgeBaseService.deleteDocument(id, docId);
return ApiResponse.ok();
}
@PostMapping("/{id}/rebuild")
public ApiResponse<Void> rebuild(@PathVariable Long id) {
knowledgeBaseService.rebuildVectorStore(id);
return ApiResponse.ok();
}
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteKnowledgeBase(@PathVariable Long id) {
knowledgeBaseService.deleteKnowledgeBase(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,18 @@
package com.hertz.modules.knowledge.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("knowledge_base")
public class KnowledgeBase {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String description;
private LocalDateTime createTime;
private Long createBy;
}

View File

@@ -0,0 +1,28 @@
package com.hertz.modules.knowledge.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("knowledge_document")
public class KnowledgeDocument {
@TableId(type = IdType.AUTO)
private Long id;
private Long kbId;
private String originalName;
private String storedName;
private String storedPath;
private String contentType;
private Long sizeBytes;
private String sha256;
private String status;
private Integer chunkCount;
private String errorMessage;
private LocalDateTime createTime;
private Integer deleted;
private LocalDateTime deletedTime;
}

View File

@@ -0,0 +1,9 @@
package com.hertz.modules.knowledge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.knowledge.entity.KnowledgeBase;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeBaseMapper extends BaseMapper<KnowledgeBase> {
}

View File

@@ -0,0 +1,10 @@
package com.hertz.modules.knowledge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.knowledge.entity.KnowledgeDocument;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeDocumentMapper extends BaseMapper<KnowledgeDocument> {
}

View File

@@ -0,0 +1,17 @@
package com.hertz.modules.knowledge.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.modules.knowledge.entity.KnowledgeBase;
import com.hertz.modules.knowledge.entity.KnowledgeDocument;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface KnowledgeBaseService {
KnowledgeBase createKnowledgeBase(String name, String description);
IPage<KnowledgeBase> pageKnowledgeBases(int page, int size, String keyword);
void uploadDocument(Long kbId, MultipartFile file);
List<KnowledgeDocument> listDocuments(Long kbId);
void deleteDocument(Long kbId, Long documentId);
void rebuildVectorStore(Long kbId);
void deleteKnowledgeBase(Long id);
}

View File

@@ -0,0 +1,84 @@
package com.hertz.modules.knowledge.service;
import com.hertz.config.AppPathResolver;
import java.io.File;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class KnowledgeVectorStoreManager {
private final EmbeddingModel embeddingModel;
private final AppPathResolver pathResolver;
@Value("${spring.ai.vectorstore.simple.store.path:vector-store.json}")
private String vectorStorePath;
private final Map<Long, SimpleVectorStore> cache = new ConcurrentHashMap<>();
private final Map<Long, Object> locks = new ConcurrentHashMap<>();
public KnowledgeVectorStoreManager(EmbeddingModel embeddingModel, AppPathResolver pathResolver) {
this.embeddingModel = embeddingModel;
this.pathResolver = pathResolver;
}
public SimpleVectorStore get(Long kbId) {
Object lock = locks.computeIfAbsent(kbId, k -> new Object());
synchronized (lock) {
return cache.computeIfAbsent(kbId, id -> {
SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
File file = resolveStoreFile(id).toFile();
if (file.exists()) {
store.load(file);
}
return store;
});
}
}
public void save(Long kbId) {
Object lock = locks.computeIfAbsent(kbId, k -> new Object());
synchronized (lock) {
SimpleVectorStore store = get(kbId);
store.save(resolveStoreFile(kbId).toFile());
}
}
public void replace(Long kbId, SimpleVectorStore newStore) {
Object lock = locks.computeIfAbsent(kbId, k -> new Object());
synchronized (lock) {
cache.put(kbId, newStore);
newStore.save(resolveStoreFile(kbId).toFile());
}
}
public void delete(Long kbId) {
Object lock = locks.computeIfAbsent(kbId, k -> new Object());
synchronized (lock) {
cache.remove(kbId);
File file = resolveStoreFile(kbId).toFile();
if (file.exists()) {
file.delete();
}
}
}
private Path resolveStoreFile(Long kbId) {
File configured = new File(pathResolver.resolve(vectorStorePath));
File baseDir;
if (vectorStorePath.endsWith(".json")) {
baseDir = configured.getParentFile() == null ? new File(".") : configured.getParentFile();
} else {
baseDir = configured;
}
if (!baseDir.exists()) {
baseDir.mkdirs();
}
return new File(baseDir, "vector-store-kb-" + kbId + ".json").toPath();
}
}

View File

@@ -0,0 +1,278 @@
package com.hertz.modules.knowledge.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hertz.common.exception.BusinessException;
import com.hertz.config.AppPathResolver;
import com.hertz.config.AppProperties;
import com.hertz.modules.knowledge.entity.KnowledgeBase;
import com.hertz.modules.knowledge.entity.KnowledgeDocument;
import com.hertz.modules.knowledge.mapper.KnowledgeBaseMapper;
import com.hertz.modules.knowledge.mapper.KnowledgeDocumentMapper;
import com.hertz.modules.knowledge.service.KnowledgeBaseService;
import com.hertz.modules.knowledge.service.KnowledgeVectorStoreManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.security.MessageDigest;
@Service
@RequiredArgsConstructor
@Slf4j
public class KnowledgeBaseServiceImpl implements KnowledgeBaseService {
private final KnowledgeBaseMapper knowledgeBaseMapper;
private final KnowledgeDocumentMapper knowledgeDocumentMapper;
private final KnowledgeVectorStoreManager vectorStoreManager;
private final EmbeddingModel embeddingModel;
private final AppProperties appProperties;
private final AppPathResolver pathResolver;
private static final Pattern OLLAMA_MODEL_NOT_FOUND = Pattern.compile("model\\\\s+\\\\\\\"([^\\\\\\\"]+)\\\\\\\"\\\\s+not\\\\s+found", Pattern.CASE_INSENSITIVE);
private static final String STATUS_PROCESSING = "PROCESSING";
private static final String STATUS_READY = "READY";
private static final String STATUS_FAILED = "FAILED";
@Override
public KnowledgeBase createKnowledgeBase(String name, String description) {
KnowledgeBase kb = new KnowledgeBase();
kb.setName(name);
kb.setDescription(description);
kb.setCreateTime(LocalDateTime.now());
kb.setCreateBy(com.hertz.security.SecurityUtils.getCurrentUserId());
knowledgeBaseMapper.insert(kb);
return kb;
}
@Override
public IPage<KnowledgeBase> pageKnowledgeBases(int page, int size, String keyword) {
LambdaQueryWrapper<KnowledgeBase> wrapper = new LambdaQueryWrapper<KnowledgeBase>()
.orderByDesc(KnowledgeBase::getCreateTime);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(KnowledgeBase::getName, keyword)
.or().like(KnowledgeBase::getDescription, keyword));
}
return knowledgeBaseMapper.selectPage(Page.of(page, size), wrapper);
}
@Override
public void uploadDocument(Long kbId, MultipartFile file) {
String originalName = file.getOriginalFilename();
log.info("Uploading document to KB {}: {}", kbId, originalName);
if (kbId == null || knowledgeBaseMapper.selectById(kbId) == null) {
throw new BusinessException(40400, "知识库不存在");
}
if (file.isEmpty()) {
throw new BusinessException(40001, "文件不能为空");
}
if (originalName == null || originalName.isBlank()) {
throw new BusinessException(40001, "文件名不能为空");
}
String suffix = "";
int dot = originalName.lastIndexOf(".");
if (dot >= 0 && dot < originalName.length() - 1) {
suffix = originalName.substring(dot);
}
String storedName = UUID.randomUUID().toString().replace("-", "") + suffix;
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String relativePath = normalizePath(appProperties.getUpload().getKnowledgePath())
+ "kb-" + kbId + "/" + datePath + "/" + storedName;
String fullPath = pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + relativePath;
File dest = new File(fullPath);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
KnowledgeDocument docRecord = new KnowledgeDocument();
docRecord.setKbId(kbId);
docRecord.setOriginalName(originalName);
docRecord.setStoredName(storedName);
docRecord.setStoredPath(relativePath);
docRecord.setContentType(file.getContentType());
docRecord.setSizeBytes(file.getSize());
docRecord.setStatus(STATUS_PROCESSING);
docRecord.setChunkCount(0);
docRecord.setCreateTime(LocalDateTime.now());
knowledgeDocumentMapper.insert(docRecord);
try {
file.transferTo(dest);
docRecord.setSha256(sha256Hex(dest));
knowledgeDocumentMapper.updateById(docRecord);
TikaDocumentReader reader = new TikaDocumentReader(new FileSystemResource(dest));
List<Document> documents = reader.read();
for (Document doc : documents) {
doc.getMetadata().put("kbId", kbId);
doc.getMetadata().put("documentId", docRecord.getId());
doc.getMetadata().put("filename", originalName);
}
TokenTextSplitter splitter = new TokenTextSplitter();
List<Document> splitDocuments = splitter.apply(documents);
SimpleVectorStore store = vectorStoreManager.get(kbId);
try {
store.add(splitDocuments);
} catch (RuntimeException e) {
throw translateVectorizeException(e);
}
vectorStoreManager.save(kbId);
log.info("Added {} chunks to vector store for KB {}", splitDocuments.size(), kbId);
docRecord.setStatus(STATUS_READY);
docRecord.setChunkCount(splitDocuments.size());
docRecord.setErrorMessage(null);
knowledgeDocumentMapper.updateById(docRecord);
} catch (Exception e) {
docRecord.setStatus(STATUS_FAILED);
docRecord.setErrorMessage(truncate(e.getMessage(), 1000));
knowledgeDocumentMapper.updateById(docRecord);
throw e instanceof RuntimeException ? (RuntimeException) e : new RuntimeException(e);
}
}
@Override
public List<KnowledgeDocument> listDocuments(Long kbId) {
return knowledgeDocumentMapper.selectList(new LambdaQueryWrapper<KnowledgeDocument>()
.eq(KnowledgeDocument::getKbId, kbId)
.orderByDesc(KnowledgeDocument::getCreateTime));
}
@Override
public void deleteDocument(Long kbId, Long documentId) {
KnowledgeDocument doc = knowledgeDocumentMapper.selectById(documentId);
if (doc == null || !kbId.equals(doc.getKbId())) {
throw new BusinessException(40400, "文档不存在");
}
File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath());
if (file.exists()) {
file.delete();
}
knowledgeDocumentMapper.deleteById(documentId);
rebuildVectorStore(kbId);
}
@Override
public void rebuildVectorStore(Long kbId) {
List<KnowledgeDocument> docs = knowledgeDocumentMapper.selectList(new LambdaQueryWrapper<KnowledgeDocument>()
.eq(KnowledgeDocument::getKbId, kbId)
.eq(KnowledgeDocument::getStatus, STATUS_READY)
.orderByAsc(KnowledgeDocument::getId));
SimpleVectorStore newStore = SimpleVectorStore.builder(embeddingModel).build();
TokenTextSplitter splitter = new TokenTextSplitter();
int totalChunks = 0;
for (KnowledgeDocument doc : docs) {
File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath());
if (!file.exists()) {
continue;
}
TikaDocumentReader reader = new TikaDocumentReader(new FileSystemResource(file));
List<Document> documents = reader.read();
for (Document d : documents) {
d.getMetadata().put("kbId", kbId);
d.getMetadata().put("documentId", doc.getId());
d.getMetadata().put("filename", doc.getOriginalName());
}
List<Document> split = splitter.apply(documents);
newStore.add(split);
totalChunks += split.size();
doc.setChunkCount(split.size());
knowledgeDocumentMapper.updateById(doc);
}
vectorStoreManager.replace(kbId, newStore);
log.info("Rebuilt vector store for KB {} with {} documents and {} chunks", kbId, docs.size(), totalChunks);
}
private RuntimeException translateVectorizeException(RuntimeException e) {
var message = e.getMessage();
if (message == null) {
return e;
}
if (message.contains("[404]") && message.toLowerCase().contains("not found") && message.toLowerCase().contains("try pulling it first")) {
String model = null;
Matcher matcher = OLLAMA_MODEL_NOT_FOUND.matcher(message);
if (matcher.find()) {
model = matcher.group(1);
}
if (model == null || model.isBlank()) {
model = "nomic-embed-text";
}
return new BusinessException(50300, "Ollama 嵌入模型未安装: " + model + "。请先执行ollama pull " + model);
}
return e;
}
private String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
return path.endsWith("/") || path.endsWith("\\") ? path.replace("\\", "/") : path.replace("\\", "/") + "/";
}
private String sha256Hex(File file) {
try (InputStream in = java.nio.file.Files.newInputStream(file.toPath())) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) > 0) {
digest.update(buffer, 0, read);
}
byte[] bytes = digest.digest();
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
private String truncate(String s, int max) {
if (s == null) return null;
if (s.length() <= max) return s;
return s.substring(0, max);
}
@Override
public void deleteKnowledgeBase(Long id) {
List<KnowledgeDocument> docs = knowledgeDocumentMapper.selectList(new LambdaQueryWrapper<KnowledgeDocument>()
.eq(KnowledgeDocument::getKbId, id));
for (KnowledgeDocument doc : docs) {
File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath());
if (file.exists()) {
file.delete();
}
knowledgeDocumentMapper.deleteById(doc.getId());
}
vectorStoreManager.delete(id);
knowledgeBaseMapper.deleteById(id);
}
}

View File

@@ -0,0 +1,22 @@
package com.hertz.modules.monitor.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.modules.monitor.dto.MonitorDto;
import com.hertz.modules.monitor.service.MonitorService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/monitor")
@RequiredArgsConstructor
public class MonitorController {
private final MonitorService monitorService;
@GetMapping("/server")
public ApiResponse<MonitorDto> getServerInfo() {
return ApiResponse.ok(monitorService.getServerInfo());
}
}

View File

@@ -0,0 +1,89 @@
package com.hertz.modules.monitor.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MonitorDto {
private CpuInfo cpu;
private MemInfo mem;
private JvmInfo jvm;
private SysInfo sys;
private List<DiskInfo> disks;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class CpuInfo {
private int cpuNum;
private double total;
private double sys;
private double used;
private double wait;
private double free;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MemInfo {
private double total;
private double used;
private double free;
private double usage;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class JvmInfo {
private double total;
private double max;
private double free;
private String version;
private String home;
private double usage;
private String startTime;
private String runTime;
private String inputArgs;
private String name;
private long gcCount;
private long gcTime;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SysInfo {
private String computerName;
private String computerIp;
private String userDir;
private String osName;
private String osArch;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DiskInfo {
private String dirName;
private String sysTypeName;
private String typeName;
private String total;
private String free;
private String used;
private double usage;
}
}

View File

@@ -0,0 +1,7 @@
package com.hertz.modules.monitor.service;
import com.hertz.modules.monitor.dto.MonitorDto;
public interface MonitorService {
MonitorDto getServerInfo();
}

View File

@@ -0,0 +1,165 @@
package com.hertz.modules.monitor.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.NumberUtil;
import com.hertz.modules.monitor.dto.MonitorDto;
import com.hertz.modules.monitor.service.MonitorService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import oshi.hardware.CentralProcessor.TickType;
import oshi.hardware.GlobalMemory;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.software.os.FileSystem;
import oshi.software.os.OSFileStore;
import oshi.software.os.OperatingSystem;
import oshi.util.Util;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
@Slf4j
@Service
public class MonitorServiceImpl implements MonitorService {
private final SystemInfo si = new SystemInfo();
@Override
public MonitorDto getServerInfo() {
HardwareAbstractionLayer hal = si.getHardware();
OperatingSystem os = si.getOperatingSystem();
return MonitorDto.builder()
.cpu(getCpuInfo(hal.getProcessor()))
.mem(getMemInfo(hal.getMemory()))
.sys(getSysInfo())
.jvm(getJvmInfo())
.disks(getDiskInfo(os))
.build();
}
private MonitorDto.CpuInfo getCpuInfo(CentralProcessor processor) {
long[] prevTicks = processor.getSystemCpuLoadTicks();
Util.sleep(1000);
long[] ticks = processor.getSystemCpuLoadTicks();
long nice = ticks[TickType.NICE.getIndex()] - prevTicks[TickType.NICE.getIndex()];
long irq = ticks[TickType.IRQ.getIndex()] - prevTicks[TickType.IRQ.getIndex()];
long softirq = ticks[TickType.SOFTIRQ.getIndex()] - prevTicks[TickType.SOFTIRQ.getIndex()];
long steal = ticks[TickType.STEAL.getIndex()] - prevTicks[TickType.STEAL.getIndex()];
long cSys = ticks[TickType.SYSTEM.getIndex()] - prevTicks[TickType.SYSTEM.getIndex()];
long user = ticks[TickType.USER.getIndex()] - prevTicks[TickType.USER.getIndex()];
long iowait = ticks[TickType.IOWAIT.getIndex()] - prevTicks[TickType.IOWAIT.getIndex()];
long idle = ticks[TickType.IDLE.getIndex()] - prevTicks[TickType.IDLE.getIndex()];
long totalCpu = user + nice + cSys + idle + iowait + irq + softirq + steal;
// Prevent division by zero
if (totalCpu == 0) totalCpu = 1;
return MonitorDto.CpuInfo.builder()
.cpuNum(processor.getLogicalProcessorCount())
.total(totalCpu)
.sys(NumberUtil.round(cSys * 100.0 / totalCpu, 2).doubleValue())
.used(NumberUtil.round(user * 100.0 / totalCpu, 2).doubleValue())
.wait(NumberUtil.round(iowait * 100.0 / totalCpu, 2).doubleValue())
.free(NumberUtil.round(idle * 100.0 / totalCpu, 2).doubleValue())
.build();
}
private MonitorDto.MemInfo getMemInfo(GlobalMemory memory) {
double total = NumberUtil.div(memory.getTotal(), (1024 * 1024 * 1024), 2);
double free = NumberUtil.div(memory.getAvailable(), (1024 * 1024 * 1024), 2);
double used = NumberUtil.sub(total, free);
double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100);
return MonitorDto.MemInfo.builder()
.total(total)
.used(used)
.free(free)
.usage(usage)
.build();
}
private MonitorDto.SysInfo getSysInfo() {
Properties props = System.getProperties();
return MonitorDto.SysInfo.builder()
.computerName(NetUtil.getLocalHostName())
.computerIp(NetUtil.getLocalhostStr())
.osName(props.getProperty("os.name"))
.osArch(props.getProperty("os.arch"))
.userDir(props.getProperty("user.dir"))
.build();
}
private MonitorDto.JvmInfo getJvmInfo() {
Properties props = System.getProperties();
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
long time = runtime.getStartTime();
// GC Info
long gcCount = 0;
long gcTime = 0;
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gcBean : gcBeans) {
long count = gcBean.getCollectionCount();
long t = gcBean.getCollectionTime();
if (count > 0) gcCount += count;
if (t > 0) gcTime += t;
}
double total = NumberUtil.div(Runtime.getRuntime().totalMemory(), (1024 * 1024), 2);
double max = NumberUtil.div(Runtime.getRuntime().maxMemory(), (1024 * 1024), 2);
double free = NumberUtil.div(Runtime.getRuntime().freeMemory(), (1024 * 1024), 2);
double used = total - free;
double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100);
return MonitorDto.JvmInfo.builder()
.total(total)
.max(max)
.free(free)
.version(props.getProperty("java.version"))
.home(props.getProperty("java.home"))
.name(ManagementFactory.getRuntimeMXBean().getVmName())
.usage(usage)
.startTime(DateUtil.formatDateTime(new Date(time)))
.runTime(DateUtil.formatBetween(new Date(time), new Date()))
.inputArgs(runtime.getInputArguments().toString())
.gcCount(gcCount)
.gcTime(gcTime)
.build();
}
private List<MonitorDto.DiskInfo> getDiskInfo(OperatingSystem os) {
List<MonitorDto.DiskInfo> list = new ArrayList<>();
FileSystem fileSystem = os.getFileSystem();
List<OSFileStore> fileStores = fileSystem.getFileStores();
for (OSFileStore fs : fileStores) {
long free = fs.getUsableSpace();
long total = fs.getTotalSpace();
long used = total - free;
if (total == 0) continue;
double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100);
MonitorDto.DiskInfo disk = MonitorDto.DiskInfo.builder()
.dirName(fs.getMount())
.sysTypeName(fs.getType())
.typeName(fs.getName())
.total(FileUtil.readableFileSize(total))
.free(FileUtil.readableFileSize(free))
.used(FileUtil.readableFileSize(used))
.usage(usage)
.build();
list.add(disk);
}
return list;
}
}

View File

@@ -0,0 +1,115 @@
package com.hertz.modules.system.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.common.exception.BusinessException;
import com.hertz.security.JwtService;
import com.hertz.security.SecurityUtils;
import com.hertz.modules.system.dto.AuthDtos;
import com.hertz.modules.system.mapper.SysRoleMapper;
import com.hertz.modules.system.service.CaptchaService;
import com.hertz.modules.system.service.UserService;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
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;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final SysRoleMapper roleMapper;
private final CaptchaService captchaService;
public AuthController(
UserService userService,
PasswordEncoder passwordEncoder,
JwtService jwtService,
SysRoleMapper roleMapper,
CaptchaService captchaService
) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.roleMapper = roleMapper;
this.captchaService = captchaService;
}
@PostMapping("/register")
public ApiResponse<AuthDtos.MeResponse> register(@Valid @RequestBody AuthDtos.RegisterRequest req) {
captchaService.validateCaptcha(req.uuid(), req.code());
var user = userService.register(req.username(), req.password(), req.nickname());
return ApiResponse.ok(new AuthDtos.MeResponse(
user.getId(),
user.getUsername(),
user.getNickname(),
user.getAvatarPath(),
user.getPhone(),
user.getEmail(),
user.getGender(),
List.of()
));
}
@PostMapping("/login")
public ApiResponse<AuthDtos.LoginResponse> login(@Valid @RequestBody AuthDtos.LoginRequest req) {
captchaService.validateCaptcha(req.uuid(), req.code());
var user = userService.findByUsername(req.username());
if (user == null || user.getStatus() == null || user.getStatus() != 1) {
throw new BusinessException(40003, "用户名或密码错误");
}
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new BusinessException(40003, "用户名或密码错误");
}
var token = jwtService.createToken(user.getId(), user.getUsername());
var roles = roleMapper.selectRoleKeysByUserId(user.getId());
return ApiResponse.ok(new AuthDtos.LoginResponse(token, user.getId(), user.getUsername(), roles));
}
@GetMapping("/me")
public ApiResponse<AuthDtos.MeResponse> me(Authentication authentication) {
var userId = SecurityUtils.getCurrentUserId(authentication);
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
var user = userService.findByUsername(authentication.getName());
var roles = roleMapper.selectRoleKeysByUserId(userId);
return ApiResponse.ok(new AuthDtos.MeResponse(
userId,
authentication.getName(),
user == null ? null : user.getNickname(),
user == null ? null : user.getAvatarPath(),
user == null ? null : user.getPhone(),
user == null ? null : user.getEmail(),
user == null ? null : user.getGender(),
roles
));
}
@PostMapping("/profile")
public ApiResponse<Void> updateProfile(@RequestBody @Valid AuthDtos.UpdateProfileRequest req) {
var userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
userService.updateProfile(userId, req);
return ApiResponse.ok();
}
@PostMapping("/password")
public ApiResponse<Void> updatePassword(@RequestBody @Valid AuthDtos.UpdatePasswordRequest req) {
var userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
userService.updatePassword(userId, req);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,25 @@
package com.hertz.modules.system.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.modules.system.dto.AuthDtos;
import com.hertz.modules.system.service.CaptchaService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth/captcha")
public class CaptchaController {
private final CaptchaService captchaService;
public CaptchaController(CaptchaService captchaService) {
this.captchaService = captchaService;
}
@GetMapping
public ApiResponse<AuthDtos.CaptchaResponse> getCaptcha() {
var res = captchaService.generateCaptcha();
return ApiResponse.ok(res);
}
}

View File

@@ -0,0 +1,69 @@
package com.hertz.modules.system.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.common.exception.BusinessException;
import com.hertz.config.AppPathResolver;
import com.hertz.config.AppProperties;
import java.io.File;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
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/common/file")
@RequiredArgsConstructor
public class FileController {
private final AppProperties appProperties;
private final AppPathResolver pathResolver;
@PostMapping("/upload")
public ApiResponse<UploadResult> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException(400, "File cannot be empty");
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new BusinessException(400, "Original filename is required");
}
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
if (!suffix.equalsIgnoreCase(".jpg") && !suffix.equalsIgnoreCase(".png") && !suffix.equalsIgnoreCase(".jpeg")) {
throw new BusinessException(400, "Only JPG/PNG formats are supported");
}
if (file.getSize() > 2 * 1024 * 1024) {
throw new BusinessException(400, "File size exceeds 2MB");
}
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
String relativePath = appProperties.getUpload().getAvatarPath() + datePath + "/" + fileName;
// Construct full path
String fullPath = pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + relativePath;
File dest = new File(fullPath);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
try {
file.transferTo(dest);
// Return relative path. Frontend should prepend the base URL.
return ApiResponse.ok(new UploadResult("/uploads/" + relativePath, null));
} catch (IOException e) {
throw new BusinessException(500, "File upload failed: " + e.getMessage());
}
}
public record UploadResult(String url, String thumbUrl) {
}
}

View File

@@ -0,0 +1,72 @@
package com.hertz.modules.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.common.api.ApiResponse;
import com.hertz.common.exception.BusinessException;
import com.hertz.security.SecurityUtils;
import com.hertz.modules.system.dto.MenuDto;
import com.hertz.modules.system.entity.SysMenu;
import com.hertz.modules.system.service.MenuService;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/system/menus")
public class MenuController {
private final MenuService menuService;
public MenuController(MenuService menuService) {
this.menuService = menuService;
}
@GetMapping("/tree")
public ApiResponse<List<MenuDto>> tree(Authentication authentication) {
var userId = SecurityUtils.getCurrentUserId(authentication);
if (userId == null) {
throw new BusinessException(40100, "未登录或登录已过期");
}
return ApiResponse.ok(menuService.getMenuTreeByUserId(userId));
}
@GetMapping("/page")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:view')")
public ApiResponse<IPage<SysMenu>> page(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword
) {
return ApiResponse.ok(menuService.pageMenus(page, size, keyword));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:add')")
public ApiResponse<Void> create(@RequestBody SysMenu menu) {
menuService.saveMenu(menu);
return ApiResponse.ok();
}
@PutMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:edit')")
public ApiResponse<Void> update(@RequestBody SysMenu menu) {
menuService.updateMenu(menu);
return ApiResponse.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:remove')")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
menuService.deleteMenu(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,81 @@
package com.hertz.modules.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.common.api.ApiResponse;
import com.hertz.modules.system.entity.SysRole;
import com.hertz.modules.system.service.RoleService;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/system/roles")
public class RoleController {
private final RoleService roleService;
public RoleController(RoleService roleService) {
this.roleService = roleService;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
public ApiResponse<List<SysRole>> list() {
return ApiResponse.ok(roleService.listEnabledRoles());
}
@GetMapping("/page")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
public ApiResponse<IPage<SysRole>> page(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword
) {
return ApiResponse.ok(roleService.pageRoles(page, size, keyword));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:add')")
public ApiResponse<Void> create(@RequestBody SysRole role) {
roleService.saveRole(role);
return ApiResponse.ok();
}
@PutMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:edit')")
public ApiResponse<Void> update(@RequestBody SysRole role) {
roleService.updateRole(role);
return ApiResponse.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:remove')")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
roleService.deleteRole(id);
return ApiResponse.ok();
}
@GetMapping("/{id}/menus")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
public ApiResponse<List<Long>> getRoleMenus(@PathVariable("id") Long id) {
return ApiResponse.ok(roleService.getRoleMenuIds(id));
}
public record UpdateRoleMenusRequest(List<Long> menuIds) {
}
@PutMapping("/{id}/menus")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:assign')")
public ApiResponse<Void> updateRoleMenus(@PathVariable("id") Long id, @RequestBody UpdateRoleMenusRequest req) {
roleService.updateRolePermissions(id, req.menuIds());
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,161 @@
package com.hertz.modules.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hertz.common.api.ApiResponse;
import com.hertz.modules.system.entity.SysRole;
import com.hertz.modules.system.entity.SysUser;
import com.hertz.modules.system.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/system/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
public ApiResponse<IPage<UserListItem>> page(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(200) int size,
@RequestParam(required = false) String keyword
) {
var p = userService.pageUsers(page, size, keyword);
var dtoPage = Page.<UserListItem>of(p.getCurrent(), p.getSize(), p.getTotal());
dtoPage.setRecords(p.getRecords().stream().map(u -> {
var roles = userService.getUserRoles(u.getId()).stream()
.map(SysRole::getRoleName)
.toList();
return new UserListItem(
u.getId(),
u.getUsername(),
u.getNickname(),
u.getAvatarPath(),
u.getPhone(),
u.getEmail(),
u.getGender(),
u.getStatus(),
u.getCreatedAt(),
roles
);
}).toList());
return ApiResponse.ok(dtoPage);
}
public record UserListItem(
Long id,
String username,
String nickname,
String avatarPath,
String phone,
String email,
Integer gender,
Integer status,
LocalDateTime createdAt,
List<String> roles
) {
}
public record CreateUserRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password,
@NotBlank(message = "昵称不能为空") String nickname,
String avatarPath,
String phone,
String email,
Integer gender,
Integer status
) {}
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:add')")
public ApiResponse<Void> create(@RequestBody @Valid CreateUserRequest req) {
var u = new SysUser();
u.setUsername(req.username());
u.setPassword(req.password());
u.setNickname(req.nickname());
u.setAvatarPath(req.avatarPath());
u.setPhone(req.phone());
u.setEmail(req.email());
u.setGender(req.gender());
u.setStatus(req.status());
userService.createUser(u);
return ApiResponse.ok();
}
public record UpdateUserRequest(
Long id,
String password,
@NotBlank(message = "昵称不能为空") String nickname,
String avatarPath,
String phone,
String email,
Integer gender,
Integer status
) {}
@PutMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:edit')")
public ApiResponse<Void> update(@RequestBody @Valid UpdateUserRequest req) {
var u = new SysUser();
u.setId(req.id());
u.setPassword(req.password());
u.setNickname(req.nickname());
u.setAvatarPath(req.avatarPath());
u.setPhone(req.phone());
u.setEmail(req.email());
u.setGender(req.gender());
u.setStatus(req.status());
userService.updateUser(u);
return ApiResponse.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')")
public ApiResponse<Void> delete(@PathVariable Long id) {
userService.deleteUser(id);
return ApiResponse.ok();
}
@DeleteMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')")
public ApiResponse<Void> deleteBatch(@RequestBody List<Long> ids) {
userService.deleteUsers(ids);
return ApiResponse.ok();
}
@GetMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
public ApiResponse<List<Long>> roleIds(@PathVariable("id") long userId) {
return ApiResponse.ok(userService.getUserRoleIds(userId));
}
public record UpdateRolesRequest(List<Long> roleIds) {
}
@PutMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:assign')")
public ApiResponse<Void> updateRoles(@PathVariable("id") long userId, @RequestBody UpdateRolesRequest req) {
userService.updateUserRoles(userId, req == null ? List.of() : req.roleIds());
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,65 @@
package com.hertz.modules.system.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
public class AuthDtos {
public record LoginRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password,
String uuid,
String code
) {
}
public record RegisterRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password,
@NotBlank(message = "昵称不能为空") String nickname,
String uuid,
String code
) {
}
public record LoginResponse(
String token,
long userId,
String username,
List<String> roles
) {
}
public record MeResponse(
long userId,
String username,
String nickname,
String avatarPath,
String phone,
String email,
Integer gender,
List<String> roles
) {
}
public record UpdateProfileRequest(
@NotBlank(message = "昵称不能为空") String nickname,
String avatarPath,
String phone,
String email,
Integer gender
) {
}
public record UpdatePasswordRequest(
@NotBlank(message = "旧密码不能为空") String oldPassword,
@NotBlank(message = "新密码不能为空") String newPassword
) {
}
public record CaptchaResponse(
@com.fasterxml.jackson.annotation.JsonProperty("uuid") String uuid,
@com.fasterxml.jackson.annotation.JsonProperty("img") String img
) {
}
}

View File

@@ -0,0 +1,21 @@
package com.hertz.modules.system.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Data
public class MenuDto {
private Long id;
private Long parentId;
private String type;
private String name;
private String path;
private String component;
private String perms;
private String icon;
private Integer sort;
private Integer status;
private List<MenuDto> children = new ArrayList<>();
}

View File

@@ -0,0 +1,27 @@
package com.hertz.modules.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("sys_menu")
public class SysMenu {
@TableId(type = IdType.AUTO)
private Long id;
private Long parentId;
private String type;
private String name;
private String path;
private String component;
private String perms;
private String icon;
private Integer sort;
private Integer visible;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.hertz.modules.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("sys_role")
public class SysRole {
@TableId(type = IdType.AUTO)
private Long id;
private String roleKey;
private String roleName;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,16 @@
package com.hertz.modules.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("sys_role_menu")
public class SysRoleMenu {
@TableId(type = IdType.AUTO)
private Long id;
private Long roleId;
private Long menuId;
}

View File

@@ -0,0 +1,28 @@
package com.hertz.modules.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("sys_user")
public class SysUser {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String nickname;
private String avatarPath;
private String phone;
private String email;
/**
* 0-未知 1-男 2-女
*/
private Integer gender;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,16 @@
package com.hertz.modules.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("sys_user_role")
public class SysUserRole {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long roleId;
}

View File

@@ -0,0 +1,56 @@
package com.hertz.modules.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.system.entity.SysMenu;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface SysMenuMapper extends BaseMapper<SysMenu> {
@Select("""
SELECT DISTINCT m.*
FROM sys_menu m
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = #{userId}
AND m.status = 1
AND m.visible = 1
AND m.type IN ('D','M','B')
ORDER BY m.sort ASC, m.id ASC
""")
List<SysMenu> selectMenusByUserId(@Param("userId") long userId);
@Select("""
SELECT m.*
FROM sys_menu m
WHERE m.status = 1
AND m.visible = 1
AND m.type IN ('D','M','B')
ORDER BY m.sort ASC, m.id ASC
""")
List<SysMenu> selectAllVisibleMenus();
@Select("""
SELECT DISTINCT m.perms
FROM sys_menu m
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = #{userId}
AND m.status = 1
AND m.perms IS NOT NULL
AND m.perms <> ''
""")
List<String> selectPermsByUserId(@Param("userId") long userId);
@Select("""
SELECT DISTINCT m.perms
FROM sys_menu m
WHERE m.status = 1
AND m.perms IS NOT NULL
AND m.perms <> ''
""")
List<String> selectAllPerms();
}

View File

@@ -0,0 +1,29 @@
package com.hertz.modules.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.system.entity.SysRole;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface SysRoleMapper extends BaseMapper<SysRole> {
@Select("""
SELECT r.role_key
FROM sys_role r
INNER JOIN sys_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.status = 1
""")
List<String> selectRoleKeysByUserId(@Param("userId") long userId);
@Select("""
SELECT r.*
FROM sys_role r
INNER JOIN sys_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.status = 1
ORDER BY r.id ASC
""")
List<SysRole> selectRolesByUserId(@Param("userId") long userId);
}

View File

@@ -0,0 +1,9 @@
package com.hertz.modules.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.system.entity.SysRoleMenu;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysRoleMenuMapper extends BaseMapper<SysRoleMenu> {
}

View File

@@ -0,0 +1,10 @@
package com.hertz.modules.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.system.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

View File

@@ -0,0 +1,10 @@
package com.hertz.modules.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.modules.system.entity.SysUserRole;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
}

View File

@@ -0,0 +1,8 @@
package com.hertz.modules.system.service;
import java.util.Set;
public interface AuthzService {
Set<String> loadAuthorities(long userId);
}

View File

@@ -0,0 +1,8 @@
package com.hertz.modules.system.service;
import com.hertz.modules.system.dto.AuthDtos;
public interface CaptchaService {
AuthDtos.CaptchaResponse generateCaptcha();
void validateCaptcha(String uuid, String code);
}

View File

@@ -0,0 +1,19 @@
package com.hertz.modules.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.modules.system.dto.MenuDto;
import com.hertz.modules.system.entity.SysMenu;
import java.util.List;
public interface MenuService {
List<MenuDto> getMenuTreeByUserId(long userId);
IPage<SysMenu> pageMenus(int page, int size, String keyword);
void saveMenu(SysMenu menu);
void updateMenu(SysMenu menu);
void deleteMenu(Long id);
}

View File

@@ -0,0 +1,22 @@
package com.hertz.modules.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.modules.system.entity.SysRole;
import java.util.List;
public interface RoleService {
List<SysRole> listEnabledRoles();
IPage<SysRole> pageRoles(int page, int size, String keyword);
void saveRole(SysRole role);
void updateRole(SysRole role);
void deleteRole(Long id);
void updateRolePermissions(Long roleId, List<Long> menuIds);
List<Long> getRoleMenuIds(Long roleId);
}

View File

@@ -0,0 +1,34 @@
package com.hertz.modules.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hertz.modules.system.entity.SysRole;
import com.hertz.modules.system.dto.AuthDtos;
import com.hertz.modules.system.entity.SysUser;
import java.util.List;
public interface UserService {
SysUser register(String username, String rawPassword, String nickname);
SysUser findByUsername(String username);
IPage<SysUser> pageUsers(int page, int size, String keyword);
void createUser(SysUser user);
void updateUser(SysUser user);
void updateProfile(Long userId, AuthDtos.UpdateProfileRequest req);
void updatePassword(Long userId, AuthDtos.UpdatePasswordRequest req);
void deleteUser(Long id);
void deleteUsers(List<Long> ids);
void updateUserRoles(long userId, List<Long> roleIds);
List<Long> getUserRoleIds(long userId);
List<SysRole> getUserRoles(long userId);
}

View File

@@ -0,0 +1,36 @@
package com.hertz.modules.system.service.impl;
import com.hertz.modules.system.mapper.SysMenuMapper;
import com.hertz.modules.system.mapper.SysRoleMapper;
import com.hertz.modules.system.service.AuthzService;
import java.util.HashSet;
import org.springframework.stereotype.Service;
@Service
public class AuthzServiceImpl implements AuthzService {
private final SysRoleMapper roleMapper;
private final SysMenuMapper menuMapper;
public AuthzServiceImpl(SysRoleMapper roleMapper, SysMenuMapper menuMapper) {
this.roleMapper = roleMapper;
this.menuMapper = menuMapper;
}
@Override
public HashSet<String> loadAuthorities(long userId) {
var roleKeys = roleMapper.selectRoleKeysByUserId(userId);
var authorities = new HashSet<String>();
for (var roleKey : roleKeys) {
if (roleKey != null && !roleKey.isBlank()) {
authorities.add("ROLE_" + roleKey);
}
}
if (roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r))) {
authorities.addAll(menuMapper.selectAllPerms());
} else {
authorities.addAll(menuMapper.selectPermsByUserId(userId));
}
return authorities;
}
}

View File

@@ -0,0 +1,55 @@
package com.hertz.modules.system.service.impl;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import cn.hutool.core.lang.UUID;
import com.hertz.common.exception.BusinessException;
import com.hertz.modules.system.dto.AuthDtos;
import com.hertz.modules.system.service.CaptchaService;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class CaptchaServiceImpl implements CaptchaService {
private final StringRedisTemplate redisTemplate;
public CaptchaServiceImpl(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public AuthDtos.CaptchaResponse generateCaptcha() {
// Width: 120, Height: 40, Code Count: 4, Interference Line Count: 10
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(120, 40, 4, 10);
String uuid = UUID.fastUUID().toString(true);
String code = lineCaptcha.getCode();
// Store in Redis with 5 minutes expiration
redisTemplate.opsForValue().set("captcha:" + uuid, Objects.requireNonNull(code), 5, TimeUnit.MINUTES);
return new AuthDtos.CaptchaResponse(uuid, lineCaptcha.getImageBase64Data());
}
@Override
public void validateCaptcha(String uuid, String code) {
if (uuid == null || uuid.isBlank() || code == null || code.isBlank()) {
throw new BusinessException(40001, "验证码不能为空");
}
String key = "captcha:" + uuid;
String cachedCode = redisTemplate.opsForValue().get(key);
if (cachedCode == null) {
throw new BusinessException(40002, "验证码已过期");
}
if (!code.equalsIgnoreCase(cachedCode)) {
throw new BusinessException(40003, "验证码错误");
}
// Validate once, then delete to prevent replay
redisTemplate.delete(key);
}
}

View File

@@ -0,0 +1,130 @@
package com.hertz.modules.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hertz.common.exception.BusinessException;
import com.hertz.modules.system.dto.MenuDto;
import com.hertz.modules.system.entity.SysMenu;
import com.hertz.modules.system.entity.SysRoleMenu;
import com.hertz.modules.system.mapper.SysMenuMapper;
import com.hertz.modules.system.mapper.SysRoleMapper;
import com.hertz.modules.system.mapper.SysRoleMenuMapper;
import com.hertz.modules.system.service.MenuService;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MenuServiceImpl implements MenuService {
private final SysMenuMapper menuMapper;
private final SysRoleMapper roleMapper;
private final SysRoleMenuMapper roleMenuMapper;
public MenuServiceImpl(SysMenuMapper menuMapper, SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) {
this.menuMapper = menuMapper;
this.roleMapper = roleMapper;
this.roleMenuMapper = roleMenuMapper;
}
@Override
public List<MenuDto> getMenuTreeByUserId(long userId) {
var roleKeys = roleMapper.selectRoleKeysByUserId(userId);
var menus = roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r))
? menuMapper.selectAllVisibleMenus()
: menuMapper.selectMenusByUserId(userId);
var map = new HashMap<Long, MenuDto>();
for (var m : menus) {
var dto = new MenuDto();
dto.setId(m.getId());
dto.setParentId(m.getParentId());
dto.setType(m.getType());
dto.setName(m.getName());
dto.setPath(m.getPath());
dto.setComponent(m.getComponent());
dto.setPerms(m.getPerms());
dto.setIcon(m.getIcon());
dto.setSort(m.getSort());
dto.setStatus(m.getStatus());
map.put(dto.getId(), dto);
}
var roots = new ArrayList<MenuDto>();
for (var dto : map.values()) {
var parentId = dto.getParentId() == null ? 0L : dto.getParentId();
if (parentId == 0L || !map.containsKey(parentId)) {
roots.add(dto);
} else {
map.get(parentId).getChildren().add(dto);
}
}
Comparator<MenuDto> comparator = Comparator
.comparing((MenuDto d) -> d.getSort() == null ? 0 : d.getSort())
.thenComparing(d -> d.getId() == null ? 0 : d.getId());
sortRecursively(roots, comparator);
return roots;
}
@Override
public IPage<SysMenu> pageMenus(int page, int size, String keyword) {
var wrapper = new LambdaQueryWrapper<SysMenu>().orderByAsc(SysMenu::getSort);
if (keyword != null && !keyword.isBlank()) {
wrapper.like(SysMenu::getName, keyword);
}
return menuMapper.selectPage(Page.of(page, size), wrapper);
}
@Override
@Transactional
public void saveMenu(SysMenu menu) {
menu.setCreatedAt(java.time.LocalDateTime.now());
menu.setUpdatedAt(java.time.LocalDateTime.now());
menuMapper.insert(menu);
}
@Override
@Transactional
public void updateMenu(SysMenu menu) {
var existing = menuMapper.selectById(menu.getId());
if (existing == null) {
throw new BusinessException(404, "Menu not found");
}
existing.setParentId(menu.getParentId());
existing.setType(menu.getType());
existing.setName(menu.getName());
existing.setPath(menu.getPath());
existing.setComponent(menu.getComponent());
existing.setPerms(menu.getPerms());
existing.setIcon(menu.getIcon());
existing.setSort(menu.getSort());
existing.setVisible(menu.getVisible());
existing.setStatus(menu.getStatus());
existing.setUpdatedAt(java.time.LocalDateTime.now());
menuMapper.updateById(existing);
}
@Override
@Transactional
public void deleteMenu(Long id) {
if (menuMapper.selectCount(new LambdaQueryWrapper<SysMenu>().eq(SysMenu::getParentId, id)) > 0) {
throw new BusinessException(400, "Has sub-menus, cannot delete");
}
menuMapper.deleteById(id);
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getMenuId, id));
}
private void sortRecursively(List<MenuDto> nodes, Comparator<MenuDto> comparator) {
nodes.sort(comparator);
for (var n : nodes) {
if (n.getChildren() != null && !n.getChildren().isEmpty()) {
sortRecursively(n.getChildren(), comparator);
}
}
}
}

View File

@@ -0,0 +1,94 @@
package com.hertz.modules.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hertz.common.exception.BusinessException;
import com.hertz.modules.system.entity.SysRole;
import com.hertz.modules.system.entity.SysRoleMenu;
import com.hertz.modules.system.mapper.SysRoleMapper;
import com.hertz.modules.system.mapper.SysRoleMenuMapper;
import com.hertz.modules.system.service.RoleService;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class RoleServiceImpl implements RoleService {
private final SysRoleMapper roleMapper;
private final SysRoleMenuMapper roleMenuMapper;
public RoleServiceImpl(SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) {
this.roleMapper = roleMapper;
this.roleMenuMapper = roleMenuMapper;
}
@Override
public List<SysRole> listEnabledRoles() {
return roleMapper.selectList(new LambdaQueryWrapper<SysRole>()
.eq(SysRole::getStatus, 1)
.orderByAsc(SysRole::getId));
}
@Override
public IPage<SysRole> pageRoles(int page, int size, String keyword) {
var wrapper = new LambdaQueryWrapper<SysRole>().orderByDesc(SysRole::getId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(SysRole::getRoleName, keyword).or().like(SysRole::getRoleKey, keyword));
}
return roleMapper.selectPage(Page.of(page, size), wrapper);
}
@Override
@Transactional
public void saveRole(SysRole role) {
if (roleMapper.selectCount(new LambdaQueryWrapper<SysRole>().eq(SysRole::getRoleKey, role.getRoleKey())) > 0) {
throw new BusinessException(400, "Role key already exists");
}
role.setCreatedAt(java.time.LocalDateTime.now());
role.setUpdatedAt(java.time.LocalDateTime.now());
roleMapper.insert(role);
}
@Override
@Transactional
public void updateRole(SysRole role) {
var existing = roleMapper.selectById(role.getId());
if (existing == null) {
throw new BusinessException(404, "Role not found");
}
existing.setRoleName(role.getRoleName());
existing.setRoleKey(role.getRoleKey());
existing.setStatus(role.getStatus());
existing.setUpdatedAt(java.time.LocalDateTime.now());
roleMapper.updateById(existing);
}
@Override
@Transactional
public void deleteRole(Long id) {
roleMapper.deleteById(id);
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, id));
}
@Override
@Transactional
public void updateRolePermissions(Long roleId, List<Long> menuIds) {
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId));
if (menuIds != null) {
menuIds.stream().distinct().forEach(menuId -> {
SysRoleMenu rm = new SysRoleMenu();
rm.setRoleId(roleId);
rm.setMenuId(menuId);
roleMenuMapper.insert(rm);
});
}
}
@Override
public List<Long> getRoleMenuIds(Long roleId) {
return roleMenuMapper.selectList(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId))
.stream().map(SysRoleMenu::getMenuId).toList();
}
}

View File

@@ -0,0 +1,251 @@
package com.hertz.modules.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hertz.common.exception.BusinessException;
import com.hertz.config.AppPathResolver;
import com.hertz.config.AppProperties;
import com.hertz.modules.system.dto.AuthDtos;
import com.hertz.modules.system.entity.SysRole;
import com.hertz.modules.system.entity.SysUser;
import com.hertz.modules.system.entity.SysUserRole;
import com.hertz.modules.system.mapper.SysRoleMapper;
import com.hertz.modules.system.mapper.SysUserMapper;
import com.hertz.modules.system.mapper.SysUserRoleMapper;
import com.hertz.modules.system.service.UserService;
import java.io.File;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserServiceImpl implements UserService {
private final SysUserMapper userMapper;
private final SysUserRoleMapper userRoleMapper;
private final SysRoleMapper roleMapper;
private final PasswordEncoder passwordEncoder;
private final AppProperties appProperties;
private final AppPathResolver pathResolver;
public UserServiceImpl(
SysUserMapper userMapper,
SysUserRoleMapper userRoleMapper,
SysRoleMapper roleMapper,
PasswordEncoder passwordEncoder,
AppProperties appProperties,
AppPathResolver pathResolver
) {
this.userMapper = userMapper;
this.userRoleMapper = userRoleMapper;
this.roleMapper = roleMapper;
this.passwordEncoder = passwordEncoder;
this.appProperties = appProperties;
this.pathResolver = pathResolver;
}
@Override
public SysUser register(String username, String rawPassword, String nickname) {
var exists = userMapper.selectCount(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username)) > 0;
if (exists) {
throw new BusinessException(40002, "用户名已存在");
}
var user = new SysUser();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword));
user.setNickname(nickname);
user.setStatus(1);
userMapper.insert(user);
return user;
}
@Override
public SysUser findByUsername(String username) {
return userMapper.selectOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username)
.last("LIMIT 1"));
}
@Override
public IPage<SysUser> pageUsers(int page, int size, String keyword) {
var wrapper = new LambdaQueryWrapper<SysUser>()
.orderByDesc(SysUser::getId);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(SysUser::getUsername, keyword)
.or().like(SysUser::getNickname, keyword)
.or().like(SysUser::getPhone, keyword)
.or().like(SysUser::getEmail, keyword));
}
return userMapper.selectPage(Page.of(page, size), wrapper);
}
@Override
@Transactional
public void createUser(SysUser user) {
if (userMapper.selectCount(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, user.getUsername())) > 0) {
throw new BusinessException(400, "用户名已存在");
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setCreatedAt(java.time.LocalDateTime.now());
user.setUpdatedAt(java.time.LocalDateTime.now());
userMapper.insert(user);
}
@Override
@Transactional
public void updateUser(SysUser user) {
var existing = userMapper.selectById(user.getId());
if (existing == null) {
throw new BusinessException(404, "用户不存在");
}
existing.setNickname(user.getNickname());
existing.setPhone(user.getPhone());
existing.setEmail(user.getEmail());
existing.setGender(user.getGender());
existing.setStatus(user.getStatus());
String oldAvatarPath = existing.getAvatarPath();
String newAvatarPath = user.getAvatarPath();
existing.setAvatarPath(newAvatarPath);
existing.setUpdatedAt(java.time.LocalDateTime.now());
if (user.getPassword() != null && !user.getPassword().isBlank()) {
existing.setPassword(passwordEncoder.encode(user.getPassword()));
}
userMapper.updateById(existing);
deleteOldAvatarIfReplaced(oldAvatarPath, newAvatarPath);
}
@Override
@Transactional
public void updateProfile(Long userId, AuthDtos.UpdateProfileRequest req) {
var existing = userMapper.selectById(userId);
if (existing == null) {
throw new BusinessException(404, "用户不存在");
}
existing.setNickname(req.nickname());
String oldAvatarPath = existing.getAvatarPath();
String newAvatarPath = oldAvatarPath;
if (req.avatarPath() != null) {
newAvatarPath = req.avatarPath();
existing.setAvatarPath(req.avatarPath());
}
existing.setPhone(req.phone());
existing.setEmail(req.email());
existing.setGender(req.gender());
existing.setUpdatedAt(java.time.LocalDateTime.now());
userMapper.updateById(existing);
deleteOldAvatarIfReplaced(oldAvatarPath, newAvatarPath);
}
@Override
@Transactional
public void updatePassword(Long userId, AuthDtos.UpdatePasswordRequest req) {
var existing = userMapper.selectById(userId);
if (existing == null) {
throw new BusinessException(404, "用户不存在");
}
if (!passwordEncoder.matches(req.oldPassword(), existing.getPassword())) {
throw new BusinessException(400, "旧密码错误");
}
existing.setPassword(passwordEncoder.encode(req.newPassword()));
existing.setUpdatedAt(java.time.LocalDateTime.now());
userMapper.updateById(existing);
}
@Override
@Transactional
public void deleteUser(Long id) {
userMapper.deleteById(id);
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, id));
}
@Override
@Transactional
public void deleteUsers(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
userMapper.deleteByIds(ids);
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().in(SysUserRole::getUserId, ids));
}
@Override
@Transactional
public void updateUserRoles(long userId, List<Long> roleIds) {
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
if (roleIds == null || roleIds.isEmpty()) {
return;
}
for (var roleId : roleIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList())) {
var ur = new SysUserRole();
ur.setUserId(userId);
ur.setRoleId(roleId);
userRoleMapper.insert(ur);
}
}
@Override
public List<Long> getUserRoleIds(long userId) {
var list = userRoleMapper.selectList(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
return list.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).distinct().toList();
}
@Override
public List<SysRole> getUserRoles(long userId) {
return roleMapper.selectRolesByUserId(userId);
}
private void deleteOldAvatarIfReplaced(String oldAvatarPath, String newAvatarPath) {
if (oldAvatarPath == null || oldAvatarPath.isBlank()) {
return;
}
if (newAvatarPath == null || newAvatarPath.isBlank()) {
return;
}
if (Objects.equals(oldAvatarPath, newAvatarPath)) {
return;
}
String avatarPrefix = "/uploads/" + normalizeRelativePrefix(appProperties.getUpload().getAvatarPath());
if (!oldAvatarPath.replace("\\", "/").startsWith(avatarPrefix)) {
return;
}
String relative = oldAvatarPath.replace("\\", "/");
if (!relative.startsWith("/uploads/")) {
return;
}
relative = relative.substring("/uploads/".length());
File root = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()));
File file = new File(root, relative.replace("/", File.separator));
try {
String rootCanonical = root.getCanonicalPath();
String fileCanonical = file.getCanonicalPath();
if (!fileCanonical.startsWith(rootCanonical)) {
return;
}
} catch (Exception ignored) {
return;
}
if (file.exists()) {
file.delete();
}
}
private String normalizeRelativePrefix(String path) {
if (path == null) return "";
String p = path.replace("\\", "/");
if (!p.endsWith("/")) {
p = p + "/";
}
return p;
}
}

View File

@@ -0,0 +1,38 @@
/**
* Spring Security 用户信息加载服务。
*
* <p>用于根据用户名加载用户信息,供认证与权限体系使用。</p>
*/
package com.hertz.security;
import com.hertz.modules.system.service.UserService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserService userService;
public CustomUserDetailsService(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User not found: " + username);
}
// We are using JWT for auth, so this is mostly to satisfy Spring Security's default config
// or if we wanted to support Basic Auth/Form Login alongside JWT.
// For now, we return a minimal UserDetails implementation.
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles("USER") // Default role, actual authorities are loaded in JwtAuthFilter
.build();
}
}

View File

@@ -0,0 +1,67 @@
/**
* JWT 认证过滤器。
*
* <p>从请求头 Authorization: Bearer token 中解析 JWT并将认证信息写入 SecurityContext。</p>
*/
package com.hertz.security;
import com.hertz.modules.system.service.AuthzService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.stream.Collectors;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final AuthzService authzService;
public JwtAuthFilter(JwtService jwtService, AuthzService authzService) {
this.jwtService = jwtService;
this.authzService = authzService;
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
var header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
var token = header.substring("Bearer ".length()).trim();
try {
var claims = jwtService.parse(token);
var userId = claims.get("uid", Number.class).longValue();
var username = claims.getSubject();
var authorities = authzService.loadAuthorities(userId).stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
auth.setDetails(userId);
SecurityContextHolder.getContext().setAuthentication(auth);
// System.out.println("DEBUG: JWT Auth Success for user: " + username);
} catch (Exception e) {
System.err.println("DEBUG: JWT Auth Failed: " + e.getMessage());
e.printStackTrace();
logger.error("JWT Authentication failed: " + e.getMessage(), e);
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,53 @@
/**
* JWT 工具服务。
*
* <p>负责生成与解析 JWT用于无状态认证。</p>
*/
package com.hertz.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class JwtService {
private final SecretKey key;
private final long expireSeconds;
public JwtService(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expire-seconds}") long expireSeconds
) {
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("app.jwt.secret 至少 32 字节");
}
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expireSeconds = expireSeconds;
}
public String createToken(long userId, String username) {
var now = Instant.now();
return Jwts.builder()
.subject(username)
.claim("uid", userId)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(expireSeconds)))
.signWith(key)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@@ -0,0 +1,65 @@
/**
* Spring Security 配置。
*
* <p>配置无状态会话、跨域策略、接口访问规则,并注册 JWT 认证过滤器。</p>
*/
package com.hertz.security;
import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
var config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
JwtAuthFilter jwtAuthFilter
) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
.requestMatchers("/api/auth/**", "/error", "/uploads/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}

View File

@@ -0,0 +1,30 @@
/**
* 安全上下文工具类。
*
* <p>用于从 Spring Security 上下文中获取当前登录用户信息。</p>
*/
package com.hertz.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() {
}
public static Long getCurrentUserId() {
return getCurrentUserId(SecurityContextHolder.getContext().getAuthentication());
}
public static Long getCurrentUserId(Authentication authentication) {
if (authentication == null) {
return null;
}
var details = authentication.getDetails();
if (details instanceof Number n) {
return n.longValue();
}
return null;
}
}

View File

@@ -0,0 +1,10 @@
{
"properties": [
{
"name": "spring.ai.vectorstore.simple.store.path",
"type": "java.lang.String",
"description": "SimpleVectorStore 持久化存储路径(可为目录或文件路径,项目内按需解析)。"
}
]
}

View File

@@ -0,0 +1,57 @@
server:
port: 8088
spring:
application:
name: Hertz-Springboot
sql:
init:
mode: never # 禁用 SQL 初始化(不自动执行 schema.sql / data.sql可选always默认、never
servlet:
multipart:
max-file-size: 2MB
max-request-size: 10MB
ai:
ollama:
base-url: http://localhost:11434
chat:
# 如果需要快速响应,建议切换为标准模型,如: llama3.1, qwen2.5:7b, deepseek-llm:7b
model: deepseek-llm:7b
options:
temperature: 0.7
embedding:
model: nomic-embed-text
vectorstore:
simple:
store:
path: ./uploads/knowledge/vector_store
datasource:
url: jdbc:mysql://localhost:3306/hertz_springboot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
password:
database: 0
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
app:
jwt:
secret: change-me-to-a-long-random-string-change-me-to-a-long-random-string
expire-seconds: 86400
upload:
root-path: ./uploads
avatar-path: avatar/
knowledge-path: knowledge/
management:
endpoints:
web:
exposure:
include: health,info,metrics

View File

@@ -0,0 +1,26 @@
-- ----------------------------
-- Table structure for conversations
-- ----------------------------
CREATE TABLE IF NOT EXISTS `ai_conversations` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`title` varchar(255) NOT NULL COMMENT '对话标题',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话记录表';
-- ----------------------------
-- Table structure for messages
-- ----------------------------
CREATE TABLE IF NOT EXISTS `ai_messages` (
`id` bigint NOT NULL AUTO_INCREMENT,
`conversation_id` bigint NOT NULL COMMENT '所属对话ID',
`role` enum('user','assistant') NOT NULL COMMENT '消息角色',
`content` text NOT NULL COMMENT '消息内容',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_conversation_id` (`conversation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话消息表';

View File

@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS knowledge_base (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
name VARCHAR(255) NOT NULL COMMENT '知识库名称',
description TEXT COMMENT '知识库描述',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
create_by BIGINT COMMENT '创建人ID'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库';
CREATE TABLE IF NOT EXISTS knowledge_document (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
kb_id BIGINT NOT NULL COMMENT '知识库ID',
original_name VARCHAR(512) NOT NULL COMMENT '原始文件名',
stored_name VARCHAR(512) NOT NULL COMMENT '存储文件名',
stored_path VARCHAR(1024) NOT NULL COMMENT '存储相对路径',
content_type VARCHAR(255) COMMENT '文件类型',
size_bytes BIGINT NOT NULL COMMENT '文件大小(字节)',
sha256 VARCHAR(64) COMMENT '文件SHA256摘要',
status VARCHAR(32) NOT NULL COMMENT '处理状态(PROCESSING/READY/FAILED)',
chunk_count INT DEFAULT 0 COMMENT '分片数量',
error_message VARCHAR(1024) COMMENT '失败原因',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除(软删除标记)',
deleted_time DATETIME COMMENT '删除时间',
KEY idx_kb_id (kb_id),
KEY idx_kb_deleted (kb_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档';

View File

@@ -0,0 +1,14 @@
-- ----------------------------
-- Table structure for sys_monitor_log
-- ----------------------------
DROP TABLE IF EXISTS `sys_monitor_log`;
CREATE TABLE `sys_monitor_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`cpu_usage` double NOT NULL COMMENT 'CPU使用率(%)',
`memory_usage` double NOT NULL COMMENT '内存使用率(%)',
`memory_total` bigint NOT NULL COMMENT '总内存(字节)',
`memory_used` bigint NOT NULL COMMENT '已用内存(字节)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统监控日志表';

View File

@@ -0,0 +1,107 @@
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '加密密码',
`nickname` varchar(50) NOT NULL COMMENT '用户昵称',
`avatar_path` varchar(255) DEFAULT NULL COMMENT '头像路径',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`gender` tinyint(1) DEFAULT '0' COMMENT '0-未知 1-男 2-女',
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_key` varchar(50) NOT NULL COMMENT '角色标识',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`parent_id` bigint DEFAULT '0' COMMENT '父菜单ID',
`type` varchar(10) NOT NULL COMMENT 'D-目录 M-菜单 B-按钮',
`name` varchar(50) NOT NULL COMMENT '菜单名称',
`path` varchar(200) DEFAULT NULL COMMENT '路由路径',
`component` varchar(200) DEFAULT NULL COMMENT '组件路径',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
`sort` int DEFAULT '0' COMMENT '排序',
`visible` tinyint DEFAULT '1' COMMENT '0-隐藏 1-显示',
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
PRIMARY KEY (`id`),
KEY `idx_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint NOT NULL COMMENT '角色ID',
`menu_id` bigint NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`id`),
KEY `idx_role_menu` (`role_id`,`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';
-- ----------------------------
-- Init Data
-- ----------------------------
INSERT INTO `sys_role` (`id`, `role_key`, `role_name`) VALUES
(1, 'ADMIN', '管理员');
INSERT INTO `sys_menu` (`id`, `parent_id`, `type`, `name`, `path`, `component`, `perms`, `icon`, `sort`) VALUES
(1, 0, 'M', '仪表盘', '/admin/dashboard', 'admin/Dashboard', NULL, 'DataLine', 0),
(2, 0, 'D', '系统管理', '/admin/system', NULL, NULL, 'Setting', 10),
(3, 2, 'M', '用户管理', '/admin/system/user', 'admin/system/User', 'system:user:view', 'User', 0),
(4, 2, 'M', '角色管理', '/admin/system/role', 'admin/system/Role', 'system:role:view', 'Tickets', 1),
(5, 2, 'M', '菜单管理', '/admin/system/menu', 'admin/system/Menu', 'system:menu:view', 'Menu', 2);
INSERT INTO `sys_user` (`id`, `username`, `password`, `nickname`, `phone`, `email`, `gender`, `status`) VALUES
(1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '管理员', '18888888888', 'hertz@hertz.com', 1, 1),
(2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', '普通用户', '13888888888', 'demo@hertz.com', 1, 1);
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES
(1, 1);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5);

View File

@@ -0,0 +1,13 @@
package com.hertz;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class HertzApplicationTests {
@Test
void contextLoads() {
}
}

1
ui/.env.dev Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8088

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hertz Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

20
ui/jsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"strict": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.js", "src/**/*.vue", "vite.config.js"],
"exclude": ["node_modules"]
}

2058
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
ui/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"element-plus": "^2.13.1",
"marked": "^17.0.1",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
ui/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

3
ui/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

29
ui/src/api/auth.js Normal file
View File

@@ -0,0 +1,29 @@
import { http } from './http'
export async function getCaptcha() {
const { data } = await http.get('/api/auth/captcha')
return data.data
}
export async function login(req) {
const { data } = await http.post('/api/auth/login', req)
return data.data
}
export async function register(req) {
const { data } = await http.post('/api/auth/register', req)
return data.data
}
export async function me() {
const { data } = await http.get('/api/auth/me')
return data.data
}
export async function updateProfile(data) {
await http.post('/api/auth/profile', data)
}
export async function updatePassword(data) {
await http.post('/api/auth/password', data)
}

39
ui/src/api/chat.js Normal file
View File

@@ -0,0 +1,39 @@
import { http as request } from './http'
export const chatApi = {
// Create Conversation
async createConversation(title) {
const { data } = await request.post('api/ai/conversations', { title })
return data.data
},
// Get List
async getConversations() {
const { data } = await request.get('api/ai/conversations')
return data.data
},
// Delete
async deleteConversation(id) {
const { data } = await request.delete(`api/ai/conversations/${id}`)
return data.data
},
// Update Title
async updateConversation(id, title) {
const { data } = await request.put(`api/ai/conversations/${id}`, { title })
return data.data
},
// Search
async searchConversations(query) {
const { data } = await request.get('api/ai/conversations/search', { params: { query } })
return data.data
},
// Save Message
async saveMessage(conversationId, role, content) {
const { data } = await request.post(`api/ai/conversations/${conversationId}/messages`, { role, content })
return data.data
},
// Get Messages
async getMessages(conversationId) {
const { data } = await request.get(`api/ai/conversations/${conversationId}/messages`)
return data.data
}
}

12
ui/src/api/common.js Normal file
View File

@@ -0,0 +1,12 @@
import { http } from './http'
export async function uploadFile(file) {
const formData = new FormData()
formData.append('file', file)
const { data } = await http.post('/api/common/file/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data.data
}

52
ui/src/api/http.js Normal file
View File

@@ -0,0 +1,52 @@
import axios from 'axios'
import { useAuthStore } from '../stores/auth'
import { useMenuStore } from '../stores/menu'
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
http.interceptors.response.use(
(resp) => resp,
(err) => {
if (err?.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
const menu = useMenuStore()
menu.reset()
import('../router')
.then(({ default: router }) => {
const current = router.currentRoute.value
if (current.path.startsWith('/admin') && current.path !== '/login') {
router.replace({ path: '/login', query: { redirect: current.fullPath } })
}
})
.catch(() => {})
}
if (err?.response?.status === 403) {
import('../router')
.then(({ default: router }) => {
const current = router.currentRoute.value
const path = current.path
const target = path.startsWith('/admin') ? '/admin/403' : '/portal/403'
if (path !== target) {
router.replace({ path: target, query: { from: current.fullPath } })
}
})
.catch(() => {
if (window.location.pathname !== '/portal/403') window.location.replace('/portal/403')
})
}
return Promise.reject(err)
},
)

47
ui/src/api/knowledge.js Normal file
View File

@@ -0,0 +1,47 @@
import { http as request } from './http'
export const knowledgeApi = {
// Get List
async getKnowledgeBases(params) {
const { data } = await request.get('api/knowledge-bases', { params })
return data.data
},
// Create
async createKnowledgeBase(data) {
const { data: res } = await request.post('api/knowledge-bases', data)
return res.data
},
// Upload Document
async uploadDocument(id, file) {
const formData = new FormData()
formData.append('file', file)
const { data } = await request.post(`api/knowledge-bases/${id}/upload`, formData)
return data
},
// List Documents
async listDocuments(id) {
const { data } = await request.get(`api/knowledge-bases/${id}/documents`)
return data.data
},
// Delete Document
async deleteDocument(id, docId) {
const { data } = await request.delete(`api/knowledge-bases/${id}/documents/${docId}`)
return data.data
},
// Rebuild Vector Store
async rebuildVectorStore(id) {
const { data } = await request.post(`api/knowledge-bases/${id}/rebuild`)
return data.data
},
// Delete
async deleteKnowledgeBase(id) {
const { data } = await request.delete(`api/knowledge-bases/${id}`)
return data.data
}
}

6
ui/src/api/monitor.js Normal file
View File

@@ -0,0 +1,6 @@
import { http } from './http'
export async function getServerInfo() {
const { data } = await http.get('/api/monitor/server')
return data
}

85
ui/src/api/system.js Normal file
View File

@@ -0,0 +1,85 @@
import { http } from './http'
export async function fetchMenuTree() {
const { data } = await http.get('/api/system/menus/tree')
return data.data
}
export async function pageMenus(params) {
const { data } = await http.get('/api/system/menus/page', { params })
return data.data
}
export async function createMenu(data) {
await http.post('/api/system/menus', data)
}
export async function updateMenu(data) {
await http.put('/api/system/menus', data)
}
export async function deleteMenu(id) {
await http.delete(`/api/system/menus/${id}`)
}
export async function fetchUsers(params) {
const { data } = await http.get('/api/system/users', { params })
return data.data
}
export async function createUser(data) {
await http.post('/api/system/users', data)
}
export async function updateUser(data) {
await http.put('/api/system/users', data)
}
export async function deleteUser(id) {
await http.delete(`/api/system/users/${id}`)
}
export async function deleteUsers(ids) {
await http.delete('/api/system/users', { data: ids })
}
export async function fetchRoles() {
const { data } = await http.get('/api/system/roles')
return data.data
}
export async function pageRoles(params) {
const { data } = await http.get('/api/system/roles/page', { params })
return data.data
}
export async function createRole(data) {
await http.post('/api/system/roles', data)
}
export async function updateRole(data) {
await http.put('/api/system/roles', data)
}
export async function deleteRole(id) {
await http.delete(`/api/system/roles/${id}`)
}
export async function fetchRoleMenuIds(roleId) {
const { data } = await http.get(`/api/system/roles/${roleId}/menus`)
return data.data
}
export async function updateRoleMenus(roleId, menuIds) {
await http.put(`/api/system/roles/${roleId}/menus`, { menuIds })
}
export async function fetchUserRoleIds(userId) {
const { data } = await http.get(`/api/system/users/${userId}/roles`)
return data.data
}
export async function updateUserRoles(userId, roleIds) {
const { data } = await http.put(`/api/system/users/${userId}/roles`, { roleIds })
return data.data
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 226 KiB

Some files were not shown because too many files have changed in this diff Show More