This commit is contained in:
Wang 2025-12-25 09:31:52 +08:00
parent e06820c124
commit 068f16ffaa
2390 changed files with 163943 additions and 81 deletions

87
.gitignore vendored
View File

@ -1,79 +1,8 @@
# ---> JetBrains /.idea/
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider /*/target/
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 /*/*/target/
/seer-common/target/
# User-specific stuff .flattened-pom.xml
.idea/**/workspace.xml /*/.flattened-pom.xml
.idea/**/tasks.xml /*/*/.flattened-pom.xml
.idea/**/usage.statistics.xml /http_cache/
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

271
README.md
View File

@ -1,3 +1,270 @@
# seer-teach-cloud-21 # Seer Teach Cloud - 智能教育AI云平台
jdk 21 [![Java](https://img.shields.io/badge/java-21-blue.svg)](https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html)
[![Spring Boot](https://img.shields.io/badge/spring--boot-3.5.9-brightgreen.svg)](https://spring.io/projects/spring-boot)
[![Spring Cloud](https://img.shields.io/badge/spring--cloud-2025.0.1-brightgreen.svg)](https://spring.io/projects/spring-cloud)
## 项目概述
**Seer Teach Cloud** 是一个基于Spring Cloud微服务架构的智能教育AI云平台集成了先进的AI技术提供个性化学习、智能批改、知识图谱构建等功能。该平台专为现代教育场景设计支持多终端访问包括智能设备、移动端和Web端。
## 架构特点
### 微服务架构
- **服务拆分**:按业务领域拆分为多个独立的微服务模块
- **技术栈**Spring Boot 3.5.9 + Spring Cloud 2025.0.1 + Spring Cloud Alibaba
- **网关层**Spring Cloud Gateway统一入口支持路由、限流、鉴权
- **注册中心**Nacos服务注册与发现
- **配置中心**Nacos统一配置管理
### AI智能教育引擎
- **大语言模型集成**支持阿里云百炼、火山引擎等多种AI平台
- **智能批改**AI自动批改作业和试卷提供详细解析
- **个性化推荐**:基于学习行为的智能推荐系统
- **知识点拆分**AI自动拆分和构建知识图谱
- **智能问答**:支持自然语言交互的智能答疑
### 核心功能模块
#### 1. 教师服务 (seer-teacher)
- 知识点管理与拆分
- 教学目标生成
- 讲课稿与音视频生成
- 学生学习报告分析
- AI辅助教学工具
#### 2. 用户服务 (seer-user)
- 用户注册与登录
- 权限管理
- 用户扩展信息管理
- 等级与积分系统
#### 3. 商城服务 (seer-mall)
- 商品管理与分类
- 订单处理
- 购物车功能
- 支付集成
#### 4. 支付服务 (seer-pay)
- 学豆系统(虚拟币)
- 充值与消费
- 订单支付
- 退款处理
#### 5. 物联网服务 (seer-iot)
- 设备管理
- 硬件交互
- 数据采集与分析
#### 6. 消息推送 (seer-netty)
- 实时消息推送
- IM通信
- 音视频流处理
#### 7. 微信服务 (seer-mp)
- 微信公众号集成
- 小程序支持
- OAuth认证
#### 8. 开放API (seer-open-api)
- API网关
- 第三方集成
- 访问控制
## 技术栈
### 后端技术
- **基础框架**: Spring Boot 3.5.9 / Spring Cloud 2025.0.1
- **微服务**: Spring Cloud Alibaba, Nacos, OpenFeign
- **持久层**: MyBatis-Plus 3.5.15, MySQL, Redis
- **消息队列**: RocketMQ
- **任务调度**: XXL-Job
- **API文档**: SpringDoc OpenAPI
- **权限认证**: Sa-Token
- **对象存储**: MinIO
- **序列化**: Protobuf, Fastjson2
- **模板引擎**: Freemarker, Velocity
### AI与数据处理
- **AI平台集成**: 阿里云百炼、火山引擎
- **大模型调用**: LLM API集成
- **文本转语音**: AI语音合成
- **智能批改**: OCR + AI分析
- **知识图谱**: 知识点拆分与关联
### 网络通信
- **Netty**: 高性能网络通信框架
- **Protobuf**: 高效序列化协议
- **SSE**: Server-Sent Events实时通信
- **MQ**: RocketMQ消息队列
## 项目结构
```
seer-teach-cloud-21/
├── seer-dependencies/ # 依赖管理
├── seer-common/ # 通用组件
│ ├── common/ # 通用工具类
│ ├── common-auth-scan/ # 认证扫描
│ ├── common-cache/ # 缓存组件
│ ├── common-config/ # 配置管理
│ ├── ... # 其他通用模块
├── seer-gateway/ # API网关
├── seer-teacher/ # 教师服务
│ ├── seer-teacher-api/ # 教师API接口
│ ├── seer-teacher-service/ # 教师业务服务
│ ├── seer-teacher-service-admin/ # 教师管理服务
│ └── ... # 其他教师模块
├── seer-user/ # 用户服务
├── seer-mall/ # 商城服务
├── seer-pay/ # 支付服务
├── seer-iot/ # 物联网服务
├── seer-netty/ # 网络通信服务
├── seer-mp/ # 微信服务
└── seer-open-api/ # 开放API服务
```
## 快速开始
### 环境要求
- Java 21+
- Maven 3.8+
- MySQL 8.0+
- Redis 6.0+
- Nacos Server
- MinIO (可选)
### 本地开发
1. **克隆项目**
```bash
git clone https://github.com/seer-teach-cloud/seer-teach-cloud-21.git
cd seer-teach-cloud-21
```
2. **配置环境**
```bash
# 配置数据库连接、Redis连接等
# 修改各模块的 application.yml 文件
```
3. **编译项目**
```bash
mvn clean install -Dmaven.test.skip=true
```
4. **启动服务**
```bash
# 1. 启动 Nacos Server
# 2. 启动 MySQL 和 Redis
# 3. 按顺序启动各微服务模块
```
5. **本地开发执行命令**:
```shell
mvn flatten:clean
mvn clean install '-Dmaven.test.skip=true'
```
## API接口设计规范
### RESTful接口设计
- 资源使用复数名词(如/users单个资源通过ID标识如/users/{id}
- 避免URL中出现动词操作通过HTTP方法区分
- 统一返回JSON格式包含code、message、data字段
### HTTP方法与操作映射
| 方法 | 场景 | 状态码示例 |
| ------ | ---------------------------- | ---------------- |
| GET | 获取资源(支持过滤参数) | 200, 304, 404 |
| POST | 创建资源需返回Location头 | 201, 400, 415 |
| PUT | 全量更新资源 | 200, 409冲突 |
| DELETE | 删除资源 | 204, 404 |
### 过滤与分页参数
- 使用 `?limit=10&offset=0``?page=1&per_page=10` 控制返回数据量
- 支持排序(`?sort=field&order=asc`)和条件筛选(`?status=active`
## 数据库设计规范
### 表结构设计
- 表名使用复数如user改为users主键为id外键用关联表名_id如user_id
- 字段命名使用小驼峰如create_time避免保留字
### 索引优化
- 主键自动创建聚簇索引,唯一字段添加唯一索引
- 高频查询字段添加普通索引,复合索引遵循"最左前缀原则"
### 事务与连接池
- 使用@Transactional注解管理事务,明确传播行为
## 数据库迁移规范
### 版本化迁移脚本 (Versioned Migrations)
- 命名格式V + 版本号 + 双下划线 + 描述 + .sql
- 示例V20240619__init.sql、V1.5.0__add_user_table.sql
### 可重复迁移脚本 (Repeatable Migrations)
- 命名格式R + 双下划线 + 描述 + .sql
- 示例R__update_view.sql
## AI功能模块
### 支持的AI场景
- 知识点生成示例题目
- 知识点生成题目
- 教材解析
- 知识点拆分与检查
- 举一反三题目生成
- 教学目标生成
- 讲课稿生成
- 文本转语音
### AI平台支持
- 阿里云百炼平台
- 火山引擎(即梦)
- 本地模型
## 开发规范
### 代码规范
- 遵循Java编程规范
- 使用Lombok简化代码
- 使用MapStruct进行对象映射
- 统一日志记录
### 测试规范
- 单元测试覆盖率达到80%以上
- 集成测试覆盖核心业务流程
- 性能测试确保系统稳定性
## 部署说明
### 生产环境部署
1. 配置生产环境的数据库、Redis、Nacos等中间件
2. 编译打包各微服务模块
3. 使用Docker容器化部署
4. 配置负载均衡和监控
### Docker部署
```bash
# 构建Docker镜像
mvn spring-boot:build-image
# 使用Docker Compose部署
docker-compose up -d
```
## 监控与运维
### 监控指标
- 应用性能监控APM
- 业务指标监控
- 系统资源监控
- AI服务调用监控
---
**Seer Teach Cloud** - 让AI赋能教育让学习更智能

163
pom.xml Normal file
View File

@ -0,0 +1,163 @@
<?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>
<groupId>com.seer.teach</groupId>
<artifactId>seer-teach-cloud-21</artifactId>
<version>1.0.0-SNAPSHOT</version>
<description>seer-teach-cloud-21</description>
<packaging>pom</packaging>
<modules>
<module>seer-dependencies</module>
<module>seer-common</module>
<module>seer-teacher</module>
<module>seer-mall</module>
<module>seer-netty</module>
<module>seer-gateway</module>
<module>seer-pay</module>
<module>seer-user</module>
<module>seer-iot</module>
<module>seer-admin</module>
<module>seer-mp</module>
<module>seer-open-api</module>
</modules>
<properties>
<revision>1.0.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<!-- Plugin versions -->
<maven-surefire-plugin.version>3.2.2</maven-surefire-plugin.version>
<maven-compiler-plugin.version>3.14.0</maven-compiler-plugin.version>
<flatten-maven-plugin.version>1.7.3</flatten-maven-plugin.version>
</properties>
<dependencies>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
</dependency>
<!-- Maven -->
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.activation</groupId>
<artifactId>jakarta.activation-api</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<!--Guava-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.seer.teach</groupId>
<artifactId>seer-dependencies</artifactId>
<version>${revision}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<encoding>UTF-8</encoding>
<debug>true</debug>
<debuglevel>lines,vars,source</debuglevel>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>${flatten-maven-plugin.version}</version>
<configuration>
<flattenMode>bom</flattenMode>
<updatePomFile>true</updatePomFile>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>package</phase>
<goals>
<goal>flatten</goal>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>central</id>
<url>https://maven.aliyun.com/repository/central</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>huawei</id>
<name>huawei</name>
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
</repository>
</repositories>
</project>

23
seer-admin/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM eclipse-temurin:21-jre
# 设置时区
ENV TZ=Asia/Shanghai
# 创建应用目录
RUN mkdir -p /app/java/seer-admin
WORKDIR /app/java/seer-admin
# 复制 JAR 文件
COPY ./target/*.jar app.jar
# 复制 entrypoint 脚本
COPY entrypoint.sh /entrypoint.sh
# 确保脚本可执行
RUN chmod +x /entrypoint.sh
# 创建临时卷(可选)
VOLUME /tmp
# 设置入口点(使用 Exec 模式)
ENTRYPOINT ["/entrypoint.sh"]

155
seer-admin/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,155 @@
cdpipeline {
agent any
tools {
jdk 'jdk21'
}
parameters {
string(
name: 'DEPLOY_SERVERS',
defaultValue: '192.168.0.238',
description: '部署的目标服务器多个目标服务器以英文逗号分隔192.168.0.47,192.168.0.79'
)
choice(
name: 'spring.profiles.active',
choices: ['dev','test', 'prod'],
description: '选择要激活的 Spring Profile'
)
}
environment {
BASE_IMAGE_NAME = 'seer-admin'
SSH_CREDENTIALS_ID = 'seerTeachPubKey'
DEPLOY_USER = 'root'
LOGS_HOST_PATH = '/app/java/seer-admin/logs'
LOGS_CONTAINER_PATH = '/app/java/seer-admin/logs'
}
options {
timeout(time: 30, unit: 'MINUTES')
skipDefaultCheckout(true)
}
stages {
stage('初始化 & 分支校验') {
steps {
script {
sh 'echo "初始Git工作目录: $(pwd)"'
sh 'ls -la '
def scmVars = checkout scm
env.GIT_COMMIT = scmVars.GIT_COMMIT
env.GIT_BRANCH = scmVars.GIT_BRANCH
// 清理分支名称,替换非法字符,确保符合 Docker 标签命名规范
env.BRANCH_CLEAN = env.GIT_BRANCH.replaceAll(/[^\w.-]/, '-')
env.IMAGE_TAG = "${BASE_IMAGE_NAME}:${env.BRANCH_CLEAN}"
env.TAR_FILE = "${BASE_IMAGE_NAME}_${env.BRANCH_CLEAN}.tar"
echo " 初始化完成"
echo "原始分支:${env.GIT_BRANCH}"
echo "清理后分支:${env.BRANCH_CLEAN}"
echo "镜像标签:${env.IMAGE_TAG}"
echo "目标服务器:${params.DEPLOY_SERVERS}"
}
}
}
stage('构建 Maven 项目') {
steps {
sh 'echo "初始工作目录: $(pwd)"'
sh 'ls -la '
sh 'mvn clean package -Dmaven.test.skip=true -pl :seer-admin -am'
echo " Maven 构建完成"
}
}
stage('构建 Docker 镜像') {
steps {
dir('seer-admin') {
sh "docker build -t ${env.IMAGE_TAG} ."
echo " Docker 镜像构建成功:${env.IMAGE_TAG}"
}
}
}
stage('导出并传输镜像') {
steps {
script {
sh 'echo "导出并传输镜像工作目录: $(pwd)"'
sh "docker save -o ${env.TAR_FILE} ${env.IMAGE_TAG}"
echo "镜像已导出为文件:${env.TAR_FILE}"
def servers = params.DEPLOY_SERVERS.split(',')
servers.each { server ->
sshagent([env.SSH_CREDENTIALS_ID]) {
def sev = server.trim()
sh "scp -o StrictHostKeyChecking=no ${env.TAR_FILE} ${DEPLOY_USER}@${sev}:/tmp/"
}
echo " 镜像已传输到远程服务器:${server}"
}
}
}
}
stage('远程部署镜像') {
steps {
script {
def servers = params.DEPLOY_SERVERS.split(',')
servers.each { server ->
deployToServer(server.trim())
}
}
}
}
}
post {
always {
script {
if (env.TAR_FILE?.trim()) {
sh "rm -f ${env.TAR_FILE} || true"
}
deleteDir()
}
}
success {
echo " 构建成功:分支 ${env.GIT_BRANCH},镜像 ${env.IMAGE_TAG}"
}
failure {
echo " 构建失败:分支 ${env.GIT_BRANCH}"
}
}
}
def deployToServer(server){
sshagent([env.SSH_CREDENTIALS_ID]) {
sh """
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${server} << 'EOF'
set -e
echo "🛠 加载镜像..."
docker load -i /tmp/${env.TAR_FILE}
echo " 停止并移除旧容器(如果存在)..."
docker stop ${BASE_IMAGE_NAME} || true
docker rm ${BASE_IMAGE_NAME} || true
echo " 创建日志目录..."
mkdir -p ${LOGS_HOST_PATH}
echo " 启动新容器..."
echo "激活的 Profile: ${params['spring.profiles.active']}"
docker run -d --name ${BASE_IMAGE_NAME} -p 6060:6060 \\
-v ${LOGS_HOST_PATH}:${LOGS_CONTAINER_PATH} \\
-e NACOS_DISCOVERY_IP=${server} \\
-e SPRING_PROFILES_ACTIVE=${params['spring.profiles.active']} \\
--restart=always \\
${env.IMAGE_TAG}
EOF
"""
echo " 镜像已部署到远程服务器:${server}"
}
}

82
seer-admin/entrypoint.sh Normal file
View File

@ -0,0 +1,82 @@
#!/bin/sh
# =============================================================================
# Docker Entrypoint Script for seer-admin
# - 支持 JAVA_OPTS 覆盖
# - 添加默认 JVM 参数
# - 支持优雅关闭 (exec)
# - 可扩展(健康检查、依赖等待等)
# =============================================================================
set -eu
log() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}
warn() {
echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
log "Starting seer-admin..."
# -----------------------------------------------------------------------------
# 设置默认环境变量
# -----------------------------------------------------------------------------
SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-prod}"
NACOS_DISCOVERY_IP="${NACOS_DISCOVERY_IP:-$(hostname -i)}"
# -----------------------------------------------------------------------------
# 合并环境变量和默认 JVM 参数
# -----------------------------------------------------------------------------
# 用户通过 -e JAVA_OPTS 传递的参数优先
USER_JAVA_OPTS="${JAVA_OPTS:-}"
# 内置默认 JVM 参数
DEFAULT_JAVA_OPTS="
-Xms1g
-Xmx1g
-Xmn512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+UseContainerSupport
-XX:+UseNUMA
-XX:+ParallelRefProcEnabled
-XX:+UseStringDeduplication
-XX:MaxRAMPercentage=70.0
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./user_heapdump.hprof
-Djava.security.egd=file:/dev/./urandom
"
# 合并:用户参数 + 默认参数
if [ -z "$USER_JAVA_OPTS" ]; then
JAVA_OPTS="$DEFAULT_JAVA_OPTS"
log "使用内置默认 JVM 参数"
else
JAVA_OPTS="$USER_JAVA_OPTS"
log "使用用户提供的 JVM 参数"
fi
# -----------------------------------------------------------------------------
# 可选:打印环境变量(调试用)
# -----------------------------------------------------------------------------
log "Spring Profiles Active: $SPRING_PROFILES_ACTIVE"
log "Nacos Discovery IP: $NACOS_DISCOVERY_IP"
log "JVM Options: $JAVA_OPTS"
# -----------------------------------------------------------------------------
# 执行主应用(关键:使用 exec让 Java 成为 PID=1
# -----------------------------------------------------------------------------
exec java \
${JAVA_OPTS} \
-Dspring.profiles.active="${SPRING_PROFILES_ACTIVE}" \
-Dspring.cloud.nacos.discovery.ip="${NACOS_DISCOVERY_IP}" \
-jar /app/java/seer-admin/app.jar "$@"

147
seer-admin/pom.xml Normal file
View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-teach-cloud-21</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>seer-admin</artifactId>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-web</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-cache</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-config</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-user-service-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-mall-service-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-pay-service-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-mp-service-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-teacher-service-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>seer-iot-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- Redis 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Sa-Token 权限认证在线文档https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- 配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
</dependencies>
<build>
<!-- 设置构建的 jar 包名 -->
<finalName>${project.artifactId}</finalName>
<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
<includes>
<include>**.properties</include>
<include>**.xml</include>
<include>**.yml</include>
<include>certs/**.pem</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.5.9</version>
<configuration>
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<mainClass>com.seer.teach.admin.SeerAdminApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,38 @@
package com.seer.teach.admin;
import cn.hutool.core.net.NetUtil;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Slf4j
@EnableFeignClients(basePackages = "com.seer.teach.*.api")
@SpringBootApplication(scanBasePackages = "com.seer",exclude = QuartzAutoConfiguration.class)
@EnableTransactionManagement
@EnableAspectJAutoProxy
@EnableAsync
@ServletComponentScan
@MapperScan(basePackages={"com.seer.**.mapper"})
public class SeerAdminApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(SeerAdminApplication.class);
Environment env = app.run(args).getEnvironment();
String port = env.getProperty("server.port", "8080");
String contextPath = env.getProperty("server.servlet.context-path", "/");
log.info("----------------------------------------------------------");
log.info("---------Application '{}' is running-------------------",env.getProperty("spring.application.name"));
log.info("-----------Local : http://localhost:{}{} --------------",port,contextPath);
log.info("--------------Ip : http://{}:{}{} --------------------", NetUtil.getLocalhostStr(),port,contextPath);
log.info("---------Swagger : http://{}:{}{}/swagger-ui/index.html",NetUtil.getLocalhostStr(),port,contextPath);
log.info("----------------------------------------------------------");
}
}

View File

@ -0,0 +1,95 @@
package com.seer.teach.admin.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
/**
* @Author: Captain
* @Autograph: 安稳
* @Description: SpringDoc OpenAPI配置类
* @Date: 2022-11-03 02:01:09
*/
@Configuration
@Profile({"local","dev","test"})
public class AdminApiConfig {
@Value("${server.servlet.context-path:/}")
private String contextPath;
@Bean
public OpenAPI adminOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Seer Teach 后台管理系统API")
.description("Seer Teach 后台管理系统API文档")
.version("v1.0.0")
.contact(new Contact()
.name("广东用嘉研发部")))
.components(new io.swagger.v3.oas.models.Components()
.addSecuritySchemes("token",
new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name("token")
.description("输入sa-token令牌进行认证")))
.addSecurityItem(new SecurityRequirement().addList("token"))
.addServersItem(new Server().url(contextPath));
}
@Bean
public GroupedOpenApi mallAdminApi() {
return GroupedOpenApi.builder()
.group("admin-mall")
.pathsToMatch("/mall/**")
.build();
}
@Bean
public GroupedOpenApi payAdminApi() {
return GroupedOpenApi.builder()
.group("admin-pay")
.pathsToMatch("/pay/**")
.build();
}
@Bean
public GroupedOpenApi userAdminApi() {
return GroupedOpenApi.builder()
.group("admin-user")
.pathsToMatch("/user/**")
.build();
}
@Bean
public GroupedOpenApi tvAdminApi() {
return GroupedOpenApi.builder()
.group("admin-tv")
.pathsToMatch("/tv/**")
.build();
}
@Bean
public GroupedOpenApi teacherAdminApi() {
return GroupedOpenApi.builder()
.group("admin-teacher")
.pathsToMatch("/teacher/**")
.build();
}
@Bean
public GroupedOpenApi commonAdminApi() {
return GroupedOpenApi.builder()
.group("admin-common")
.pathsToMatch("/common/**")
.build();
}
}

View File

@ -0,0 +1,52 @@
package com.seer.teach.admin.config;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
@Configuration
public class SaTokenConfigure {
/**
* 注册 [Sa-Token全局过滤器]设置顺序在CORS过滤器之后
*/
//@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
.addInclude("/**")
.addExclude("/favicon.ico", "/user/login", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**")
.setAuth(obj -> {
if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) {
return;
}
SaRouter.match("/**", r -> {
// 排除多个不需要认证的接口
SaRouter.match("/user/login");
SaRouter.match("/user/register");
SaRouter.match("/swagger-ui/**");
SaRouter.match("/v3/api-docs/**");
SaRouter.match("/swagger-resources/**");
// 其他接口都需要登录
StpUtil.checkLogin();
});
})
.setError(e -> {
return SaResult.code(HttpStatus.UNAUTHORIZED.value());
})
.setBeforeAuth(r -> {
SaHolder.getResponse()
// 是否启用浏览器默认XSS防护 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时停止渲染页面
.setHeader("X-XSS-Protection", "1; mode=block")
// 禁用浏览器内容嗅探
.setHeader("X-Content-Type-Options", "nosniff");
});
}
}

View File

@ -0,0 +1,202 @@
server:
port: 6060
servlet:
context-path: /seer/admin
spring:
application:
name: seer-admin # 服务名称
main:
allow-bean-definition-overriding: true
allow-circular-references: true
mvc:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
url: jdbc:mysql://localhost:3306/seer_teach?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&connectTimeout=10000&socketTimeout=60000
data:
redis:
# Redis数据库索引默认为0
database: 0
# Redis服务器地址
host: 192.168.0.45
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码默认为空
password: Zs139768
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
mvc:
pathmatch:
matching-strategy: ant_path_matcher
flyway:
enabled: true
locations: classpath:db/mysql
baseline-on-migrate: true
clean-disable: true
table: flyway_schema_history_admin
cloud:
nacos:
discovery:
server-addr: 192.168.0.39:8848 # Nacos 服务地址
group: DEFAULT_GROUP # 分组默认DEFAULT_GROUP
namespace: ${spring.profiles.active}
config:
server-addr: 192.168.0.39:8848 # 配置中心地址
file-extension: yaml # 配置文件后缀yaml/properties
namespace: ${spring.profiles.active}
#日志
logging:
config: classpath:logback-${spring.profiles.active}.xml
# 公众号配置(必填),参见 https://github.com/Wechat-Group/WxJava/blob/develop/spring-boot-starters/wx-java-mp-spring-boot-starter/README.md 文档
wx:
mp:
config-storage:
type: RedisTemplate
key-prefix: wx
http-client-type: HttpClient
app-id: null
miniapp:
appid: wx04019010242d540e
secret: d9280058237ee5e3dd81bafd72e5cf3b
msgDataFormat: XML
config-storage:
type: RedisTemplate
key-prefix: wx
feign:
okhttp:
enabled: true
httpclient:
enabled: false
mybatis-plus:
type-aliases-package: com.seer.teach.entity
mapper-locations: classpath:mapper/*.xml,classpath*:com/seer/teach/**/*Mapper.xml
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
configuration:
map-underscore-to-camel-case: true
use-generated-keys: true
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
shutdown:
enabled: false
#极兔
logistics:
jt:
apiAccount: '178337126125932605'
privateKey: '0258d71b55fc45e3ad7a7f38bf4b201a'
customerCode: 'J0086474299'
customerPwd: 'H5CD3zE6'
createOrderUrl: 'https://uat-openapi.jtexpress.com.cn/webopenplatformapi/api/order/addOrder'
queryTraceUrl: 'https://uat-openapi.jtexpress.com.cn/webopenplatformapi/api/logistics/trace'
#Minio配置
minio:
bucket: seerteach
url: http://192.168.0.39:9300
accessKey: oCkIBnlSHPstC9opjava
secretKey: sizdZmYHKQhFAZIFC2FnZ7BhRzSd3TGmTlXIAIzu
aiCorrect: aiCorrect/
avatar: avatar/
wxQrCodeImage: wxQrCodeImage/
aiAudio: aiAudio/
aiChatAudio: aiChatAudio/
aiSmartAudio: aiSmartAudio/
aiHealthArticleAudio: aiSmartHealthArticleAudio/
aiPreview: aiPreview/
aiReview: aiReview/
userAudios: userAudios/
chatLogFolder: chatLogFolder/
androidFolder: android/
banner: banner/
sa-token:
# cookie:
# same-site: None
# secure: false
# token 名称(同时也是 cookie 名称)
token-name: token
# token 有效期(单位:秒) 默认30天-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: false
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: true
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik
token-style: uuid
# 是否输出操作日志
is-log: true
#大模型配置
largeModel:
ip: http://192.168.0.28:8878/
aiCorrectToPoint: ai_correct_to_point
cancelAiCorrect: cancel_ai_correct
aiCorrectToPointToChallenge: ai_correct_to_point_to_challenge
questionsCorrect: questions_correct
getChapters: get_chapters_context
aiQuestionCorrect: ai_evaluation_correct
aiChat: ai_chat
getQuestions: get_questions
getPlanQuestions: get_plan_questions
study: study
vcr: vcr
tts: tts
stt: stt
sseAiChat: sseChat
sseStudy: sseStudy
sseAiCorrect: ahom_sseAiCorrect
getComment: get_comment
recommendChapters: recommend_chapters
planCoverImage: http://192.168.0.39:9300/seerteach/systemConfig/planConver/planImage.png
ocr: http://192.168.0.28:8099/bboxAndOrder
processUpload: http://192.168.0.19:7007/processBase64
getContextAboutSingleQuestion: http://192.168.0.28:8099/getContextAboutSingleQuestion
getTextBase64: http://192.168.0.28:8099/getTextBase64
zs:
decryption:
enabled: true
encryption:
enabled: true
task:
clean-minio:
enabled: false
online-device:
enabled: false
refresh-wx-access-token:
enabled: false
text:
similarity:
service:
url: http://192.168.0.108:5000/text-similarity-batch
cors:
origins:
- http://localhost:*
- http://192.168.*:*
- http://10.10.*:*
- http://10.10.4.143:8020
- https://admin-api.seerteach.net:*
- http://admin-api.seerteach.net:*
- https://admin.seerteach.net:*
- https://admin.seerteach.net

View File

@ -0,0 +1,104 @@
server:
port: 6060
servlet:
context-path: /seer/admin
multipart:
max-file-size: 50MB
max-request-size: 50MB
spring:
application:
name: seer-admin
main:
allow-bean-definition-overriding: true
allow-circular-references: true
mvc:
profiles:
active: dev
mvc:
pathmatch:
matching-strategy: ant_path_matcher
flyway:
enabled: true
locations: classpath:db/mysql
baseline-on-migrate: true
clean-disable: true
table: flyway_schema_history_admin
config:
import:
- optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml
- optional:nacos:shared-database.yaml
- optional:nacos:shared-redis.yaml
cloud:
nacos:
discovery:
server-addr: 192.168.0.39:8848 # Nacos 服务地址
group: DEFAULT_GROUP # 分组默认DEFAULT_GROUP
namespace: ${spring.profiles.active}
config:
server-addr: 192.168.0.39:8848 # 配置中心地址
file-extension: yaml # 配置文件后缀yaml/properties
namespace: ${spring.profiles.active}
#日志
logging:
config: classpath:logback-${spring.profiles.active}.xml
# 公众号配置(必填),参见 https://github.com/Wechat-Group/WxJava/blob/develop/spring-boot-starters/wx-java-mp-spring-boot-starter/README.md 文档
wx:
mp:
config-storage:
type: RedisTemplate
key-prefix: wx
http-client-type: HttpClient
app-id: null
miniapp:
appid: wx04019010242d540e
secret: d9280058237ee5e3dd81bafd72e5cf3b
msgDataFormat: XML
config-storage:
type: RedisTemplate
key-prefix: wx
feign:
okhttp:
enabled: true
httpclient:
enabled: false
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
shutdown:
enabled: false
zs:
decryption:
enabled: true
encryption:
enabled: true
task:
clean-minio:
enabled: false
online-device:
enabled: false
refresh-wx-access-token:
enabled: false
text:
similarity:
service:
url: http://192.168.0.108:5000/text-similarity-batch
cors:
origins:
- http://localhost:*
- http://192.168.*:*
- http://10.10.*:*
- http://10.10.4.143:8020
- https://admin-api.seerteach.net:*
- http://admin-api.seerteach.net:*
- https://admin.seerteach.net:*
- https://admin.seerteach.net

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCmWTZURv0fHtyn
nr9AZMe6CpkVTMVxVtAY0363wZowGQOEBulY7NuZUF2rrt5nkBsYKwN7h8cj7c5s
tA6Yx2KSdGegq5+T3Jbcc19vo1N+fvRKWiFZ22k3DFR8Iup+LGK1/nESvggAEr7P
+sam2ODvd1kGJxVI/5rViy7oKTF5MNpwIUNnoS92qr9nHVopOkU8pYID5beek+jE
nIAZfXvrOIK1XvqnEGH9/3PGJ/i02DGb2vqr1Kll14Xwa8hJuKUcHCF2gwKtMUTu
Z6ybuCp0UIzszSvdsAFTn97hQEPClcFsff6mWfL0K/0dc1dHvm1468NsroZyYyWJ
ynBzVeC/AgMBAAECggEBAIafgFZoNQVwhoao9IJ6jSDE3urb/JYi+bp9vvmbltsC
A1Rf+4zZ80Z6QbRlitwpRaQje2gHlGRBWmOivIVsJxv7VLo06qpRRU4XmM7SUQn4
WF+r3X3JEbdZJS5pW3jNFv3Oc1gFrpfQk9fhTc9NiYyC++r8yj8PjRDw2P9OBxnZ
3uuB8uRuP9dh8ED5y4PcIfNh/I5BCulqz8zpIFP2GjkOE6whaHF9r7wlx933sheg
+JgLkzQiVfCL1kO5aLdbywbttjb2DDXBiOfiX3IZFod+epyjCqrXag+YdTC4molr
oDGHVZJYVujCdszZt5QFD376oP/uK1MnDLNoJnN6tMECgYEA21FuDizAQ1S+m5O+
YNiu0lPH74MQHZTEh/EPA4gsviQPr9TN1maHbXy94wxTX9I5q/T3Nh2aDibbvcjQ
5vcV86RoYE+T+DN8ax65x2Tgq6uWxLi51DkKkrwdd2zyZZ4CDNdGIgV7z3dAMfDo
i8hC6GDP/oQF7q5jiZqeFzUfH+8CgYEAwivGuaxvtJGpiGkHPawjbwzzT+mdbDEc
Ue60EMMigpFXCn1h08bLbEG/lNvZ8ecrp49B2p/x+64HPwoEy7bzp6f8NTPjLkpq
V54uCeAzDiUOX/DSVGZwX0u/Ht1B7hSjadca9ras4XGBCcers5P2y/+lYRlax6jh
TsCwD6CnfDECgYEAxtdLKrrUDbeVoMQQxQlvZu3ixXpUcB1jGcUqUY9y0Wksd8Q+
YvZOLqv8FRAlvyiAdTEBuSSZed8tNyIMlHrMgjs7DqbXhx5W3V/cG7WQJNTLOswo
XwrgVS0Moiw6kHrzbOT4hvvlxrFdmGnMzH7ieoDb0uur3TxqrmVqk6vr7i0CgYB1
yO5ctXBxnaa0m9mLnL9F3xo9kJ4xAj2GqgFK5cQqZhXhxBsyxzWg7uVTXGYB6tQ9
aZZuE3ZL0M6Oe/paxRlay3kfoOEftH57tfWBgiIWY34rzr8X+agS9rTx+Q/EZ3qV
eqndnQSUITFAiIHshkZAi0x78VBzK0u5ZQOoBzFyEQKBgQCRDnHLsfjBf6tQa8E/
xghQYVuBN117c9EMLdTcSZ/jDpjuybJFwgnleuQ6kuNa7Ye90jJsvWSvpRYOQYkL
xDMDgOLhBoBFysMUodFMS/hsLkQHaiPfIaS/6AY5Xbv/NdZQxe9OybVBxJWzHT+L
lKJ6JV8s7P5/eXpoq3yivOMclA==
-----END PRIVATE KEY-----

View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Windows-->
<configuration>
<contextName>community</contextName>
<!--设置日志所在的目录-->
<property name="LOG_PATH" value="/app/java/seer-admin/logs"/>
<!--设置当前项目的名字。一般放在data目录下还会有其它项目的日志文件所以需要设置一个-->
<property name="Logging" value="seer-admin"/>
<property name="CONSOLE_LOG_PATTERN" value="%red(%date{yyyy-MM-dd HH:mm:ss}) %highlight(%-5level) %red([%thread]) %boldMagenta(%logger{50}) [%file:%line] %cyan(%msg%n)"/>
<!-- 错误级别的日志 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_error.log</file>
<!--设置文件超出最大容量后的处理方式为根据时间新建一个文件-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!--设置文件的最大容量-->
<maxFileSize>50MB</maxFileSize>
<!--设置文件的过期时间30天-->
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<!--设置修改日志的方式为追加的方式-->
<append>true</append>
<!--进行编码-->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器只记录错误级别的日志使用LevelFilter进行精确匹配-->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 警告级别的日志 -->
<appender name="FILE_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_warn.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器记录warn及以上级别的日志使用ThresholdFilter设置阈值-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>warn</level>
</filter>
</appender>
<!-- 信息级别 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器记录info及以上级别的日志使用ThresholdFilter设置阈值-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!-- 日志输出到控制台 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!--设置当前项目的日志级别com.zky下的所有日志级别设置为debug模式-->
<logger name="com.baomidou.mybatisplus" level="info"/>
<logger name="com.seer.teach" level="info"/>
<logger name="io.lettuce" level="info"/>
<logger name="org.mybatis" level="info"/>
<logger name="org.apache.ibatis" level="info"/>
<logger name="org.springframework.data.redis" level="info"/>
<logger name="com.alibaba.nacos.shaded.io.grpc" level="info"/>
<logger name="com.alibaba.nacos.client.naming" level="WARN"/>
<logger name="org.apache.http.impl.conn" level="info"/>
<!--将整个项目的日志设置为info级别-->
<root level="info">
<appender-ref ref="FILE_ERROR"/>
<appender-ref ref="FILE_WARN"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Windows-->
<configuration>
<contextName>community</contextName>
<!--设置日志所在的目录-->
<property name="LOG_PATH" value="/app/java/seer-admin/logs"/>
<!--设置当前项目的名字。一般放在data目录下还会有其它项目的日志文件所以需要设置一个-->
<property name="Logging" value="seer-admin"/>
<property name="CONSOLE_LOG_PATTERN" value="%red(%date{yyyy-MM-dd HH:mm:ss}) %highlight(%-5level) %red([%thread]) %boldMagenta(%logger{50}) [%file:%line] %cyan(%msg%n)"/>
<!-- 错误级别的日志 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_error.log</file>
<!--设置文件超出最大容量后的处理方式为根据时间新建一个文件-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!--设置文件的最大容量-->
<maxFileSize>50MB</maxFileSize>
<!--设置文件的过期时间30天-->
<maxHistory>30</maxHistory>
</rollingPolicy>
<!--设置修改日志的方式为追加的方式-->
<append>true</append>
<!--进行编码-->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器如果是error级别的信息则接受否则拒绝-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>error</level>
</filter>
</appender>
<!-- 警告级别的日志 -->
<appender name="FILE_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_warn.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>warn</level>
</filter>
</appender>
<!-- 信息级别 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!-- 日志输出到控制台 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!--设置当前项目的日志级别com.zky下的所有日志级别设置为debug模式-->
<logger name="com.baomidou.mybatisplus" level="info"/>
<logger name="com.seer.teach" level="info"/>
<logger name="io.lettuce" level="info"/>
<logger name="org.mybatis" level="info"/>
<logger name="org.apache.ibatis" level="info"/>
<logger name="org.springframework.data.redis" level="info"/>
<logger name="com.alibaba.nacos.shaded.io.grpc" level="info"/>
<logger name="com.alibaba.nacos.client.naming" level="WARN"/>
<logger name="org.apache.http.impl.conn" level="info"/>
<!--将整个项目的日志设置为debug级别-->
<root level="info">
<appender-ref ref="FILE_ERROR"/>
<appender-ref ref="FILE_WARN"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Windows-->
<configuration>
<contextName>seer-admin</contextName>
<!--设置日志所在的目录-->
<property name="LOG_PATH" value="/app/java/seer-admin/logs"/>
<!--设置当前项目的名字。一般放在data目录下还会有其它项目的日志文件所以需要设置一个-->
<property name="Logging" value="seer-admin"/>
<!-- 错误级别的日志 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_error.log</file>
<!--设置文件超出最大容量后的处理方式为根据时间新建一个文件-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!--设置文件的最大容量-->
<maxFileSize>50MB</maxFileSize>
<!--设置文件的过期时间30天-->
<maxHistory>30</maxHistory>
</rollingPolicy>
<!--设置修改日志的方式为追加的方式-->
<append>true</append>
<!--进行编码-->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器如果是error级别的信息则接受否则拒绝-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>error</level>
</filter>
</appender>
<!-- 警告级别的日志 -->
<appender name="FILE_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_warn.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>warn</level>
</filter>
</appender>
<!-- 信息级别 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!-- 日志输出到控制台 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>debug</level>
</filter>
</appender>
<!--设置当前项目的日志级别com.zky下的所有日志级别设置为debug模式-->
<logger name="com.baomidou.mybatisplus" level="debug"/>
<logger name="com.seer.teach" level="debug"/>
<logger name="com.alibaba.nacos.client.naming" level="WARN"/>
<!--将整个项目的日志设置为debug级别-->
<root level="debug">
<appender-ref ref="FILE_ERROR"/>
<appender-ref ref="FILE_WARN"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Windows-->
<configuration>
<contextName>community</contextName>
<!--设置日志所在的目录-->
<property name="LOG_PATH" value="/app/java/seer-admin/logs"/>
<!--设置当前项目的名字。一般放在data目录下还会有其它项目的日志文件所以需要设置一个-->
<property name="Logging" value="seer-admin"/>
<!-- 错误级别的日志 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_error.log</file>
<!--设置文件超出最大容量后的处理方式为根据时间新建一个文件-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!--设置文件的最大容量-->
<maxFileSize>50MB</maxFileSize>
<!--设置文件的过期时间30天-->
<maxHistory>30</maxHistory>
</rollingPolicy>
<!--设置修改日志的方式为追加的方式-->
<append>true</append>
<!--进行编码-->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<!--设置过滤器如果是error级别的信息则接受否则拒绝-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>error</level>
</filter>
</appender>
<!-- 警告级别的日志 -->
<appender name="FILE_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_warn.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>warn</level>
</filter>
</appender>
<!-- 信息级别 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/log_info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<append>true</append>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>info</level>
</filter>
</appender>
<!-- 日志输出到控制台 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>debug</level>
</filter>
</appender>
<!--设置当前项目的日志级别com.zky下的所有日志级别设置为debug模式-->
<logger name="com.baomidou.mybatisplus" level="debug"/>
<logger name="com.seer.teach" level="debug"/>
<logger name="com.alibaba.nacos.client.naming" level="WARN"/>
<!--将整个项目的日志设置为debug级别-->
<root level="debug">
<appender-ref ref="FILE_ERROR"/>
<appender-ref ref="FILE_WARN"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,3 @@
shenmapstruct.unmappedTargetPolicy=IGNORE
mapstruct.unmappedSourcePolicy=IGNORE
mapstruct.nullValuePropertyMappingStrategy=IGNORE

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-auth-scan</artifactId>
<dependencies>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,29 @@
package com.seer.teach.common.auth.controller;
import com.seer.teach.common.auth.dto.PermissionDTO;
import com.seer.teach.common.auth.service.PermissionScannerService;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@RestController
@Tag(name = "权限扫描")
@RequestMapping("/internal")
public class PermissionScanController {
private final PermissionScannerService permissionScannerService;
@Operation(summary = "扫描权限注解")
@GetMapping("/permissions")
public List<PermissionDTO> scanPermissions() {
return permissionScannerService.scanPermissionAnnotations();
}
}

View File

@ -0,0 +1,37 @@
package com.seer.teach.common.auth.dto;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@Getter
@Setter
public class PermissionDTO {
private Integer id;
private String module;
private String title;
private String className;
private Integer type;
private List<ApiItem> apiItems;
@Data
public static class ApiItem {
private String title;
private String className;
private String authorityCode;
private String module;
private String httpMethod;
private String apiPath;
private String methodName;
private Integer type;
private Integer sort;
}
}

View File

@ -0,0 +1,138 @@
package com.seer.teach.common.auth.service;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.seer.teach.common.auth.dto.PermissionDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@Slf4j
@Component
public class PermissionScannerService {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private Environment environment;
public List<PermissionDTO> scanPermissionAnnotations() {
String contextPath = environment.getProperty("server.servlet.context-path", "");
String module = environment.getProperty("spring.application.name");
contextPath = StringUtils.hasText(contextPath) ? contextPath : "";
Map<String, Object> controllers = applicationContext.getBeansWithAnnotation(RestController.class);
List<PermissionDTO> permissions = new ArrayList<>();
for (Object controller : controllers.values()) {
Class<?> clazz = AopUtils.getTargetClass(controller);
Optional<PermissionDTO> permission = scanMethodAnnotations(clazz,contextPath,module);
permission.ifPresent(permissions::add);
}
return permissions;
}
private Optional<PermissionDTO> scanMethodAnnotations(Class<?> clazz,String contextPath,String module) {
if(!clazz.isAnnotationPresent(Tag.class)){
return Optional.empty();
}
String tagName = clazz.getAnnotation(Tag.class).name();
if( !StringUtils.hasText(tagName)){
return Optional.empty();
}
PermissionDTO permission = new PermissionDTO();
permission.setModule(module);
permission.setTitle(tagName);
permission.setClassName(clazz.getName());
permission.setType(2);
List<PermissionDTO.ApiItem> apiItems = new ArrayList<>();
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(SaCheckPermission.class)) {
SaCheckPermission annotation = method.getAnnotation(SaCheckPermission.class);
if (Objects.isNull(annotation)) {
continue;
}
for (String perm : annotation.value()) {
log.info("permission: {}", perm);
if(StringUtils.hasText(perm)){
PermissionDTO.ApiItem apiItem = new PermissionDTO.ApiItem();
apiItem.setModule(module);
apiItem.setTitle(extractInterfaceName(method));
apiItem.setAuthorityCode(perm);
apiItem.setHttpMethod(getHttpMethod(method));
apiItem.setApiPath(extractApiPath(clazz,contextPath, method));
apiItem.setClassName(clazz.getName());
apiItem.setMethodName(method.getName());
apiItem.setType(3);
apiItems.add(apiItem);
}
}
}
}
permission.setApiItems(apiItems);
return Optional.of(permission);
}
private String extractApiPath(Class<?> clazz,String contextPath, Method method) {
String basePath = contextPath;
RequestMapping annotation = clazz.getAnnotation(RequestMapping.class);
if (Objects.nonNull(annotation)) {
basePath += annotation.value()[0];
}
String methodMapping = getHttpMethodPath(method);
log.info("basePath: {}, methodMapping: {}", basePath, methodMapping);
return basePath + methodMapping;
}
private String extractInterfaceName(Method method) {
Operation annotation = method.getAnnotation(Operation.class);
if (Objects.nonNull(annotation)) {
return annotation.summary();
}
return null;
}
private String getHttpMethod(Method method) {
if (method.isAnnotationPresent(GetMapping.class)) return "get";
if (method.isAnnotationPresent(PostMapping.class)) return "post";
if (method.isAnnotationPresent(PutMapping.class)) return "put";
if (method.isAnnotationPresent(DeleteMapping.class)) return "delete";
return "ANY";
}
private String getHttpMethodPath(Method method) {
if (method.isAnnotationPresent(GetMapping.class) && method.getAnnotation(GetMapping.class).value().length > 0) {
return method.getAnnotation(GetMapping.class).value()[0];
}
if (method.isAnnotationPresent(PostMapping.class) && method.getAnnotation(PostMapping.class).value().length > 0) {
return method.getAnnotation(PostMapping.class).value()[0];
}
if (method.isAnnotationPresent(PutMapping.class) && method.getAnnotation(PutMapping.class).value().length > 0) {
return method.getAnnotation(PutMapping.class).value()[0];
}
if (method.isAnnotationPresent(DeleteMapping.class) && method.getAnnotation(DeleteMapping.class).value().length > 0) {
return method.getAnnotation(DeleteMapping.class).value()[0];
}
if (method.isAnnotationPresent(RequestMapping.class) && method.getAnnotation(RequestMapping.class).value().length > 0) {
return method.getAnnotation(RequestMapping.class).value()[0];
}
return "";
}
}

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-cache</artifactId>
<dependencies>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-dto</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-anno</artifactId>
</dependency>
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-redis-springdata</artifactId>
</dependency>
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
<includes>
<include>**.yaml</include>
</includes>
</resource>
</resources>
</build>
</project>

View File

@ -0,0 +1,89 @@
package com.seer.teach.common.cache;
import com.seer.teach.common.cache.dto.DeviceMetricDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* 设备指标信息缓存 Redis 操作版本
* 通过 Redis Hash 存储设备在线信息
*/
@Slf4j
@Component
public class DeviceMetricsCache {
/** Redis 主 key */
private static final String DEVICE_ONLINE_KEY = "deviceOnline";
/** 缓存有效期(默认 10 分钟) */
private static final Duration L2_TTL = Duration.ofMinutes(10);
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取指定设备的指标信息
* 直接从 Redis 获取
*
* @param deviceKey 设备唯一标识
* @return 设备指标信息未找到返回 null
*/
public DeviceMetricDTO get(String deviceKey) {
HashOperations<String, String, DeviceMetricDTO> ops = redisTemplate.opsForHash();
return ops.get(DEVICE_ONLINE_KEY, deviceKey);
}
/**
* 将设备指标信息写入 Redis
*
* @param deviceKey 设备唯一标识
* @param dto 设备指标数据
*/
public void put(String deviceKey, DeviceMetricDTO dto) {
HashOperations<String, String, DeviceMetricDTO> ops = redisTemplate.opsForHash();
ops.put(DEVICE_ONLINE_KEY, deviceKey, dto);
redisTemplate.expire(DEVICE_ONLINE_KEY, L2_TTL.toMillis(), TimeUnit.MILLISECONDS);
}
/**
* 删除指定设备的缓存数据
*
* @param deviceKey 设备唯一标识
*/
public void invalidate(String deviceKey) {
redisTemplate.opsForHash().delete(DEVICE_ONLINE_KEY, deviceKey);
log.debug("从 Redis 删除设备缓存: {}", deviceKey);
}
/**
* 查询所有在线设备的指标信息
*
* @return 所有在线设备的指标映射表
*/
@SuppressWarnings("unchecked")
public Map<String, DeviceMetricDTO> getAllOnline() {
Map<Object, Object> all = redisTemplate.opsForHash().entries(DEVICE_ONLINE_KEY);
return Optional.ofNullable((Map<String, DeviceMetricDTO>) (Object) all)
.orElse(Collections.emptyMap());
}
/**
* 判断指定设备是否存在于 Redis
*
* @param deviceKey 设备唯一标识
* @return true=存在false=不存在
*/
public boolean exists(String deviceKey) {
Boolean result = redisTemplate.opsForHash().hasKey(DEVICE_ONLINE_KEY, deviceKey);
return Boolean.TRUE.equals(result);
}
}

View File

@ -0,0 +1,38 @@
package com.seer.teach.common.cache;
import com.seer.teach.common.cache.dto.DeviceMetricDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Slf4j
@Service
public class DeviceMetricsCacheService {
@Resource
private DeviceMetricsCache deviceMetricsCache;
/** 上报设备指标 */
public void reportMetric(String deviceKey, DeviceMetricDTO dto) {
deviceMetricsCache.put(deviceKey, dto);
}
/** 获取最新指标 */
public DeviceMetricDTO getLatestMetric(String deviceKey) {
return deviceMetricsCache.get(deviceKey);
}
/** 删除设备缓存 */
public void invalidateMetric(String deviceKey) {
deviceMetricsCache.invalidate(deviceKey);
}
/** 获取全部在线设备 */
public Map<String, DeviceMetricDTO> getAllOnlineDevices() {
return deviceMetricsCache.getAllOnline();
}
}

View File

@ -0,0 +1,38 @@
package com.seer.teach.common.cache;
import com.seer.teach.common.dto.mq.LlmChatDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* Redis 发布器Publisher
* LlmChatDTO 消息发布到指定设备频道
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LlmAudioPublisher {
/**
* Redis频道前缀
*/
private static final String CHANNEL_PREFIX = "llmAudioChannel:";
private final RedisTemplate<String, Object> redisTemplate;
public static String getChannel(String channelName) {
return CHANNEL_PREFIX + channelName;
}
/**
* 发布音频消息到指定设备频道
*
* @param channelName 队列名称
* @param message 消息对象
*/
public void publish(String channelName, LlmChatDTO message) {
redisTemplate.convertAndSend(channelName, message);
}
}

View File

@ -0,0 +1,40 @@
package com.seer.teach.common.cache;
import com.seer.teach.common.dto.mq.LlmChatDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* LlmChat 发布服务
* 对外提供发布音频消息的接口
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LlmAudioPublisherService {
private final LlmAudioPublisher llmAudioPublisher;
/**
* 发布音频消息到指定设备频道
*
* @param device 设备标识
* @param message 消息对象
*/
public void publishToDevice(String channelName, LlmChatDTO message) {
llmAudioPublisher.publish(channelName, message);
}
/**
* 发布音频消息到多个设备
*
* @param devices 设备列表
* @param message 消息对象
*/
public void publishToDevices(Iterable<String> channelNames, LlmChatDTO message) {
for (String channelName : channelNames) {
publishToDevice(channelName, message);
}
}
}

View File

@ -0,0 +1,85 @@
package com.seer.teach.common.cache;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* SSE 数据缓存Redis 版本
* sessionId KeyList<Object> Value
* 每个 SSE 会话缓存对应的推送数据
*/
@Slf4j
@Component
public class SseDataCache {
/** Redis key 前缀 */
private static final String SSE_DATA_KEY_PREFIX = "sseData:";
/** 每个会话缓存有效期(默认 30 分钟) */
private static final Duration SSE_SESSION_TTL = Duration.ofMinutes(30);
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取 Redis 中完整的 SSE 数据列表
*
* @param sessionId SSE 会话 ID
* @return 数据列表可能为空
*/
public List<Object> getAll(Integer sessionId) {
String key = buildKey(sessionId);
ListOperations<String, Object> ops = redisTemplate.opsForList();
List<Object> list = ops.range(key, 0, -1);
return list != null ? list : Collections.emptyList();
}
/**
* SSE 缓存中追加一条数据
*
* @param sessionId SSE 会话 ID
* @param data SSE 数据对象
*/
public void add(Integer sessionId, Object data) {
String key = buildKey(sessionId);
ListOperations<String, Object> ops = redisTemplate.opsForList();
ops.rightPush(key, data);
redisTemplate.expire(key, SSE_SESSION_TTL.toMillis(), TimeUnit.MILLISECONDS);
}
/**
* 删除指定 SSE 会话的所有缓存数据
*
* @param sessionId SSE 会话 ID
*/
public void invalidate(Integer sessionId) {
String key = buildKey(sessionId);
redisTemplate.delete(key);
log.debug("已清理 SSE 缓存: sessionId={}", sessionId);
}
/**
* 判断指定 SSE 会话是否存在缓存数据
*
* @param sessionId SSE 会话 ID
* @return true=存在false=不存在
*/
public boolean exists(Integer sessionId) {
String key = buildKey(sessionId);
Boolean result = redisTemplate.hasKey(key);
return Boolean.TRUE.equals(result);
}
/** 构建 Redis key */
private String buildKey(Integer sessionId) {
return SSE_DATA_KEY_PREFIX + sessionId;
}
}

View File

@ -0,0 +1,39 @@
package com.seer.teach.common.cache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.List;
/**
* SSE 数据缓存业务封装
* 对外提供更高层的缓存操作接口
*/
@Slf4j
@Service
public class SseDataCacheService {
@Resource
private SseDataCache sseDataCache;
/** 缓存追加一条 SSE 数据 */
public void appendSseData(Integer sessionId, Object data) {
sseDataCache.add(sessionId, data);
}
/** 获取指定会话的所有缓存数据 */
public List<Object> getSseData(Integer sessionId) {
return sseDataCache.getAll(sessionId);
}
/** 清除指定会话缓存 */
public void clearSseData(Integer sessionId) {
sseDataCache.invalidate(sessionId);
}
/** 判断会话缓存是否存在 */
public boolean exists(Integer sessionId) {
return sseDataCache.exists(sessionId);
}
}

View File

@ -0,0 +1,195 @@
package com.seer.teach.common.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* SSE 生命周期缓存管理类提供两级缓存L1 本地缓存 + L2 Redis 缓存支持
* L1 使用 Caffeine 实现分为正向缓存和负向缓存L2 使用 Redis
*/
@Slf4j
@Component
public class SseLifeCycleCache {
/** 一级缓存L1过期时间30分钟 */
private static final Duration L1_TTL = Duration.ofMinutes(30);
/** 二级缓存L2 Redis过期时间30分钟 */
private static final Duration L2_TTL = Duration.ofMinutes(30);
/** 负向缓存过期时间30秒用于防止缓存穿透 */
private static final Duration L1_NEGATIVE_TTL = Duration.ofSeconds(30);
@Resource
private RedisTemplate<String, Object> redisTemplate;
/** 一级正向缓存(本地) */
private final Cache<String, Integer> l1;
/** 一级负向缓存(本地,用于记录空值防止穿透) */
private final Cache<String, Boolean> l1Negative;
/**
* 构造函数初始化本地缓存实例
*
* @param redisTemplate Redis 操作模板
*/
public SseLifeCycleCache(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
// 初始化 L1 正向缓存最大容量 50000写入后 30 分钟过期
this.l1 = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(L1_TTL)
.recordStats()
.build();
// 初始化 L1 负向缓存最大容量 100000写入后 30 秒过期
this.l1Negative = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(L1_NEGATIVE_TTL)
.build();
}
/**
* 获取缓存值优先从 L1 缓存获取未命中则查询 Redis
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
* @return 缓存值若不存在或已失效则返回 null
*/
public Integer get(String prefix, Integer userId) {
String key = redisKey(prefix, userId);
// 如果在负向缓存中存在则直接返回 null
if (Boolean.TRUE.equals(l1Negative.getIfPresent(key))) {
return null;
}
// 尝试从 L1 正向缓存获取
Integer local = l1.getIfPresent(key);
if (local != null) {
log.debug("[SseLifeCycleCache] 命中 L1, prefix={}, userId={}, value={}", prefix, userId, local);
return local;
}
// L1 未命中尝试从 Redis 获取
Integer fromRedis = loadFromRedis(key);
if (fromRedis != null) {
log.debug("[SseLifeCycleCache] 命中 L2, prefix={}, userId={}, value={}", prefix, userId, fromRedis);
l1.put(key, fromRedis);
return fromRedis;
}
// Redis 也未命中标记为负向缓存
l1Negative.put(key, Boolean.TRUE);
return null;
}
/**
* 写入缓存值到 L1 Redis
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
* @param value 要写入的值若为 null 则表示删除该缓存项
*/
public void put(String prefix, Integer userId, Integer value) {
String key = redisKey(prefix, userId);
// 清除负向缓存中的记录
l1Negative.invalidate(key);
// 写入 Redis
writeToRedis(key, value, L2_TTL);
if (value != null) {
// 值有效时写入 L1 正向缓存
l1.put(key, value);
} else {
// 值无效时清除 L1 正向缓存并标记负向缓存
l1.invalidate(key);
l1Negative.put(key, Boolean.TRUE);
}
}
/**
* 删除指定缓存项包括 L1 Redis 中的数据
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
*/
public void invalidate(String prefix, Integer userId) {
String key = redisKey(prefix, userId);
// 清除 L1 正向和负向缓存
l1.invalidate(key);
l1Negative.invalidate(key);
// 删除 Redis 中的缓存项
redisTemplate.delete(key);
}
/**
* 直接从 Redis 中读取缓存值不经过 L1 缓存
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
* @return Redis 中的缓存值若不存在则返回 null
*/
public Integer peekL2(String prefix, Integer userId) {
return loadFromRedis(redisKey(prefix, userId));
}
/**
* 直接从 L1 缓存中读取缓存值不经过 Redis
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
* @return L1 缓存中的值若不存在则返回 null
*/
public Integer peekL1(String prefix, Integer userId) {
return l1.getIfPresent(redisKey(prefix, userId));
}
/**
* Redis 中加载指定键的值
*
* @param key Redis
* @return 若值为 Integer 类型则返回否则返回 null
*/
private Integer loadFromRedis(String key) {
Object raw = redisTemplate.opsForValue().get(key);
if (raw instanceof Integer) {
return (Integer) raw;
}
return null;
}
/**
* Redis 写入指定键值对并设置过期时间
*
* @param key Redis
* @param value 要写入的值
* @param ttl 过期时间若为 null 则使用默认 L2_TTL
*/
private void writeToRedis(String key, Integer value, Duration ttl) {
long millis = Optional.ofNullable(ttl).orElse(L2_TTL).toMillis();
redisTemplate.opsForValue().set(key, value, millis, TimeUnit.MILLISECONDS);
}
/**
* 构造 Redis 缓存键
*
* @param prefix 缓存键前缀
* @param userId 用户 ID
* @return 完整的 Redis
*/
private String redisKey(String prefix, Integer userId) {
return prefix + userId;
}
}

View File

@ -0,0 +1,89 @@
package com.seer.teach.common.cache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* User SSE 缓存 Service
* <p>
* 作用
* - 统一封装 UserSseCache 的操作
* - 业务层调用时不直接依赖 UserSseCache 工具类
* - 方便后续扩展例如埋点统计AOP日志等
* <p>
* 用法示例
* userSseCacheService.saveSession("SSE_CHAT:", userId, sessionId);
* Integer session = userSseCacheService.getSession("SSE_CHAT:", userId);
*
* @author Captain
* @since 2025-09-28
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SseLifeCycleCacheService {
/**
* AI聊天Redis键前缀
*/
public static final String AI_CHAT_REDIS_KEY_PREFIX = "sseChatLifeCycle:";
/**
* AI批改Redis键前缀
*/
public static final String AI_CORRECT_REDIS_KEY_PREFIX = "sseCorrectLifeCycle:";
/**
* AI学习Redis键前缀
*/
public static final String AI_STUDY_REDIS_KEY_PREFIX = "sseStudyLifeCycle:";
private final SseLifeCycleCache sseLifeCycleCache;
/**
* 获取用户的 SSE 缓存值优先 L1 -> L2
*
* @param prefix Redis Key 前缀
* @param userId 用户 ID
* @return 缓存值可能为 null
*/
public Integer getSession(String prefix, Integer userId) {
return sseLifeCycleCache.get(prefix, userId);
}
/**
* 保存用户 SSE 缓存先写 L2 -> 再写 L1
*
* @param prefix Redis Key 前缀
* @param userId 用户 ID
* @param sessionId 会话 ID
*/
public void saveSession(String prefix, Integer userId, Integer sessionId) {
sseLifeCycleCache.put(prefix, userId, sessionId);
}
/**
* 删除用户 SSE 缓存双删L1 + L2
*
* @param prefix Redis Key 前缀
* @param userId 用户 ID
*/
public void removeSession(String prefix, Integer userId) {
sseLifeCycleCache.invalidate(prefix, userId);
}
/**
* 仅查 Redis (L2)
*/
public Integer peekRedis(String prefix, Integer userId) {
return sseLifeCycleCache.peekL2(prefix, userId);
}
/**
* 仅查本地缓存 (L1)
*/
public Integer peekLocal(String prefix, Integer userId) {
return sseLifeCycleCache.peekL1(prefix, userId);
}
}

View File

@ -0,0 +1,101 @@
package com.seer.teach.common.cache;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seer.teach.common.cache.dto.UserStudyStatusDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 用户学习状态缓存
* @author Captain
* @since 2025-10-12
*/
@Slf4j
@Component
public class UserStudyStatusCache {
/** Redis Key 前缀 */
private static final String REDIS_PREFIX = "studyStatus:";
/** 默认过期时间1小时 */
private static final Duration DEFAULT_TTL = Duration.ofHours(1);
@Resource
private RedisTemplate<String, Object> redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 保存用户学习状态自动过期1小时 */
public void put(Integer userId, UserStudyStatusDTO status) {
if (userId == null || status == null) return;
try {
String key = REDIS_PREFIX + userId;
String json = MAPPER.writeValueAsString(status);
redisTemplate.opsForValue().set(key, json, DEFAULT_TTL);
log.debug("[UserStudyStatusCache] 保存学习状态 userId={}, value={}", userId, json);
} catch (JsonProcessingException e) {
log.error("[UserStudyStatusCache] JSON 序列化失败 userId={}, error={}", userId, e.getMessage());
}
}
/** 获取用户学习状态 */
public UserStudyStatusDTO get(Integer userId) {
if (userId == null) return null;
String key = REDIS_PREFIX + userId;
Object json = redisTemplate.opsForValue().get(key);
if (json == null) return null;
try {
return MAPPER.readValue(json.toString(), UserStudyStatusDTO.class);
} catch (Exception e) {
log.warn("[UserStudyStatusCache] JSON 解析失败 userId={}, json={}", userId, json);
return null;
}
}
/** 删除用户学习状态 */
public void remove(Integer userId) {
if (userId == null) return;
redisTemplate.delete(REDIS_PREFIX + userId);
log.debug("[UserStudyStatusCache] 删除学习状态 userId={}", userId);
}
/** 判断用户是否存在学习状态 */
public boolean exists(Integer userId) {
if (userId == null) return false;
return Boolean.TRUE.equals(redisTemplate.hasKey(REDIS_PREFIX + userId));
}
/** 获取所有用户学习状态(仅限小规模使用) */
public Map<Integer, UserStudyStatusDTO> getAll() {
// 注意若用户数较多请避免 keys("*")应维护用户ID列表
Map<Integer, UserStudyStatusDTO> result = new ConcurrentHashMap<>();
for (String key : redisTemplate.keys(REDIS_PREFIX + "*")) {
try {
String json = (String) redisTemplate.opsForValue().get(key);
if (json != null) {
Integer userId = Integer.parseInt(key.substring(REDIS_PREFIX.length()));
result.put(userId, MAPPER.readValue(json, UserStudyStatusDTO.class));
}
} catch (Exception ignored) {}
}
return result;
}
/** 清空所有学习状态(慎用) */
public void clearAll() {
for (String key : redisTemplate.keys(REDIS_PREFIX + "*")) {
redisTemplate.delete(key);
}
log.warn("[UserStudyStatusCache] 已清空所有学习状态");
}
}

View File

@ -0,0 +1,65 @@
package com.seer.teach.common.cache;
import com.seer.teach.common.cache.dto.UserStudyStatusDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 用户学习状态缓存 Service
*
* 封装 UserStudyStatusCache提供清晰的业务语义接口
* - startStudy开始学习并记录状态
* - updateStudy更新当前学习进度
* - finishStudy学习结束移除状态
* - getStudyStatus获取当前学习状态
* - isStudying是否正在学习
* - getAllStudying获取所有用户学习状态
*
* @author Captain
* @since 2025-10-12
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserStudyStatusCacheService {
private final UserStudyStatusCache userStudyStatusCache;
/** 标记用户开始学习(存入状态) */
public void startStudy(Integer userId, UserStudyStatusDTO status) {
userStudyStatusCache.put(userId, status);
}
/** 更新用户学习状态 */
public void updateStudy(Integer userId, UserStudyStatusDTO status) {
userStudyStatusCache.put(userId, status);
}
/** 获取用户学习状态 */
public UserStudyStatusDTO getStudyStatus(Integer userId) {
return userStudyStatusCache.get(userId);
}
/** 判断用户是否正在学习 */
public boolean isStudying(Integer userId) {
return userStudyStatusCache.exists(userId);
}
/** 学习结束,移除状态 */
public void finishStudy(Integer userId) {
userStudyStatusCache.remove(userId);
}
/** 获取所有学习状态 */
public Map<Integer, UserStudyStatusDTO> getAllStudying() {
return userStudyStatusCache.getAll();
}
/** 清空所有学习状态 */
public void clearAll() {
userStudyStatusCache.clearAll();
}
}

View File

@ -0,0 +1,39 @@
package com.seer.teach.common.cache.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class CorrectResultDTO {
/** 会话ID */
private Long sessionId;
/** 当前块序号或计数 */
private Integer count;
/** 题目或批改索引ID */
private Integer indexId;
/** 状态编号(如处理中、成功、失败) */
private Integer statusId;
/** 状态文本描述 */
private String statusContent;
/** 批改提示HTML 格式内容) */
private String tip;
/** 批改结果内容(最终结果) */
private String result;
/** 消息创建时间 */
private long mqTime;
}

View File

@ -0,0 +1,44 @@
package com.seer.teach.common.cache.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-26 18:28
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class DeviceMetricDTO {
private Integer cityId;
private String deviceIp;
private String nettyIp;
private double cpu;
private double memory;
private LocalDateTime updateTime;
public static DeviceMetricDTO get(Integer cityId,String deviceIp,String nettyIp,double cpu,double memory){
return new DeviceMetricDTO(cityId,deviceIp,nettyIp,cpu,memory,LocalDateTime.now());
}
public static DeviceMetricDTO defaultMetric(Integer cityId,String deviceIp,String nettyIp){
return new DeviceMetricDTO(cityId,deviceIp,nettyIp,0,0,LocalDateTime.now());
}
}

View File

@ -0,0 +1,47 @@
package com.seer.teach.common.cache.dto;
import com.seer.teach.common.dto.mq.dto.UserCacheDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.Duration;
/**
* L1缓存数据传输对象
* <p>
* 用于表示一级缓存中的用户数据包含用户缓存信息和过期时间
* 提供创建缓存对象和检查过期状态的功能
* </p>
*
* @author Captain
* @since 2025-08-16 18:04
*/
@Data
@AllArgsConstructor
public class L1CacheDTO {
private UserCacheDTO value;
private long expireAt;
/**
* 创建L1缓存对象
*
* @param dto 用户缓存数据传输对象
* @param ttl 缓存有效期时长
* @return L1缓存对象
*/
public static L1CacheDTO of(UserCacheDTO dto, Duration ttl) {
long exp = System.currentTimeMillis() + (ttl == null ? 0 : ttl.toMillis());
return new L1CacheDTO(dto, exp);
}
/**
* 判断缓存是否已过期
*
* @return true表示已过期false表示未过期
*/
public boolean isExpired() {
return System.currentTimeMillis() > expireAt;
}
}

View File

@ -0,0 +1,40 @@
package com.seer.teach.common.cache.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-16 17:20
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ReadingStatusDTO {
private Integer type;
private Integer chapterId;
// true 读完了 false 没读完
private Boolean bookPlayState;
private Boolean isEnd;
public static ReadingStatusDTO smartRead(Integer chapterId) {
return new ReadingStatusDTO(5, chapterId, false,false);
}
public static ReadingStatusDTO readTreasuryBox(Integer articleId) {
return new ReadingStatusDTO(6, articleId, false,false);
}
}

View File

@ -0,0 +1,33 @@
package com.seer.teach.common.cache.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-12 9:11
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class UserStudyStatusDTO {
private Integer type;
private Integer sessionId;
private Integer knowId;
private Integer videoIndex;
private Boolean classOver;
}

View File

@ -0,0 +1,44 @@
package com.seer.teach.common.cache.jetcache.config;
import com.alicp.jetcache.anno.config.EnableMethodCache;
import com.alicp.jetcache.autoconfigure.JetCacheAutoConfiguration;
import com.alicp.jetcache.support.Fastjson2ValueDecoder;
import com.alicp.jetcache.support.Fastjson2ValueEncoder;
import com.seer.teach.common.cache.support.YamlPropertySourceFactory;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
@Slf4j
@EnableMethodCache(basePackages = "com.seer")
@ConditionalOnProperty(name = "jetcache.enable", havingValue = "true", matchIfMissing = true)
@PropertySource(
value = "classpath:jetcache.yaml",
factory = YamlPropertySourceFactory.class
)
@RequiredArgsConstructor
@Configuration
public class JetCacheConfig extends JetCacheAutoConfiguration {
@PostConstruct
public void init() {
log.info("JetCacheConfig configuration loaded successfully");
log.info("Enabled method cache for base packages: com.seer");
log.info("Loading jetcache configuration from: classpath:jetcache.yaml");
}
@Bean
public Fastjson2ValueDecoder fastjson2ValueDecoder() {
return new Fastjson2ValueDecoder(false);
}
@Bean
public Fastjson2ValueEncoder fastjson2ValueEncoder() {
return new Fastjson2ValueEncoder(false);
}
}

View File

@ -0,0 +1,27 @@
package com.seer.teach.common.cache.support;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
import org.springframework.lang.Nullable;
import java.util.Properties;
import java.io.IOException;
public class YamlPropertySourceFactory implements PropertySourceFactory {
@Override
public org.springframework.core.env.PropertySource<?> createPropertySource(@Nullable String name, EncodedResource encodedResource) throws IOException {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(encodedResource.getResource());
Properties properties = factory.getObject();
if (properties == null) {
throw new IllegalStateException("Failed to load YAML properties from " + encodedResource.getResource());
}
String sourceName = (name != null) ? name : encodedResource.getResource().getFilename();
if (sourceName == null) {
throw new IllegalStateException("Resource must have a filename");
}
return new PropertiesPropertySource(sourceName, properties);
}
}

View File

@ -0,0 +1,36 @@
jetcache:
statIntervalMinutes: 15
areaInCacheName: false
hidePackages: com.alibaba
local:
default:
type: caffeine
limit: 100
keyConvertor: jackson
expireAfterWriteInMillis: 100000
otherArea:
type: linkedhashmap
limit: 100
keyConvertor: none
expireAfterWriteInMillis: 100000
remote:
default:
type: redis.springdata
keyConvertor: jackson
valueEncoder: bean:fastjson2ValueEncoder
valueDecoder: bean:fastjson2ValueDecoder
poolConfig:
minIdle: 5
maxIdle: 20
maxTotal: 50
## uri: redis://${spring.data.redis.password}@${spring.data.redis.host}:${spring.data.redis.port}
otherArea:
type: redis.springdata
keyConvertor: jackson
valueEncoder: bean:fastjson2ValueEncoder
valueDecoder: bean:fastjson2ValueDecoder
poolConfig:
minIdle: 5
maxIdle: 20
maxTotal: 50
## uri: redis://${spring.data.redis.password}@${spring.data.redis.host}:${spring.data.redis.port}

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-config</artifactId>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-exception</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,64 @@
package com.seer.teach.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.ArrayList;
import java.util.List;
/**
* @Author: Captain
* @Autograph: 安稳
* @Description: 跨域处理类
* @Date: 2023-07-03 16:15:51
*/
@Slf4j
@Configuration
public class CorsConfig {
public CorsConfig() {
log.info("CORS 配置类 CorsConfig已加载");
}
@Bean
@ConfigurationProperties(prefix = "zs.cors")
public Cors cors() {
return new Cors();
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public CorsFilter corsFilter(Cors cors) {
CorsConfiguration config = new CorsConfiguration();
// 设置允许的域名
config.setAllowedOriginPatterns(cors.getOrigins());
// 允许所有请求头
config.addAllowedHeader("*");
// 允许所有方法
config.addAllowedMethod("*");
// 允许携带认证信息
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
public static class Cors {
private List<String> origins = new ArrayList<>();
public List<String> getOrigins() {
return origins;
}
public void setOrigins(List<String> origins) {
this.origins = origins;
}
}
}

View File

@ -0,0 +1,56 @@
package com.seer.teach.common.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;
@Configuration
public class JacksonConfig {
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
// 设置日期时间格式
builder.simpleDateFormat(DATE_TIME_FORMAT);
// 设置时区
builder.timeZone(TimeZone.getTimeZone("Asia/Shanghai"));
// 配置 Java 8 时间类型的序列化和反序列化
builder.serializers(
new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT)),
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT))
);
builder.deserializers(
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT)),
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT))
);
// 其他配置
builder.featuresToEnable(
SerializationFeature.INDENT_OUTPUT, // 美化输出
SerializationFeature.WRITE_DATES_AS_TIMESTAMPS // 禁用时间戳
);
builder.featuresToDisable(
SerializationFeature.FAIL_ON_EMPTY_BEANS
);
// 设置是否包含 null
builder.serializationInclusion(JsonInclude.Include.ALWAYS);
};
}
}

