“提交项目”
48
.gitignore
vendored
Normal 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
@@ -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
@@ -0,0 +1,116 @@
|
|||||||
|
# Hertz Admin
|
||||||
|
|
||||||
|
Hertz Admin 是一个基于 Spring Boot 3 和 Vue 3 的前后端分离架构的轻量级权限管理平台。系统集成了用户管理、角色管理、菜单管理等核心功能,采用 RBAC(Role-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`。
|
||||||
|
|
||||||
|
### AI(Ollama)准备
|
||||||
|
|
||||||
|
本项目的 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
@@ -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
@@ -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>
|
||||||
14
src/main/java/com/hertz/HertzApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/java/com/hertz/common/api/ApiResponse.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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, "系统异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/com/hertz/common/filter/RequestLogFilter.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/main/java/com/hertz/config/AppPathResolver.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/java/com/hertz/config/AppProperties.java
Normal 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/";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/java/com/hertz/config/MybatisPlusConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
src/main/java/com/hertz/config/WebMvcConfig.java
Normal 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 + "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/main/java/com/hertz/modules/ai/controller/AiController.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main/java/com/hertz/modules/ai/dto/ChatRequest.java
Normal 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;
|
||||||
|
}
|
||||||
18
src/main/java/com/hertz/modules/ai/entity/Conversation.java
Normal 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;
|
||||||
|
}
|
||||||
21
src/main/java/com/hertz/modules/ai/entity/Message.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/main/java/com/hertz/modules/monitor/dto/MonitorDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.hertz.modules.monitor.service;
|
||||||
|
|
||||||
|
import com.hertz.modules.monitor.dto.MonitorDto;
|
||||||
|
|
||||||
|
public interface MonitorService {
|
||||||
|
MonitorDto getServerInfo();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/com/hertz/modules/system/dto/AuthDtos.java
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
21
src/main/java/com/hertz/modules/system/dto/MenuDto.java
Normal 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<>();
|
||||||
|
}
|
||||||
|
|
||||||
27
src/main/java/com/hertz/modules/system/entity/SysMenu.java
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
20
src/main/java/com/hertz/modules/system/entity/SysRole.java
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
28
src/main/java/com/hertz/modules/system/entity/SysUser.java
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.hertz.modules.system.service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface AuthzService {
|
||||||
|
Set<String> loadAuthorities(long userId);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/main/java/com/hertz/security/JwtAuthFilter.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
53
src/main/java/com/hertz/security/JwtService.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
src/main/java/com/hertz/security/SecurityConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
src/main/java/com/hertz/security/SecurityUtils.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "spring.ai.vectorstore.simple.store.path",
|
||||||
|
"type": "java.lang.String",
|
||||||
|
"description": "SimpleVectorStore 持久化存储路径(可为目录或文件路径,项目内按需解析)。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
57
src/main/resources/application.yml
Normal 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
|
||||||
26
src/main/resources/schema/ai_schema.sql
Normal 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='对话消息表';
|
||||||
26
src/main/resources/schema/knowledge_schema.sql
Normal 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='知识库文档';
|
||||||
14
src/main/resources/schema/monitor_schema.sql
Normal 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='系统监控日志表';
|
||||||
107
src/main/resources/schema/schema.sql
Normal 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);
|
||||||
13
src/test/java/com/hertz/HertzApplicationTests.java
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=http://localhost:8088
|
||||||
13
ui/index.html
Normal 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
@@ -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
24
ui/package.json
Normal 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
|
After Width: | Height: | Size: 14 KiB |
BIN
ui/public/logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
3
ui/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
29
ui/src/api/auth.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
BIN
ui/src/assets/img/default_avatar.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
ui/src/assets/img/empty.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
ui/src/assets/img/profile_bg.jpg
Normal file
|
After Width: | Height: | Size: 841 KiB |
1
ui/src/assets/login_svg/bg_1.svg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
ui/src/assets/login_svg/bg_2.png
Normal file
|
After Width: | Height: | Size: 938 KiB |
1
ui/src/assets/login_svg/bg_2.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
ui/src/assets/login_svg/bg_3.svg
Normal file
|
After Width: | Height: | Size: 141 KiB |
1
ui/src/assets/login_svg/bg_4.svg
Normal file
|
After Width: | Height: | Size: 226 KiB |