View File

@ -0,0 +1,110 @@
package com.seer.teach.common.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* @Author: Captain
* @Description: Llm配置类
* @Date: 2025-05-12 18:42
*/
@Component
@ConditionalOnProperty(prefix = "largeModel", name = "ip")
public class LlmConfig implements InitializingBean {
@Value("${largeModel.ip:}")
private String ip;
@Value("${largeModel.questionsCorrect:}")
private String questionsCorrect;
@Value("${largeModel.getPlanQuestions:}")
private String getPlanQuestions;
@Value("${largeModel.recommendChapters:}")
private String recommendChapters;
@Value("${largeModel.planCoverImage:}")
private String planCoverImage;
@Value("${largeModel.tts}")
private String tts;
@Value("${largeModel.stt}")
private String stt;
@Value("${largeModel.getComment}")
private String getComment;
@Value("${largeModel.sseAiChat}")
private String sseAiChat;
@Value("${largeModel.sseStudy}")
private String sseStudy;
@Value("${largeModel.sseAiCorrect}")
private String sseAiCorrect;
@Value("${largeModel.ocr}")
private String ocr;
@Value("${largeModel.processUpload}")
private String processUpload;
@Value("${largeModel.getContextAboutSingleQuestion}")
private String getContextAboutSingleQuestion;
@Value("${largeModel.getTextBase64}")
private String getTextBase64;
public static String IP;
public static String QUESTIONS_CORRECT;
public static String GET_PLAN_QUESTIONS;
public static String RECOMMEND_CHAPTERS;
public static String PLAN_COVER_IMAGE;
public static String TTS;
public static String STT;
public static String GET_COMMENT;
public static String SSE_AI_CHAT;
public static String SSE_STUDY;
public static String SSE_AI_CORRECT;
public static String OCR;
public static String PROCESS_UPLOAD;
public static String GET_CONTEXT_ABOUT_SINGLE_QUESTION;
public static String GET_TEXT_BASE64;
@Override
public void afterPropertiesSet(){
IP = ip;
QUESTIONS_CORRECT = questionsCorrect;
RECOMMEND_CHAPTERS = recommendChapters;
PLAN_COVER_IMAGE = planCoverImage;
TTS = tts;
STT = stt;
GET_COMMENT = getComment;
SSE_AI_CHAT = sseAiChat;
SSE_STUDY = sseStudy;
SSE_AI_CORRECT = sseAiCorrect;
GET_PLAN_QUESTIONS = getPlanQuestions;
OCR = ocr;
PROCESS_UPLOAD = processUpload;
GET_CONTEXT_ABOUT_SINGLE_QUESTION = getContextAboutSingleQuestion;
GET_TEXT_BASE64 = getTextBase64;
}
}

View File

@ -0,0 +1,67 @@
package com.seer.teach.common.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusPropertiesCustomizer;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.seer.teach.common.config.mybatis.plugin.MybatisSqlFormatPlugin;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import java.util.Properties;
/**
* @Author: Captain
* @Autograph: 安稳
* @Description:
* @Date: 2023-07-23 01:31:19
*/
@EnableTransactionManagement
@Configuration // 配置类
public class MyBatisPlusConfig {
@Value("${mybatis.print-sql:true}")
private String printSql;
/**
* @Author: Captain
* @Desc: 把自动注入数据的配置类注入进MyabtisPlus里面
* @Params: []
* @Return: com.tianyi.config.MyMetaObjectHandler
* @Time: 2024/1/22 11:54
*/
@Bean
public MyMetaObjectHandler myMetaObjectHandler() {
return new MyMetaObjectHandler();
}
@Bean
public UpdateTimePlugin updateTimePlugin() {
return new UpdateTimePlugin();
}
@Bean
public MybatisSqlFormatPlugin mybatisSqlFormatPlugin() {
MybatisSqlFormatPlugin plugin = new MybatisSqlFormatPlugin();
// 设置插件属性启用SQL打印
Properties properties = new Properties();
properties.setProperty("printSql", printSql);
plugin.setProperties(properties);
return plugin;
}
/**
* 乐观锁插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(UpdateTimePlugin updateTimePlugin,MybatisSqlFormatPlugin mybatisSqlFormatPlugin) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(updateTimePlugin);
interceptor.addInnerInterceptor(mybatisSqlFormatPlugin);
return interceptor;
}
}

View File

@ -0,0 +1,42 @@
package com.seer.teach.common.config;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import java.time.LocalDateTime;
/**
* @Author: Captain
* @Autograph: 安稳
* @Description:
* @Date: 2023-07-25 23:16:15
*/
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
try {
Object loginId = StpUtil.getLoginIdDefaultNull();
if (loginId != null) {
this.setFieldValByName("createBy", loginId.toString(), metaObject);
}
} catch (Exception e) {
}
this.setFieldValByName("createTime", LocalDateTime.now(), metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
try {
Object loginId = StpUtil.getLoginIdDefaultNull();
if (loginId != null) {
this.setFieldValByName("updateBy", loginId.toString(), metaObject);
}
} catch (Exception e) {
}
this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);
}
}

View File

@ -0,0 +1,44 @@
package com.seer.teach.common.config;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.Map;
@Slf4j
@Configuration
@RefreshScope
public class NacosServiceMetadataRegistrationConfig {
@Value("${server.servlet.context-path:}")
private String contextPath;
@Autowired
private NacosDiscoveryProperties discoveryProperties;
@PostConstruct
public void registerContextPath() {
// 将上下文根添加到服务元数据
try {
Map<String, String> metadata = discoveryProperties.getMetadata();
metadata.put("context-path", contextPath);
discoveryProperties.setMetadata(metadata);
log.info("注册服务上下文根: {}", contextPath);
} catch (Exception e) {
log.error("注册服务上下文根失败", e);
}
}
@PreDestroy
public void cleanup() {
log.info("清理Nacos服务元数据注册配置");
}
}

View File

@ -0,0 +1,23 @@
package com.seer.teach.common.config;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* NettyRedis工具类
* <p>
* NettyRedis工具类
* </p>
*
* @author Captain
* @version 1.0
* @since 2025-07-14 20:54
*/
@Component
@RequiredArgsConstructor
public class NettyRedisConfig {
public static String REDIS_KEY_ONLINE_UV = "im:online:uv:";
}

View File

@ -0,0 +1,83 @@
package com.seer.teach.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 统一设置 RedisTemplatekey 用字符串value JSONJackson
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
// 1) ObjectMapper
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
// 允许你自己的业务包
.allowIfSubType("com.seer.teach.")
// 允许常见 JDK/时间类
.allowIfSubType("java.time.")
.allowIfSubType("org.springframework.statemachine.")
.allowIfSubType("java.util.")
// 显式添加UserCacheDTO所在的包路径
.allowIfSubType("com.seer.teach.common.dto.mq.dto.UserCacheDTO")
.build();
ObjectMapper om = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 2) 开启多态并使用更安全的校验器关键
om.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);
// 3) 组装模板
StringRedisSerializer keySer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valSer = new GenericJackson2JsonRedisSerializer(om);
RedisTemplate<String, Object> t = new RedisTemplate<>();
t.setConnectionFactory(factory);
t.setKeySerializer(keySer);
t.setHashKeySerializer(keySer);
t.setValueSerializer(valSer);
t.setHashValueSerializer(valSer);
t.afterPropertiesSet();
return t;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory, Executor redisExecutor) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.setTaskExecutor(redisExecutor);
container.start();
return container;
}
@Bean("redisExecutor")
public Executor redisExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(5);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(1000);
// 线程名前缀
executor.setThreadNamePrefix("redis-msg-");
// 线程空闲时间
executor.setKeepAliveSeconds(60);
executor.initialize();
return executor;
}
}

View File

@ -0,0 +1,28 @@
package com.seer.teach.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
@Bean
public ClientHttpRequestFactory clientHttpRequestFactory() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofSeconds(15));
return factory;
}
@Bean
public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(clientHttpRequestFactory);
return restTemplate;
}
}

View File

@ -0,0 +1,42 @@
package com.seer.teach.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 通用线程池配置类
* <p>
* 提供用于异步任务执行的线程池配置
* </p>
*/
@Configuration
public class ThreadPoolConfig {
/**
* 创建通用任务执行线程池
*
* @return 线程池执行器
*/
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(200);
// 线程名前缀
executor.setThreadNamePrefix("common-task-");
// 线程空闲时间
executor.setKeepAliveSeconds(60);
// 拒绝策略由调用线程处理该任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@ -0,0 +1,55 @@
package com.seer.teach.common.config;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-08-12 14:42
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class UpdateTimePlugin implements Interceptor, InnerInterceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 拿到原始 SQL
String originalSql = statementHandler.getBoundSql().getSql();
// 如果是 UPDATE 且没包含 update_time 字段则拼接
if (originalSql.trim().toUpperCase().startsWith("UPDATE")
&& !originalSql.toLowerCase().contains("update_time")) {
// 替换 SET SET update_time = NOW(),
String newSql = originalSql.replaceFirst("(?i)SET", "SET update_time = NOW(),");
// 用反射把新SQL写回去
java.lang.reflect.Field field = statementHandler.getBoundSql().getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(statementHandler.getBoundSql(), newSql);
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(Properties properties) {
}
}

View File

@ -0,0 +1,122 @@
package com.seer.teach.common.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* @Author: Captain
* @Description: 微信小程序配置类
* @Date: 2025-05-09 20:52
*/
@Component
public class WxConfig implements InitializingBean {
@Value("${wx.appId:}")
private String appId;
@Value("${wx.secret:}")
private String secret;
@Value("${wx.getAccessTokenUrl:}")
private String getAccessTokenUrl;
@Value("${wx.getQrCodeUrl:}")
private String getQrCodeUrl;
@Value("${wx.redisAccessKey:}")
private String redisAccessKey;
@Value("${wx.wxLoginUrl:}")
private String wxLoginUrl;
/**
* 商户号
* sp_mchid是服务商在微信支付侧的唯一身份标识所有接口调用都必须包含此参数以便微信支付确认商户的身份
* 当入驻审核成功后微信支付侧会向商户提供该商户号开发者需与负责申请商户号的同事联系获取
* 具体操作如下登录服务商平台点击账户中心->个人设置-->个人信息即可查看服务商商户号
*/
@Value("${wx.mchid:}")
private String mchid;
/**
* 商户私钥文件路径
*/
@Value("${wx.privateKeyPath:}")
private String privateKeyPath;
/**
* 商户证书序列号
*/
@Value("${wx.merchant-serial-number:}")
private String merchantSerialNumber;
/**
* 商户APIV3密钥
*/
@Value("${wx.apiV3Key:}")
private String apiV3Key;
/**
* 微信支付异步通知地址
*/
@Value("${wx.js-pay-notify-url:}")
private String jsPayNotifyUrl;
/**
* 微信支付退款异步通知地址
*/
@Value("${wx.js-refund-notify-url:}")
private String refundNotifyUrl;
/**
* 微信支付公钥id
*/
@Value("${wx.public-key-id:PUB_KEY_ID_0117240729862025081100212138000608}")
private String publicKeyId;
@Value("${wx.public-key-path:certs/pub_key.pem}")
private String publicKeyPath;
/**
* 定义公开静态常量
*/
public static String APP_ID;
public static String SECRET;
public static String GET_ACCESS_TOKEN_URL;
public static String GET_QR_CODE_URL;
public static String REDIS_ACCESS_KEY;
public static String WX_LOGIN_URL;
/**
* 支付相关
*/
public static String SP_MCHID;
public static String PRIVATE_KEY_PATH;
public static String MERCHANT_SERIAL_NUMBER;
public static String API_V3_KEY;
// 小程序支付回调url
public static String PAY_NOTIFY_URL;
// 小程序退款回调url
public static String REFUND_NOTIFY_URL;
public static String PUBLIC_KEY_ID;
public static String PUBLIC_KEY_PATH;
@Override
public void afterPropertiesSet() throws Exception {
APP_ID = appId;
SECRET = secret;
GET_ACCESS_TOKEN_URL = getAccessTokenUrl+ "?appid="+APP_ID+"&secret="+SECRET+"&grant_type=client_credential";
GET_QR_CODE_URL = getQrCodeUrl;
REDIS_ACCESS_KEY = redisAccessKey;
WX_LOGIN_URL = wxLoginUrl + "?appid="+APP_ID+"&secret="+SECRET+"&grant_type=authorization_code"+"&js_code=";
SP_MCHID = mchid;
PRIVATE_KEY_PATH = privateKeyPath;
MERCHANT_SERIAL_NUMBER = merchantSerialNumber;
API_V3_KEY = apiV3Key;
PAY_NOTIFY_URL = jsPayNotifyUrl;
REFUND_NOTIFY_URL = refundNotifyUrl;
PUBLIC_KEY_ID = publicKeyId;
PUBLIC_KEY_PATH = publicKeyPath;
}
}

View File

@ -0,0 +1,87 @@
package com.seer.teach.common.config.feign;
import cn.dev33.satoken.stp.StpUtil;
import com.seer.teach.common.exception.RemoteCallException;
import feign.*;
import feign.codec.ErrorDecoder;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okhttp3.Request;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.File;
import java.util.concurrent.TimeUnit;
/**
* <p>
* OpenFeign配置类
* </p>
*
* @author Captain
* @since 2025-07-15 13:56
*/
@Slf4j
@Configuration
@EnableFeignClients
public class FeignConfig {
@Bean
public Retryer retryer() {
return new Retryer.Default(100, 1000, 3);
}
@Bean
public ErrorDecoder errorDecoder() {
return (methodKey, response) -> new RemoteCallException(
response.status(), response.reason());
}
@Bean("okHttpClient")
public okhttp3.OkHttpClient httpClient() {
// 创建连接池
ConnectionPool connectionPool = new ConnectionPool(20, 5, TimeUnit.MINUTES);
// 创建缓存
Cache cache = new Cache(new File("http_cache"), 50 * 1024 * 1024);
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.connectionPool(connectionPool)
.cache(cache)
.addInterceptor(tokenInterceptor())
.retryOnConnectionFailure(true) // 自动重试失败连接
.followRedirects(true) // 跟随重定向
.followSslRedirects(true) // 跟随SSL重定向
.build();
}
private Interceptor tokenInterceptor() {
return chain -> {
Request originalRequest = chain.request();
if(StpUtil.isLogin()){
Request newRequest = originalRequest.newBuilder()
.header(StpUtil.getTokenName(), StpUtil.getTokenValue())
.build();
return chain.proceed(newRequest);
}
return chain.proceed(originalRequest);
};
}
@Bean
public Logger feignLogger() {
return new FeignFullLogger();
}
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}

View File

@ -0,0 +1,83 @@
package com.seer.teach.common.config.feign;
import feign.Logger;
import feign.Request;
import feign.Response;
import feign.Util;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
@Slf4j
public class FeignFullLogger extends Logger {
@Override
protected void log(String configKey, String format, Object... args) {
log.info(String.format(methodTag(configKey) + format, args));
}
@Override
protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException {
Request request = response.request();
String url = request.url();
String requestBody = "";
if (request.body() != null) {
String bodyStr = new String(request.body());
requestBody = bodyStr.length() > 200 ? bodyStr.substring(0, 200) + "........." : bodyStr;
}
String hostInfo = extractHostFromUrl(url);
log.info("Feign Request => Method: {}, URL: {}, Host: {}, Headers: {}, Body: {}",
request.httpMethod().name(),
url,
hostInfo,
request.headers(),
requestBody);
String responseBody = "";
byte[] bodyData = new byte[0];
try {
if (response.body() != null) {
bodyData = Util.toByteArray(response.body().asInputStream());
String bodyStr = new String(bodyData);
responseBody = bodyStr.length() > 200 ? bodyStr.substring(0, 200) + "........." : bodyStr;
}
} catch (IOException e) {
log.warn("Failed to read response body", e);
responseBody = "[Failed to read response body]";
}
log.info("Feign Response <= Status: {}, Headers: {}, Body: {}, Time: {}ms",
response.status(),
response.headers(),
responseBody,
elapsedTime);
return response.toBuilder().body(bodyData).build();
}
/**
* 从URL中提取主机信息
* @param url 完整URL
* @return 主机信息IP:Port
*/
private String extractHostFromUrl(String url) {
try {
int protocolEnd = url.indexOf("://");
if (protocolEnd != -1) {
int pathStart = url.indexOf("/", protocolEnd + 3);
if (pathStart != -1) {
return url.substring(protocolEnd + 3, pathStart);
} else {
return url.substring(protocolEnd + 3);
}
}
} catch (Exception e) {
log.debug("Failed to extract host from URL: {}", url, e);
}
return "unknown";
}
}

View File

@ -0,0 +1,24 @@
package com.seer.teach.common.config.feign;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientSpecification;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Collections;
import java.util.List;
@Configuration
public class GlobalFeignConfiguration {
@Bean
public LoadBalancerClientFactory loadBalancerClientFactory(LoadBalancerClientsProperties properties,
ObjectProvider<List<LoadBalancerClientSpecification>> configurations) {
SameIpLoadBalancerClientFactory clientFactory = new SameIpLoadBalancerClientFactory(properties);
clientFactory.setConfigurations(configurations.getIfAvailable(Collections::emptyList));
return clientFactory;
}
}

View File

@ -0,0 +1,174 @@
package com.seer.teach.common.config.feign;
import cn.hutool.core.collection.CollUtil;
import com.alibaba.cloud.nacos.balancer.NacosBalancer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.*;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Slf4j
public class SameIpLoadBalancerClient implements ReactorServiceInstanceLoadBalancer {
private final List<String> localIps;
/**
* 用于获取 serviceId 对应的服务实例的列表
*/
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
/**
* 需要获取的服务实例名
*
* 暂时用于打印 logger 日志
*/
private final String serviceId;
/**
* 被代理的 ReactiveLoadBalancer 对象
*/
private final ReactiveLoadBalancer<ServiceInstance> reactiveLoadBalancer;
public SameIpLoadBalancerClient(ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider, String serviceId, ReactiveLoadBalancer<ServiceInstance> reactiveLoadBalancer) {
this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;
this.serviceId = serviceId;
this.reactiveLoadBalancer = reactiveLoadBalancer;
this.localIps = getAllLocalIps();
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
return supplier.get(request).next().map(list -> getInstanceResponse(list, request));
}
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, Request request) {
// 如果服务实例为空则直接返回
if (CollUtil.isEmpty(instances)) {
log.warn("[getInstanceResponse][serviceId({}) 服务实例列表为空]", serviceId);
return new EmptyResponse();
}
List<ServiceInstance> chooseInstances = filterBySameIp(instances);
boolean isSameIpRouting = !CollUtil.isEmpty(chooseInstances) && chooseInstances != instances;
// 打印负载均衡决策信息
logLoadBalanceDecision(instances, chooseInstances, isSameIpRouting,request);
if (CollUtil.isEmpty(chooseInstances)) {
chooseInstances = instances;
}
return new DefaultResponse(NacosBalancer.getHostByRandomWeight3(chooseInstances));
}
private void logLoadBalanceDecision(List<ServiceInstance> allInstances,
List<ServiceInstance> sameIpInstances,
boolean isSameIpRouting, Request request) {
if (log.isInfoEnabled()) {
// 获取请求URL
URI url = extractUrlFromRequest(request);
String httpMethod = extractMethodFromRequest(request);
log.info("[LoadBalancer][{}][{}] 总实例数: {}, 同IP实例数: {}, 选择策略: {}, URL: {}, Method: {}",
serviceId, isSameIpRouting ? "同IP路由" : "随机路由", allInstances.size(), sameIpInstances.size(), isSameIpRouting ? "优先同IP实例" : "随机选择实例", url, httpMethod);
// 打印所有实例信息
allInstances.forEach(instance ->
log.debug("[LoadBalancer][{}] 实例信息 - Host: {}, Port: {}", serviceId, instance.getHost(), instance.getPort()));
// 如果有选中的实例打印请求信息和选中的实例
if (!sameIpInstances.isEmpty()) {
ServiceInstance chosenInstance = sameIpInstances.get(0);
String fullUrl = "http://" + chosenInstance.getHost() + ":" + chosenInstance.getPort() + url.getPath();
log.info("[LoadBalancer][{}] 选中实例 - 请求URL: {}", serviceId,fullUrl);
}
}
}
private URI extractUrlFromRequest(Request request) {
try {
if (request.getContext() instanceof RequestDataContext) {
RequestDataContext requestDataContext = (RequestDataContext)request.getContext();
RequestData clientRequest = requestDataContext.getClientRequest();
return clientRequest.getUrl();
}
} catch (Exception e) {
log.debug("Failed to extract URL from req", e);
}
return null;
}
private String extractMethodFromRequest(Request request) {
try {
if (request.getContext() instanceof RequestDataContext) {
RequestDataContext requestDataContext = (RequestDataContext)request.getContext();
RequestData clientRequest = requestDataContext.getClientRequest();
return clientRequest.getHttpMethod().toString() ;
}
} catch (Exception e) {
log.debug("Failed to extract HTTP method from req", e);
}
return "Unknown Method";
}
private List<ServiceInstance> filterBySameIp(List<ServiceInstance> instances) {
// 查找与消费者IP相同的实例
List<ServiceInstance> sameIpInstances = instances.stream()
.filter(instance -> localIps.contains(instance.getHost()))
.collect(Collectors.toList());
if (!sameIpInstances.isEmpty()) {
return sameIpInstances;
}
return instances;
}
private List<String> getAllLocalIps() {
List<String> ipList = new ArrayList<>();
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
// 跳过回环接口和未启用的接口
if (iface.isLoopback() || !iface.isUp()) {
continue;
}
Enumeration<InetAddress> addresses = iface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
// 只考虑IPv4地址
if (addr.getHostAddress().contains(":")) {
continue;
}
ipList.add(addr.getHostAddress());
}
}
} catch (SocketException e) {
log.warn("Could not determine local IP addresses, using empty list", e);
}
log.debug("Local IP addresses: {}", ipList);
return Collections.unmodifiableList(ipList);
}
}

View File

@ -0,0 +1,21 @@
package com.seer.teach.common.config.feign;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
public class SameIpLoadBalancerClientFactory extends LoadBalancerClientFactory {
public SameIpLoadBalancerClientFactory(LoadBalancerClientsProperties properties) {
super(properties);
}
@Override
public ReactiveLoadBalancer<ServiceInstance> getInstance(String serviceId) {
ReactiveLoadBalancer<ServiceInstance> reactiveLoadBalancer = super.getInstance(serviceId);
return new SameIpLoadBalancerClient(super.getLazyProvider(serviceId, ServiceInstanceListSupplier.class),
serviceId, reactiveLoadBalancer);
}
}

View File

@ -0,0 +1,77 @@
package com.seer.teach.common.config.mybatis.hanler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* Integer列表类型处理器
* 用于将数据库中的VARCHAR字段(逗号分隔)与Java的List<Integer>相互转换
*/
@MappedTypes(List.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class IntegerListTypeHandler extends BaseTypeHandler<List<Integer>> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<Integer> parameter, JdbcType jdbcType) throws SQLException {
if (parameter != null && !parameter.isEmpty()) {
String value = parameter.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
ps.setString(i, value);
} else {
ps.setString(i, null);
}
}
@Override
public List<Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return parseStringToList(value);
}
@Override
public List<Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return parseStringToList(value);
}
@Override
public List<Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return parseStringToList(value);
}
/**
* 将逗号分隔的字符串解析为Integer列表
*
* @param value 逗号分隔的字符串
* @return Integer列表
*/
private List<Integer> parseStringToList(String value) {
if (value == null || value.isEmpty()) {
return new ArrayList<>();
}
try {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::valueOf)
.collect(Collectors.toList());
} catch (NumberFormatException e) {
// 如果转换失败返回空列表
return new ArrayList<>();
}
}
}

View File

@ -0,0 +1,76 @@
package com.seer.teach.common.config.mybatis.hanler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* Integer列表类型处理器
* 用于将数据库中的VARCHAR字段(逗号分隔)与Java的List<Integer>相互转换
*/
@MappedTypes(List.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class StringListTypeHandler extends BaseTypeHandler<List<String>> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
if (parameter != null && !parameter.isEmpty()) {
String value = parameter.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
ps.setString(i, value);
} else {
ps.setString(i, null);
}
}
@Override
public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return parseStringToList(value);
}
@Override
public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return parseStringToList(value);
}
@Override
public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return parseStringToList(value);
}
/**
* 将逗号分隔的字符串解析为Integer列表
*
* @param value 逗号分隔的字符串
* @return Integer列表
*/
private List<String> parseStringToList(String value) {
if (value == null || value.isEmpty()) {
return new ArrayList<>();
}
try {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
} catch (NumberFormatException e) {
// 如果转换失败返回空列表
return new ArrayList<>();
}
}
}

View File

@ -0,0 +1,115 @@
package com.seer.teach.common.config.mybatis.plugin;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.mapping.ParameterMode;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.ReflectorFactory;
import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
import org.apache.ibatis.reflection.factory.ObjectFactory;
import org.apache.ibatis.reflection.property.PropertyTokenizer;
import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;
import org.apache.ibatis.reflection.wrapper.ObjectWrapperFactory;
import org.apache.ibatis.scripting.xmltags.ForEachSqlNode;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.sql.SQLException;
import java.util.List;
import java.util.Properties;
@Slf4j
public class MybatisSqlFormatPlugin implements InnerInterceptor {
private Properties properties;
@Override
public void beforeQuery(Executor executor, MappedStatement mappedStatement, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
try {
// 执行查询逻辑在MyBatis内部完成此处无需处理
} finally {
formatAndPrintSql(mappedStatement, parameter, boundSql);
}
}
@Override
public void beforeUpdate(Executor executor, MappedStatement mappedStatement, Object parameter) throws SQLException {
try {
// 执行更新逻辑在MyBatis内部完成此处无需处理
} finally {
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
formatAndPrintSql(mappedStatement, parameter, boundSql);
}
}
/**
* 格式化并打印SQL语句
*/
private void formatAndPrintSql(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
try {
String sql = boundSql.getSql();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
ObjectFactory objectFactory = new DefaultObjectFactory();
ObjectWrapperFactory objectWrapperFactory = new DefaultObjectWrapperFactory();
ReflectorFactory reflectorFactory = new DefaultReflectorFactory();
MetaObject metaObject = parameterObject == null ? null : MetaObject
.forObject(parameterObject, objectFactory, objectWrapperFactory, reflectorFactory);
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
PropertyTokenizer prop = new PropertyTokenizer(propertyName);
if (parameterObject == null) {
value = null;
} else if (mappedStatement.getConfiguration().getTypeHandlerRegistry().hasTypeHandler(
parameterObject.getClass())) {
value = parameterObject;
} else if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (propertyName.startsWith(ForEachSqlNode.ITEM_PREFIX)
&& boundSql.hasAdditionalParameter(prop.getName())) {
value = boundSql.getAdditionalParameter(prop.getName());
if (value != null) {
value = MetaObject.forObject(value, objectFactory, objectWrapperFactory, reflectorFactory).getValue(
propertyName.substring(prop.getName().length()));
}
} else {
value = metaObject == null ? null : metaObject.getValue(propertyName);
}
if (value != null) {
boolean valueIsString = value instanceof String;
if (valueIsString && value.toString().indexOf("$") > -1) {
value = ((String) value).replaceAll("\\$", "\\\\\\$");
}
sql = sql.replaceFirst("\\?", valueIsString ? "'" + value + "'" : value.toString());
} else {
sql = sql.replaceFirst("\\?", "null");
}
}
}
}
if (properties != null &&
"true".equals(properties.getProperty("printSql", "false"))) {
log.info("执行SQL ID: {}", mappedStatement.getId());
log.info("执行SQL语句: \n{}", sql);
log.info("=======End=======");
}
} catch (Exception e) {
log.warn("格式化SQL语句时发生异常", e);
}
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-data-permission</artifactId>
<dependencies>
<dependency>
<groupId>com.seer.teach</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,31 @@
package com.seer.teach.common.data.permission.annotation;
import com.seer.teach.common.data.permission.rule.DataPermissionRule;
import java.lang.annotation.*;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataPermission {
/**
* 当前类或方法是否开启数据权限
* 即使不添加 @DataPermission 注解默认是开启状态
* 可通过设置 enable false 禁用
*/
boolean enable() default true;
/**
* 生效的数据权限规则数组优先级高于 {@link #excludeRules()}
*/
Class<? extends DataPermissionRule>[] includeRules() default {};
/**
* 排除的数据权限规则数组优先级最低
*/
Class<? extends DataPermissionRule>[] excludeRules() default {};
}

View File

@ -0,0 +1,32 @@
package com.seer.teach.common.data.permission.aop;
import com.seer.teach.common.data.permission.annotation.DataPermission;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.aopalliance.aop.Advice;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.AbstractPointcutAdvisor;
import org.springframework.aop.support.ComposablePointcut;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
@Getter
@EqualsAndHashCode(callSuper = true)
public class DataPermissionAnnotationAdvisor extends AbstractPointcutAdvisor {
private final Advice advice;
private final Pointcut pointcut;
public DataPermissionAnnotationAdvisor() {
this.advice = new DataPermissionAnnotationInterceptor();
this.pointcut = this.buildPointcut();
}
protected Pointcut buildPointcut() {
Pointcut classPointcut = new AnnotationMatchingPointcut(DataPermission.class, true);
Pointcut methodPointcut = new AnnotationMatchingPointcut(null, DataPermission.class, true);
return new ComposablePointcut(classPointcut).union(methodPointcut);
}
}

View File

@ -0,0 +1,57 @@
package com.seer.teach.common.data.permission.aop;
import com.seer.teach.common.data.permission.annotation.DataPermission;
import lombok.Getter;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.core.MethodClassKey;
import org.springframework.core.annotation.AnnotationUtils;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@DataPermission
public class DataPermissionAnnotationInterceptor implements MethodInterceptor {
static final DataPermission DATA_PERMISSION_NULL = DataPermissionAnnotationInterceptor.class.getAnnotation(DataPermission.class);
@Getter
private final Map<MethodClassKey, DataPermission> dataPermissionCache = new ConcurrentHashMap<>();
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
DataPermission dataPermission = this.findAnnotation(methodInvocation);
if (dataPermission != null) {
DataPermissionContextHolder.add(dataPermission);
}
try {
return methodInvocation.proceed();
} finally {
if (dataPermission != null) {
DataPermissionContextHolder.remove();
}
}
}
private DataPermission findAnnotation(MethodInvocation methodInvocation) {
Method method = methodInvocation.getMethod();
Object targetObject = methodInvocation.getThis();
Class<?> clazz = targetObject != null ? targetObject.getClass() : method.getDeclaringClass();
MethodClassKey methodClassKey = new MethodClassKey(method, clazz);
DataPermission dataPermission = dataPermissionCache.get(methodClassKey);
if (dataPermission != null) {
return dataPermission != DATA_PERMISSION_NULL ? dataPermission : null;
}
dataPermission = AnnotationUtils.findAnnotation(method, DataPermission.class);
if (dataPermission == null) {
dataPermission = AnnotationUtils.findAnnotation(clazz, DataPermission.class);
}
dataPermissionCache.put(methodClassKey, dataPermission != null ? dataPermission : DATA_PERMISSION_NULL);
return dataPermission;
}
}

View File

@ -0,0 +1,68 @@
package com.seer.teach.common.data.permission.aop;
import com.alibaba.ttl.TransmittableThreadLocal;
import com.seer.teach.common.data.permission.annotation.DataPermission;
import java.util.LinkedList;
import java.util.List;
public class DataPermissionContextHolder {
/**
* 使用 List 的原因可能存在方法的嵌套调用
*/
private static final ThreadLocal<LinkedList<DataPermission>> DATA_PERMISSIONS =
TransmittableThreadLocal.withInitial(LinkedList::new);
/**
* 获得当前的 DataPermission 注解
*
* @return DataPermission 注解
*/
public static DataPermission get() {
return DATA_PERMISSIONS.get().peekLast();
}
/**
* 入栈 DataPermission 注解
*
* @param dataPermission DataPermission 注解
*/
public static void add(DataPermission dataPermission) {
DATA_PERMISSIONS.get().addLast(dataPermission);
}
/**
* 出栈 DataPermission 注解
*
* @return DataPermission 注解
*/
public static DataPermission remove() {
DataPermission dataPermission = DATA_PERMISSIONS.get().removeLast();
// 无元素时清空 ThreadLocal
if (DATA_PERMISSIONS.get().isEmpty()) {
DATA_PERMISSIONS.remove();
}
return dataPermission;
}
/**
* 获得所有 DataPermission
*
* @return DataPermission 队列
*/
public static List<DataPermission> getAll() {
return DATA_PERMISSIONS.get();
}
/**
* 清空上下文
*
* 目前仅仅用于单测
*/
public static void clear() {
DATA_PERMISSIONS.remove();
}
}

View File

@ -0,0 +1,53 @@
package com.seer.teach.common.data.permission.config;
import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.seer.teach.common.data.permission.aop.DataPermissionAnnotationAdvisor;
import com.seer.teach.common.data.permission.handler.DataPermissionRuleHandler;
import com.seer.teach.common.data.permission.rule.DataPermissionCondition;
import com.seer.teach.common.data.permission.rule.DataPermissionRule;
import com.seer.teach.common.data.permission.rule.DataPermissionRuleFactory;
import com.seer.teach.common.data.permission.rule.DataPermissionRuleFactoryImpl;
import com.seer.teach.common.data.permission.rule.role.RoleDataPermissionRule;
import com.seer.teach.common.data.permission.rule.role.RoleDataPermissionRuleCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
//@Configuration
public class DataPermissionAutoConfiguration {
@Bean
public RoleDataPermissionRule roleDataPermissionRule(List<RoleDataPermissionRuleCustomizer> rules) {
RoleDataPermissionRule rule = new RoleDataPermissionRule();
if(CollectionUtil.isNotEmpty(rules)){
rules.forEach(customizer -> customizer.customize(rule, rule.getCondition()));
}
return rule;
}
@Bean
public DataPermissionRuleFactory dataPermissionRuleFactory(List<DataPermissionRule> rules, List<DataPermissionCondition> conditions) {
return new DataPermissionRuleFactoryImpl(rules,conditions);
}
@Bean
public DataPermissionRuleHandler dataPermissionRuleHandler(MybatisPlusInterceptor interceptor,
DataPermissionRuleFactory ruleFactory) {
DataPermissionRuleHandler handler = new DataPermissionRuleHandler(ruleFactory);
DataPermissionInterceptor inner = new DataPermissionInterceptor(handler);
List<InnerInterceptor> inners = new ArrayList<>(interceptor.getInterceptors());
inners.add(0, inner);
interceptor.setInterceptors(inners);
return handler;
}
@Bean
public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() {
return new DataPermissionAnnotationAdvisor();
}
}

View File

@ -0,0 +1,50 @@
package com.seer.teach.common.data.permission.handler;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
import com.seer.teach.common.data.permission.rule.DataPermissionRule;
import com.seer.teach.common.data.permission.rule.DataPermissionRuleFactory;
import lombok.RequiredArgsConstructor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.schema.Table;
import java.util.List;
@RequiredArgsConstructor
public class DataPermissionRuleHandler implements MultiDataPermissionHandler {
private final DataPermissionRuleFactory ruleFactory;
private static final String MYSQL_ESCAPE_CHARACTER = "`";
@Override
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
List<DataPermissionRule> rules = ruleFactory.getDataPermissionRule(mappedStatementId);
if (CollUtil.isEmpty(rules)) {
return null;
}
Expression allExpression = null;
for (DataPermissionRule rule : rules) {
String tableName = getTableName(table);
if (!rule.getTableNames().contains(tableName)) {
continue;
}
Expression oneExpress = rule.getExpression(tableName, table.getAlias());
if (oneExpress == null) {
continue;
}
if(rule.getCondition().matches()){
allExpression = allExpression == null ? oneExpress : new AndExpression(allExpression, oneExpress);
}
}
return allExpression;
}
public String getTableName(Table table) {
String tableName = table.getName();
if (tableName.startsWith(MYSQL_ESCAPE_CHARACTER) && tableName.endsWith(MYSQL_ESCAPE_CHARACTER)) {
tableName = tableName.substring(1, tableName.length() - 1);
}
return tableName;
}
}

View File

@ -0,0 +1,19 @@
package com.seer.teach.common.data.permission.rule;
/**
* 数据权限条件接口
* <p>
* 该接口定义了数据权限匹配的条件判断逻辑用于判断给定的数据权限是否满足特定条件
* </p>
*
*/
@FunctionalInterface
public interface DataPermissionCondition {
/**
* 判断数据权限是否匹配条件
*
* @return boolean 匹配结果true表示匹配成功false表示匹配失败
*/
boolean matches();
}

View File

@ -0,0 +1,16 @@
package com.seer.teach.common.data.permission.rule;
import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import java.util.Set;
public interface DataPermissionRule {
Set<String> getTableNames();
Expression getExpression(String tableName, Alias tableAlias);
DataPermissionCondition getCondition();
}

View File

@ -0,0 +1,30 @@
package com.seer.teach.common.data.permission.rule;
import java.util.List;
/**
* {@link DataPermissionRule} 工厂接口
* 作为 {@link DataPermissionRule} 的容器提供管理能力
*
* @author 芋道源码
*/
public interface DataPermissionRuleFactory {
/**
* 获得所有数据权限规则数组
*
* @return 数据权限规则数组
*/
List<DataPermissionRule> getDataPermissionRules();
/**
* 获得指定 Mapper 的数据权限规则数组
*
* @param mappedStatementId 指定 Mapper 的编号
* @return 数据权限规则数组
*/
List<DataPermissionRule> getDataPermissionRule(String mappedStatementId);
List<DataPermissionCondition> getConditions();
}

View File

@ -0,0 +1,61 @@
package com.seer.teach.common.data.permission.rule;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ArrayUtil;
import com.seer.teach.common.data.permission.annotation.DataPermission;
import com.seer.teach.common.data.permission.aop.DataPermissionContextHolder;
import lombok.RequiredArgsConstructor;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory {
/**
* 数据权限规则数组
*/
private final List<DataPermissionRule> rules;
/**
* 数据权限条件判断器列表
*/
private final List<DataPermissionCondition> conditions;
@Override
public List<DataPermissionRule> getDataPermissionRules() {
return rules;
}
@Override
public List<DataPermissionRule> getDataPermissionRule(String mappedStatementId) {
if (CollUtil.isEmpty(rules)) {
return Collections.emptyList();
}
DataPermission dataPermission = DataPermissionContextHolder.get();
if (dataPermission == null) {
return rules;
}
if (!dataPermission.enable()) {
return Collections.emptyList();
}
if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) {
return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass()))
.collect(Collectors.toList());
}
if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) {
return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass()))
.collect(Collectors.toList());
}
return rules;
}
@Override
public List<DataPermissionCondition> getConditions() {
return conditions;
}
}

View File

@ -0,0 +1,168 @@
package com.seer.teach.common.data.permission.rule.role;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.LambdaUtils;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.support.LambdaMeta;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.seer.teach.common.data.permission.rule.DataPermissionCondition;
import com.seer.teach.common.data.permission.rule.DataPermissionRule;
import com.seer.teach.common.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.schema.Column;
import org.springframework.util.StringUtils;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 基于角色的数据权限规则限制用户只能访问自己所属数据 creatorId = 当前用户ID
*/
@AllArgsConstructor
@Slf4j
public class RoleDataPermissionRule implements DataPermissionRule {
/**
* 缓存 SFunction -> 属性名 的映射
*/
private static final ConcurrentMap<SFunction<?, ?>, String> PROPERTY_NAME_CACHE = new ConcurrentHashMap<>();
/**
* 存储每个表对应的用户字段 creator_id, user_id
* key: 表名 (tableName)
* value: 对应的数据库字段名
*/
private final ConcurrentMap<String, String> userColumns = new ConcurrentHashMap<>();
private DataPermissionCondition condition;
/**
* 所有受此规则影响的表名集合
*/
private final Set<String> tableNames = ConcurrentHashMap.newKeySet();
public RoleDataPermissionRule() {
}
@Override
public Set<String> getTableNames() {
return tableNames;
}
@Override
public Expression getExpression(String tableName, Alias tableAlias) {
if (!StpUtil.isLogin()) {
log.warn("用户未登录,跳过数据权限控制,表名:{}", tableName);
return null;
}
int userId = StpUtil.getLoginIdAsInt();
return buildUserExpression(tableName, tableAlias, userId);
}
@Override
public DataPermissionCondition getCondition() {
return condition;
}
/**
* 构建用户字段的等值表达式tableAlias.columnName = userId
*/
private Expression buildUserExpression(String tableName, Alias tableAlias, int userId) {
String columnName = userColumns.get(tableName);
if (StrUtil.isEmpty(columnName)) {
log.debug("表 [{}] 未配置用户字段,跳过数据权限", tableName);
return null;
}
Column column = buildColumn(tableName, tableAlias, columnName);
return new EqualsTo(column, new LongValue(userId));
}
/**
* 根据实体类和 Java 属性名注册数据权限字段
*/
public <T extends BaseEntity> void addUserColumn(Class<T> entityClass, String propertyName) {
TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass);
if (tableInfo == null) {
log.error("无法获取实体类 {} 的 TableInfo", entityClass.getName());
return;
}
tableInfo.getFieldList().stream()
.filter(fieldInfo -> fieldInfo.getProperty().equals(propertyName))
.findFirst()
.ifPresent(
fieldInfo -> {
String columnName = fieldInfo.getColumn();
addUserColumn(tableInfo.getTableName(), columnName);
}
);
}
/**
* 直接根据表名和数据库字段名注册权限字段
*/
public void addUserColumn(String tableName, String columnName) {
if (StrUtil.isBlank(tableName) || StrUtil.isBlank(columnName)) {
log.warn("注册失败表名或字段名为null或空字符串");
return;
}
userColumns.put(tableName, columnName);
tableNames.add(tableName);
log.debug("手动注册表 {} 的用户字段:{}", tableName, columnName);
}
public <T extends BaseEntity> void addUserColumn(Class<T> entityClass, SFunction<T, ?> methodRef) {
if (entityClass == null || methodRef == null) {
log.warn("addUserColumn: 实体类或方法引用为空");
return;
}
String propertyName = PROPERTY_NAME_CACHE.computeIfAbsent(methodRef, ref -> {
LambdaMeta meta = LambdaUtils.extract(ref);
return extractPropertyName(meta.getImplMethodName());
});
if (StrUtil.isNotBlank(propertyName)) {
addUserColumn(entityClass, propertyName);
} else {
log.error("无法从方法引用解析出属性名:{}", methodRef);
throw new IllegalArgumentException("无效的SFunction无法提取属性名");
}
}
private String extractPropertyName(String methodName) {
if (StrUtil.isBlank(methodName)) return null;
if (methodName.startsWith("get") && methodName.length() > 3) {
return StringUtils.uncapitalize(methodName.substring(3));
} else if (methodName.startsWith("is") && methodName.length() > 2) {
return StringUtils.uncapitalize(methodName.substring(2));
}
return null;
}
/**
* 构造带别名的列对象
*/
public Column buildColumn(String tableName, Alias tableAlias, String columnName) {
String actualTableName = tableAlias != null ? tableAlias.getName() : tableName;
return new Column(actualTableName + StringPool.DOT + columnName);
}
public void setCondition(DataPermissionCondition condition) {
this.condition = condition;
}
}

View File

@ -0,0 +1,24 @@
package com.seer.teach.common.data.permission.rule.role;
import com.seer.teach.common.data.permission.rule.DataPermissionCondition;
/**
* 角色数据权限规则自定义器接口
*
* <p>该接口用于自定义角色数据权限规则允许开发者根据业务需求对默认的权限规则进行扩展或修改</p>
*
*/
@FunctionalInterface
public interface RoleDataPermissionRuleCustomizer {
/**
* 自定义角色数据权限规则
*
* <p>通过此方法可以对传入的权限规则对象进行自定义配置如添加额外的过滤条件
* 修改现有规则逻辑或扩展规则属性等</p>
*
* @param rule 角色数据权限规则对象不允许为null
*/
void customize(RoleDataPermissionRule rule,DataPermissionCondition condition);
}

View File

@ -0,0 +1,73 @@
package com.seer.teach.common.data.permission.util;
import com.seer.teach.common.data.permission.annotation.DataPermission;
import com.seer.teach.common.data.permission.aop.DataPermissionContextHolder;
import lombok.SneakyThrows;
import java.util.concurrent.Callable;
/**
* 数据权限 Util
*
* @author 芋道源码
*/
public class DataPermissionUtils {
private static DataPermission DATA_PERMISSION_DISABLE;
@DataPermission(enable = false)
@SneakyThrows
private static DataPermission getDisableDataPermissionDisable() {
if (DATA_PERMISSION_DISABLE == null) {
DATA_PERMISSION_DISABLE = DataPermissionUtils.class
.getDeclaredMethod("getDisableDataPermissionDisable")
.getAnnotation(DataPermission.class);
}
return DATA_PERMISSION_DISABLE;
}
/**
* 忽略数据权限执行对应的逻辑
*
* @param runnable 逻辑
*/
public static void executeIgnore(Runnable runnable) {
addDisableDataPermission();
try {
// 执行 runnable
runnable.run();
} finally {
removeDataPermission();
}
}
/**
* 忽略数据权限执行对应的逻辑
*
* @param callable 逻辑
* @return 执行结果
*/
@SneakyThrows
public static <T> T executeIgnore(Callable<T> callable) {
addDisableDataPermission();
try {
// 执行 callable
return callable.call();
} finally {
removeDataPermission();
}
}
/**
* 添加忽略数据权限
*/
public static void addDisableDataPermission(){
DataPermission dataPermission = getDisableDataPermissionDisable();
DataPermissionContextHolder.add(dataPermission);
}
public static void removeDataPermission(){
DataPermissionContextHolder.remove();
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-dto</artifactId>
</project>

View File

@ -0,0 +1,28 @@
package com.seer.teach.common.dto.mq;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-08-15 13:59
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "方向")
public class DirectionalInputDTO extends MqDTO{
private String angle;
private String padding;
}

View File

@ -0,0 +1,39 @@
package com.seer.teach.common.dto.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* IM回复数据传输对象
* 用于封装IM消息发送后的回复结果信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ImReplyDTO {
/**
* 是否成功
*/
private boolean success;
/**
* 消息错误信息或提示信息
*/
private String msg;
/**
* 返回的业务数据
*/
private String data;
public static ImReplyDTO ok(String data) {
return new ImReplyDTO(true, "success", data);
}
public static ImReplyDTO fail(String msg) {
return new ImReplyDTO(false, msg, null);
}
}

View File

@ -0,0 +1,24 @@
package com.seer.teach.common.dto.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-07-30 14:11
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class IntegerDTO extends MqDTO {
private Integer data;
}

View File

@ -0,0 +1,55 @@
package com.seer.teach.common.dto.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* LLM聊天消息传输对象
* 用于在消息队列中传输LLM聊天相关数据
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class LlmChatDTO extends MqDTO {
/**
* 聊天记录唯一标识符
*/
private Integer id;
/**
* 排序字段用于确定消息顺序
*/
private Integer sort;
/**
* 聊天文本内容
*/
private String text;
/**
* 完整的聊天文本内容
*/
private String allText;
/**
* 音频数据通常为base64编码的音频文件
*/
private String audioData;
/**
* 是否为最终结果标识
* true表示这是最终的聊天结果false表示中间过程数据
*/
private Boolean isFinal;
/**
* 消息队列时间戳
* 记录消息进入队列的时间
*/
private long mqTime;
}

View File

@ -0,0 +1,5 @@
package com.seer.teach.common.dto.mq;
public abstract class MqDTO {
}

View File

@ -0,0 +1,27 @@
package com.seer.teach.common.dto.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* <p>
*
* </p>
*
* @author Captain
* @since 2025-07-24 13:54
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class StringDTO extends MqDTO {
private String data;
public static StringDTO ping(){
return new StringDTO("ping");
}
}

View File

@ -0,0 +1,30 @@
package com.seer.teach.common.dto.mq;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* @Author: Captain
* @Description:
* @Date: 2025-06-07 16:48
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Schema(description = "逐字识别信息")
public class WordItem {
@Schema(description = "识别出的单字")
private String word;
@Schema(description = "起始时间(秒)")
private Double startTime;
@Schema(description = "结束时间(秒)")
private Double endTime;
}

View File

@ -0,0 +1,34 @@
package com.seer.teach.common.dto.mq.dto;
import com.seer.teach.common.dto.mq.WordItem;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-09-09 15:15
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "音频数据返回实体类")
public class AudioDTO {
@Schema(description = "音频URL")
private String audioUrl;
@Schema(description = "音频字幕数据")
private List<WordItem> wordItems;
}

View File

@ -0,0 +1,125 @@
package com.seer.teach.common.dto.mq.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDate;
/**
* 用户缓存数据传输对象
* <p>
* 用于在系统中缓存用户相关信息的DTO类包含用户的基本信息设备信息认证信息等
* </p>
*
* @author Captain
* @since 2025-08-16 18:01
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class UserCacheDTO {
/**
* 设备ID
*/
private Integer deviceId;
/**
* 设备类型
*/
private String device;
/**
* 设备是否在线
*/
private Integer deviceIsOnLine;
/**
* 设备屏幕状态
*/
private Integer screenStatus;
/**
* 设备音频状态
*/
private Integer volumeStatus;
/**
* 用户名
*/
private String userName;
/**
* 密码
*/
private String password;
/**
* 头像URL
*/
private String avatar;
/**
* 真实姓名
*/
private String realName;
/**
* 性别0-未知1-2-
*/
private Integer gender;
/**
* 昵称
*/
private String nickName;
/**
* 手机号码
*/
private String mobile;
/**
* 邮箱地址
*/
private String email;
/**
* 出生日期
*/
private LocalDate birthDate;
/**
* 微信UnionID
*/
private String unionId;
/**
* 微信OpenID
*/
private String openId;
/**
* 年级ID
*/
private Integer gradeId;
/**
* 等级ID
*/
private Integer levelId;
/**
* 经验值
*/
private Integer experience;
/**
* 角色编码
*/
private String roleCode;
}

View File

@ -0,0 +1,30 @@
package com.seer.teach.common.dto.mq.im;
import com.seer.teach.common.dto.mq.MqDTO;
import com.seer.teach.common.dto.mq.WordItem;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
/**
* @Author: Captain
* @Description: Ai聊天返回实体类
* @Date: 2025-06-07 16:49
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "聊天推送实体类数据")
public class AiChatResp extends MqDTO {
@Schema(description = "音频Url")
private String audioUrl;
@Schema(description = "字幕数据")
private List<WordItem> wordItems;
}

View File

@ -0,0 +1,39 @@
package com.seer.teach.common.dto.mq.im;
import com.seer.teach.common.dto.mq.MqDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-08 11:23
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class AiStudyDTO extends MqDTO {
/**
* 是否发送知识点数据
*/
private Boolean kd;
/**
* 是否发送音频数据
*/
private Boolean audio;
/**
* 知识点响应数据对象
*/
private KnowDTO knowData;
}

View File

@ -0,0 +1,45 @@
package com.seer.teach.common.dto.mq.im;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-08 11:26
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class AudioDTO {
/** 消息 ID */
private Long id;
/**
* 音频数据
* <p>
* 存储音频的Base64编码字符串或其他音频格式数据
* </p>
*/
private String audioData;
/**
* 文本内容
* <p>
* 与音频数据对应的文本内容通常是语音识别或文本转语音的结果
* </p>
*/
private String text;
/** 是否最终结果 */
private Boolean isFinal;
}

View File

@ -0,0 +1,30 @@
package com.seer.teach.common.dto.mq.im;
import com.seer.teach.common.dto.mq.MqDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-08-15 15:13
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "缓存设备DTO实体类")
public class CacheDeviceDTO extends MqDTO {
private Integer userId;
private String device;
}

View File

@ -0,0 +1,43 @@
package com.seer.teach.common.dto.mq.im;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-10-08 11:25
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class KnowDTO {
/**
* 知识点名称
*/
private String knowledgePointName;
/**
* 知识点摘总结
*/
private String summary;
/**
* 知识点数据内容
*/
private String knowledgePointData;
/**
* 相关视频URL
*/
private String videoUrl;
}

View File

@ -0,0 +1,35 @@
package com.seer.teach.common.dto.mq.im;
import com.seer.teach.common.dto.mq.MqDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
*
* <p>
*
* </p >
*
* @author Captain
* @since 2025-12-04 16:11
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "知识点数据")
public class KnowledgeDTO extends MqDTO {
@Schema(description = "知识点ID")
private Integer id;
@Schema(description = "知识点名称")
private String knowledgePointName;
@Schema(description = "知识点总结")
private String summary;
}

View File

@ -0,0 +1,41 @@
package com.seer.teach.common.dto.mq.im;
import com.seer.teach.common.dto.mq.MqDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "孩子信息Netty推送实体类")
public class MqUserInfoDTO extends MqDTO {
@Schema(description = "孩子Id")
private Integer id;
@Schema(description = "昵称")
private String nickName;
@Schema(description = "年级")
private String grade;
@Schema(description = "头像")
private String avatar;
@Schema(description = "学豆数量")
private Integer coinNumber;
@Schema(description = "当前等级")
private String currentLevel;
@Schema(description = "经验分数")
private Integer experience;
@Schema(description = "等级图片Url")
private String levelImageUrl;
}

View File

@ -0,0 +1,29 @@
package com.seer.teach.common.dto.mq.im;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-08-01 10:46
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "题目批改解析返回实体类")
public class QuestionCorrectParseHtmlDTO {
@Schema(description = "0 错误 1正确")
private Integer result;
@Schema(description = "解析页面")
private String html;
}

View File

@ -0,0 +1,31 @@
package com.seer.teach.common.dto.mq.webSocket;
import com.seer.teach.common.dto.mq.MqDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-07-30 15:58
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "批改流式Ws推送数据")
public class AiCorrectQuestionResultDTO extends MqDTO {
@Schema(description = "题号")
private Integer index;
@Schema(description = "result")
private Integer result;
}

View File

@ -0,0 +1,35 @@
package com.seer.teach.common.dto.mq.webSocket;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-07-30 20:24
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "做题数据返回实体类")
public class QuestionDTO {
@Schema(description = "题目Id")
private Integer id;
@Schema(description = "题目类型 0其他题型 1单选选择题 2多选选择题 3问答题 4判断题")
private Integer type;
@Schema(description = "选项数据")
private List<String> options;
}

View File

@ -0,0 +1,36 @@
package com.seer.teach.common.dto.mq.webSocket;
import com.seer.teach.common.dto.mq.MqDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
/**
* <p>
*
* </p >
*
* @author Captain
* @since 2025-07-30 20:18
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "websocket做题数据返回实体类")
public class WsMakeQuestionDTO extends MqDTO {
@Schema(description = "任务ID")
private Integer id;
@Schema(description = "type")
private Integer type;
@Schema(description = "题目数据")
private List<QuestionDTO> results;
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.seer.teach</groupId>
<artifactId>seer-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-enums</artifactId>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-dto</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>common-validation</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,105 @@
package com.seer.teach.common.enums;
import com.seer.teach.common.validation.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Collection;
import static cn.hutool.core.util.ArrayUtil.firstMatch;
@AllArgsConstructor
@Getter
public enum AfterSaleStatusEnum implements ArrayValuable<Integer> {
/**
* 没有申请
*/
NON_APPLY(0,"没有申请", "没有申请"),
/**
* 申请售后
*/
APPLY(10,"申请中", "申请退款"),
/**
* 卖家通过售后商品待退货
*/
SELLER_AGREE(20, "卖家通过", "商家同意申请"),
/**
* 卖家拒绝售后卖家拒绝
*/
SELLER_REFUSE_APPLY(21, "卖家拒绝", "商家拒绝申请"),
/**
* 买家已发货等待卖家收货商家待收货
*/
BUYER_DELIVERY(30,"买家已发货", "商家待收货"),
/**
* 卖家已收货等待平台退款等待退款等待退款
*/
WAIT_REFUND(40, "卖家已收获", "商家收货,等待平台退款"),
/**
* 退款失败
*/
REFUND_FAILED(52, "退款失败", "支付渠道退款失败"),
/**
* 完成退款退款成功
*/
COMPLETE(56, "完成", "退款成功"),
/**
* 买家取消
*/
BUYER_CANCEL(61, "买家取消售后", "取消退款"),
/**
* 卖家拒绝售后商家拒绝商家拒绝
*/
SELLER_DISAGREE(62,"卖家拒绝", "商家拒绝退款"),
/**
* 卖家拒绝收货终止售后商家拒收货
*/
SELLER_REFUSE(63,"卖家拒绝收货", "商家拒绝收货"),
;
public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleStatusEnum::getStatus).toArray(Integer[]::new);
/**
* 进行中的售后状态
*
* 不包括已经结束的状态
*/
public static final Collection<Integer> APPLYING_STATUSES = Arrays.asList(
APPLY.getStatus(),
SELLER_AGREE.getStatus(),
BUYER_DELIVERY.getStatus(),
WAIT_REFUND.getStatus()
);
/**
* 状态
*/
private final Integer status;
/**
* 状态名
*/
private final String name;
/**
* 操作内容
*
* 目的记录售后日志的内容
*/
private final String content;
@Override
public Integer[] array() {
return ARRAYS;
}
public static AfterSaleStatusEnum valueOf(Integer status) {
return firstMatch(value -> value.getStatus().equals(status), values());
}
}

View File

@ -0,0 +1,37 @@
package com.seer.teach.common.enums;
import com.seer.teach.common.validation.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
* 交易售后 - 方式
*
* @author Sin
*/
@RequiredArgsConstructor
@Getter
public enum AfterSaleWayEnum implements ArrayValuable<Integer> {
REFUND(10, "仅退款"),
RETURN_AND_REFUND(20, "退货退款");
public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleWayEnum::getWay).toArray(Integer[]::new);
/**
* 方式
*/
private final Integer way;
/**
* 方式名
*/
private final String name;
@Override
public Integer[] array() {
return ARRAYS;
}
}

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