本地工作区快照:恢复 .git 仓库(合并远端 main 前基线)
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
name: ci-userbase-service
|
||||||
|
|
||||||
|
# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted
|
||||||
|
# 触发:push 到 main(本仓库为 UserBaseService 独立仓,代码在根目录)/ 手动 Run workflow
|
||||||
|
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号,仅 docker-hosted 推送权)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'image tag (default = short sha)'
|
||||||
|
required: false
|
||||||
|
default: 'auto'
|
||||||
|
deploy:
|
||||||
|
description: 'deploy to 160 after build (auto / skip)'
|
||||||
|
required: false
|
||||||
|
default: 'auto'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# docker:24 自带 docker CLI,复用宿主 docker daemon。
|
||||||
|
# 注意:act_runner v0.6.1 默认已把宿主 docker.sock 挂入每个 job 容器,
|
||||||
|
# 切勿在此再声明 volumes: docker.sock(会与默认挂载重复,报
|
||||||
|
# "Duplicate mount point" 导致容器创建失败、全部 step cancelled)。
|
||||||
|
container:
|
||||||
|
image: docker:24
|
||||||
|
steps:
|
||||||
|
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
|
||||||
|
- name: Install git (alpine)
|
||||||
|
run: apk add --no-cache git
|
||||||
|
|
||||||
|
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
|
||||||
|
- name: Checkout (manual, internal Gitea)
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
|
||||||
|
cp -a /tmp/src/. .
|
||||||
|
rm -rf /tmp/src
|
||||||
|
|
||||||
|
- name: Compute image tag
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.tag }}" = "auto" ] || [ -z "${{ inputs.tag }}" ]; then
|
||||||
|
echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build jar with Maven
|
||||||
|
run: |
|
||||||
|
# 注意:job 容器通过 docker.sock 调的是【宿主 docker daemon】,
|
||||||
|
# 因此 docker run -v <path> 里的路径会被宿主按【宿主路径】解析;
|
||||||
|
# 而 act 工作区是宿主上的 docker volume,容器内 $PWD 对宿主不可见,
|
||||||
|
# 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。
|
||||||
|
# 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。
|
||||||
|
# mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。
|
||||||
|
# 之前用 --add-host=nexus.yunhaixingtong.com:host-gateway 经 Caddy:80 反代,但该路径在
|
||||||
|
# CI 容器内不可达,maven 回退到 /opt/docker/ubs-m2 里 08-05 的旧 yh-auth 缓存(缺
|
||||||
|
# CasSsoAutoConfiguration),镜像启动报 serverName 崩溃。现改用 settings.xml profile 把
|
||||||
|
# pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/(已验证 -7 可达)。
|
||||||
|
# 先清理上次失败残留的同名容器(set -e 下末尾的 rm 不会执行)
|
||||||
|
docker rm -f mavenbuild 2>/dev/null || true
|
||||||
|
docker run -d --name mavenbuild \
|
||||||
|
--network infra-infra-net \
|
||||||
|
-v /opt/docker/ubs-m2:/root/.m2 \
|
||||||
|
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||||
|
docker cp "${PWD}/." mavenbuild:/build/
|
||||||
|
# 写 settings.xml:新增 profile 把 nexus:8081 maven-public 作为额外仓库(infra-infra-net 内容器名直连,已验证 -7 可达)。
|
||||||
|
# 不用 mirror(external:http:*) 是因为它对 BOM import 解析未生效;profile 方式更可靠。
|
||||||
|
# pom 原有的 nexus.yunhaixingtong.com(Caddy:80) 在 CI 容器内不可解析 -> 快速失败,maven 转而用本仓库。
|
||||||
|
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||||
|
# 仅清旧 yh-auth 缓存(含 08-05 local install 的 maven-metadata-local.xml,避免本地安装优先于远端 -7);
|
||||||
|
# BOM 等其它 yh.platform 模块保留缓存(Nexus 上它们仍是 08-05 的 -1,缓存与远端一致)。
|
||||||
|
docker exec mavenbuild rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
|
||||||
|
docker exec mavenbuild sh -c 'cd /build && mvn -U -B -DskipTests clean package'
|
||||||
|
mkdir -p "${PWD}/target"
|
||||||
|
docker cp mavenbuild:/build/target/userbase.jar "${PWD}/target/userbase.jar"
|
||||||
|
docker rm -f mavenbuild
|
||||||
|
|
||||||
|
- name: Login to Nexus docker registry
|
||||||
|
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -t nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Report build
|
||||||
|
run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}"
|
||||||
|
|
||||||
|
# —— 部署阶段(仅在 push 或手动 dispatch deploy=auto 时执行)——
|
||||||
|
# 运行在 160 的 act_runner 上,复用宿主 docker daemon(docker.sock 由 act_runner 自动挂载),
|
||||||
|
# 因此 docker pull / run 直接作用于 160 宿主,等价于在 160 上执行部署。
|
||||||
|
- name: Deploy (pull + rollout with rollback)
|
||||||
|
if: github.event_name == 'push' || inputs.deploy == 'auto'
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
IMG=nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
|
||||||
|
echo "deploying $IMG"
|
||||||
|
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
|
||||||
|
docker pull "$IMG"
|
||||||
|
# 回滚锚:保留当前 userbase-service:latest(已知良好镜像),再额外打一个时间点标签
|
||||||
|
RB=userbase-service:rb-$(date +%s)
|
||||||
|
docker tag userbase-service:latest "$RB" 2>/dev/null || true
|
||||||
|
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
|
||||||
|
# bootstrap.yml 默认 nacos.config.enabled=false(本地不连);CI 必须注入 NACOS_CONFIG_ENABLED=true 才会拉配置
|
||||||
|
# SPRING_PROFILES_ACTIVE=prod 激活生产 profile(留给将来拆 application-prod.yml 用)
|
||||||
|
# namespace/凭据仍用顶层 SPRING_CLOUD_NACOS_* env(bootstrap 的 ${...} 占位符才能解析到)
|
||||||
|
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \
|
||||||
|
--add-host sso.yunhaixingtong.com:192.168.10.160 \
|
||||||
|
--memory=1.5g --memory-swap=1.5g \
|
||||||
|
-v /data/logs/userbase:/app/logs \
|
||||||
|
-e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \
|
||||||
|
-e SPRING_PROFILES_ACTIVE=prod \
|
||||||
|
-e NACOS_CONFIG_ENABLED=true \
|
||||||
|
-e NACOS_ADDR=nacos:8848 \
|
||||||
|
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
|
||||||
|
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
|
||||||
|
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
|
||||||
|
-e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \
|
||||||
|
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
|
||||||
|
-e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \
|
||||||
|
"$IMG"
|
||||||
|
# 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来
|
||||||
|
OK=0
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ 2>/dev/null || true)
|
||||||
|
if [ "$CODE" != "000" ]; then OK=1; break; fi
|
||||||
|
done
|
||||||
|
if [ "$OK" = "0" ]; then
|
||||||
|
echo "!! UNHEALTHY -> rollback to userbase-service:latest"
|
||||||
|
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
|
||||||
|
# 回滚也必须注入同样的 env,否则 bootstrap 默认 enabled=false 会连不上 nacos 拉不到线上配置
|
||||||
|
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \
|
||||||
|
--add-host sso.yunhaixingtong.com:192.168.10.160 \
|
||||||
|
--memory=1.5g --memory-swap=1.5g \
|
||||||
|
-v /data/logs/userbase:/app/logs \
|
||||||
|
-e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \
|
||||||
|
-e SPRING_PROFILES_ACTIVE=prod \
|
||||||
|
-e NACOS_CONFIG_ENABLED=true \
|
||||||
|
-e NACOS_ADDR=nacos:8848 \
|
||||||
|
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
|
||||||
|
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
|
||||||
|
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
|
||||||
|
-e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \
|
||||||
|
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
|
||||||
|
-e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \
|
||||||
|
userbase-service:latest
|
||||||
|
echo "rolled back"; exit 7
|
||||||
|
fi
|
||||||
|
echo "DEPLOY OK: $IMG"
|
||||||
|
docker tag "$IMG" userbase-service:latest
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep userbase || true
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# ---- java build ----
|
||||||
|
target/
|
||||||
|
|
||||||
|
# ---- IDE ----
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# ---- local tooling ----
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
|
# ---- env secrets (keep *.example) ----
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ---- logs ----
|
||||||
|
*.log
|
||||||
|
deploy/*_run*.log
|
||||||
|
deploy/verify_run*.log
|
||||||
|
deploy/push_run.log
|
||||||
|
|
||||||
|
# ---- OS ----
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ---- scanner artifacts ----
|
||||||
|
defender_chk.txt
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus-releases</id>
|
||||||
|
<username>admin</username>
|
||||||
|
<password>@4Jo8iDj10uSHvg</password>
|
||||||
|
</server>
|
||||||
|
<server>
|
||||||
|
<id>nexus-snapshots</id>
|
||||||
|
<username>admin</username>
|
||||||
|
<password>@4Jo8iDj10uSHvg</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus-public</id>
|
||||||
|
<name>Nexus Public Group</name>
|
||||||
|
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
<allowInsecureProtocol>true</allowInsecureProtocol>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# UserBase 后端镜像(基于已构建好的 target/userbase.jar)
|
||||||
|
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
|
||||||
|
FROM eclipse-temurin:8-jre
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl tzdata \
|
||||||
|
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/userbase.jar /app/userbase.jar
|
||||||
|
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
|
||||||
|
# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。
|
||||||
|
COPY docker/application-prod.yml /app/config/application-prod.yml
|
||||||
|
COPY docker/run-app.sh /app/run-app.sh
|
||||||
|
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
|
||||||
|
|
||||||
|
EXPOSE 8060
|
||||||
|
ENTRYPOINT ["/app/run-app.sh"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底)
|
||||||
|
server:
|
||||||
|
port: 8060
|
||||||
|
servlet:
|
||||||
|
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/userbase
|
||||||
|
context-path: /userbase
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mvc:
|
||||||
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 50MB
|
||||||
|
max-request-size: 200MB
|
||||||
|
jackson:
|
||||||
|
time-zone: GMT+8
|
||||||
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
|
|
||||||
|
redis:
|
||||||
|
host: redis-server
|
||||||
|
port: 6379
|
||||||
|
database: 0
|
||||||
|
# 生产由 nacos application-infra.yaml 注入;此处仅作 nacos 不可达时的兜底,
|
||||||
|
# 密码不写死,运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets,不入镜像)
|
||||||
|
password: ${REDIS_PASSWORD}
|
||||||
|
timeout: 2000ms
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
repositories:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# datasource:
|
||||||
|
# url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||||
|
# username: root
|
||||||
|
# # 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets)
|
||||||
|
# password: ${DB_PASSWORD}
|
||||||
|
# driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
# min-idle: 10
|
||||||
|
# max-active: 30
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database: MySQL
|
||||||
|
show-sql: false
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||||
|
properties:
|
||||||
|
hibernate.order_by.default_null_ordering: last
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||||
|
open-in-view: true
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
host: rabbitmq
|
||||||
|
port: 5672
|
||||||
|
username: admin
|
||||||
|
# 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(来自 Gitea Secrets)
|
||||||
|
password: ${RABBITMQ_PASSWORD}
|
||||||
|
listener:
|
||||||
|
direct:
|
||||||
|
auto-startup: false
|
||||||
|
simple:
|
||||||
|
auto-startup: false
|
||||||
|
|
||||||
|
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||||
|
yh:
|
||||||
|
sso:
|
||||||
|
server-url-prefix: http://sso.yunhaixingtong.com/cas/
|
||||||
|
client-host-url: http://base.yunhaixingtong.com/userbase
|
||||||
|
allow-headers: all
|
||||||
|
allow-origins: all
|
||||||
|
url-pattern: /api/*
|
||||||
|
mobi-api-pattern: /mobiapi/*
|
||||||
|
app-auth:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
ADMIN_TAG_ID: 290
|
||||||
|
|
||||||
|
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||||
|
FILE_PATH: /data/upload
|
||||||
|
FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/
|
||||||
|
UPLOAD_WEB_PATH_HTTP: http://base.yunhaixingtong.com/userbase/upload/file/
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
file:
|
||||||
|
path: /app/logs
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
|
||||||
|
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
|
||||||
|
# 镜像内烤好的 application-prod.yml 兜底默认。
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
|
||||||
|
|
||||||
|
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
|
||||||
|
--spring.profiles.active=prod
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>2.7.18</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.yunhai</groupId>
|
||||||
|
<artifactId>yunhaibase</artifactId>
|
||||||
|
<version>0.0.1</version>
|
||||||
|
<name>UserBase</name>
|
||||||
|
<description>用户中心</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>1.8</java.version>
|
||||||
|
<cn.hutool.version>5.8.36</cn.hutool.version>
|
||||||
|
<easyexcel.version>3.1.2</easyexcel.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-platform-dependencies</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||||
|
<version>2021.0.5.0</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架;密码加密与 CAS 校验共用 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-bootstrap</artifactId>
|
||||||
|
<version>3.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- RabbitMQ:推送订阅消息队列(串行消费 + 死信重试)。版本由 Boot 2.7 父 POM 统一管理 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Cache 抽象层(含 CaffeineCacheManager) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-context-support</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 本地内存缓存兜底:Caffeine(版本由 Boot 2.7 父 POM 统一管理) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson JSON 序列化(Redis value 序列化,解决泛型/乱码) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Jackson Java 8 日期时间支持(LocalDateTime / LocalDate 序列化);
|
||||||
|
虽 spring-boot-starter-json 会传递引入,但此处显式声明以防上层排除 → 序列化异常 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Redisson 核心:分布式锁、限流器、Lua 复杂操作(与 Spring Cache 隔离,手动配置 RedissonClient 避免自动配置冲突) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.redisson</groupId>
|
||||||
|
<artifactId>redisson</artifactId>
|
||||||
|
<version>3.13.6</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba</groupId>
|
||||||
|
<artifactId>fastjson</artifactId>
|
||||||
|
<version>1.2.80</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 核心基础工具 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-core</artifactId>
|
||||||
|
<version>${cn.hutool.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;替代旧 Springfox) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-ui</artifactId>
|
||||||
|
<version>1.7.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- easypoi 导出相关 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.afterturn</groupId>
|
||||||
|
<artifactId>easypoi-base</artifactId>
|
||||||
|
<version>4.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 基础平台架包:认证(CAS/会话)+ 应用间签名鉴权 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-auth-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- 接口规范:成功响应统一包装为 {code,msg,data};异常以应用内 GlobalExceptionHandler 为主,
|
||||||
|
本 starter 的异常处理器仅作「未被处理异常」的兜底。 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-api-std</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
|
||||||
|
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
|
||||||
|
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>userbase</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
|
||||||
|
"Error while storing the mojo status / 拒绝访问" -->
|
||||||
|
<useIncrementalCompilation>false</useIncrementalCompilation>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>com.yunhai.userbase.UserBaseApplication</mainClass>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>nexus-public</id>
|
||||||
|
<name>bsch/nexus public</name>
|
||||||
|
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>true</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<!--
|
||||||
|
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
|
||||||
|
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
|
||||||
|
"Error while storing the mojo status / 拒绝访问"。
|
||||||
|
另:输出目录需从未被 IDE 打开过,否则同一棵输出树下的 application.yml 等资源
|
||||||
|
会被 IDE 的 JPS 预加载进程持有句柄而写不进(拒绝访问)。常规本地构建无需激活此 profile。
|
||||||
|
说明:C:/ci-build 已被 IDE 锁过,现改用全新目录 C:/ub-build。
|
||||||
|
-->
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>ci</id>
|
||||||
|
<build>
|
||||||
|
<directory>C:/ub-build2/userbase</directory>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon
|
||||||
|
// that act_runner already mounts into the job container (/var/run/docker.sock).
|
||||||
|
//
|
||||||
|
// Why this script exists:
|
||||||
|
// act_runner (v0.6.1) ignores both `container.options` extra --volume and
|
||||||
|
// workflow `container.volumes` in this setup, so the host static dir can't be
|
||||||
|
// bind-mounted directly into the job container. But the host docker.sock IS
|
||||||
|
// mounted by default, so we use the Docker HTTP API to:
|
||||||
|
// 1. tar the built assets straight out of THIS container's filesystem
|
||||||
|
// (GET /containers/{jobId}/archive?path=...), and
|
||||||
|
// 2. upload that tar into a throwaway helper container that has
|
||||||
|
// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive),
|
||||||
|
// then `mv` the extracted folder so files land at the correct top level.
|
||||||
|
//
|
||||||
|
// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt
|
||||||
|
// on the HOST via `docker exec` into the helper (the job container itself cannot
|
||||||
|
// write there). The Gitea logs API is unavailable here, so this file is our only
|
||||||
|
// reliable window into what happened.
|
||||||
|
//
|
||||||
|
// No docker CLI and no network egress required — we speak the Docker HTTP API
|
||||||
|
// over the unix socket directly with Node's http module.
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SOCKET = '/var/run/docker.sock';
|
||||||
|
const HOST_STATIC = '/data/www/userbase';
|
||||||
|
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
|
||||||
|
const STAGING = HOST_STATIC + '/_staging';
|
||||||
|
|
||||||
|
// --- low level docker http helpers -------------------------------------------
|
||||||
|
|
||||||
|
function dockerJson(method, p, bodyObj) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = bodyObj ? JSON.stringify(bodyObj) : null;
|
||||||
|
const headers = {};
|
||||||
|
if (data) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
headers['Content-Length'] = Buffer.byteLength(data);
|
||||||
|
}
|
||||||
|
const req = http.request(
|
||||||
|
{ socketPath: SOCKET, path: p, method, headers },
|
||||||
|
(res) => {
|
||||||
|
let buf = '';
|
||||||
|
res.on('data', (c) => (buf += c));
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchive(id, p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath: SOCKET,
|
||||||
|
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
(res) => resolve(res)
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function putArchive(id, p, tarStream) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath: SOCKET,
|
||||||
|
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/x-tar' },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let buf = '';
|
||||||
|
res.on('data', (c) => (buf += c));
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
tarStream.pipe(req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a command inside the helper and return its stdout/stderr (Detach:false so
|
||||||
|
// the call resolves only after the command finishes — reliable for sequencing).
|
||||||
|
async function runExec(id, cmd) {
|
||||||
|
const created = await dockerJson('POST', `/containers/${id}/exec`, {
|
||||||
|
Cmd: ['sh', '-c', cmd],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
});
|
||||||
|
if (created.status >= 400) {
|
||||||
|
throw new Error('exec create failed: ' + created.status + ' ' + created.body);
|
||||||
|
}
|
||||||
|
const execId = JSON.parse(created.body).Id;
|
||||||
|
const started = await dockerJson('POST', `/exec/${execId}/start`, {
|
||||||
|
Detach: false,
|
||||||
|
Tty: false,
|
||||||
|
});
|
||||||
|
return started; // { status, body(stdout+stderr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helper container + diagnostics ------------------------------------------
|
||||||
|
|
||||||
|
function getContainerId() {
|
||||||
|
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||||
|
const m = cg.match(/[0-9a-f]{64}/);
|
||||||
|
if (!m) {
|
||||||
|
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
return m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let HELPER_ID = null;
|
||||||
|
let DEBUG_BUF = [];
|
||||||
|
|
||||||
|
function dbg(line) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
DEBUG_BUF.push(`[${ts}] ${line}`);
|
||||||
|
console.log(line); // also emit to job log (best-effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing
|
||||||
|
// the payload inline (single-quoted) avoids env-var/newline fragility that broke
|
||||||
|
// the earlier diagnostics channel. Uses Detach:false so we know it completed.
|
||||||
|
async function flushDebug() {
|
||||||
|
if (!HELPER_ID) {
|
||||||
|
console.error('flushDebug: no helper, cannot write host debug');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = DEBUG_BUF.join('\n') + '\n';
|
||||||
|
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
||||||
|
const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`;
|
||||||
|
try {
|
||||||
|
const r = await runExec(HELPER_ID, cmd);
|
||||||
|
if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body);
|
||||||
|
else dbg('debug written to host (' + content.length + ' bytes)');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('flushDebug error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main --------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveDistPath() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.cwd(), 'front', 'dist', 'prod'),
|
||||||
|
'/workspace/admin/userbase/front/dist/prod',
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (fs.existsSync(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
dbg('START script version=2026-08-04c');
|
||||||
|
dbg('cwd=' + process.cwd());
|
||||||
|
|
||||||
|
// 1) helper container with the host static dir mounted RW; keep it alive.
|
||||||
|
const createBody = {
|
||||||
|
Image: HELPER_IMAGE,
|
||||||
|
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
|
||||||
|
Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`],
|
||||||
|
};
|
||||||
|
const created = await dockerJson('POST', '/containers/create', createBody);
|
||||||
|
if (created.status >= 400) {
|
||||||
|
throw new Error('create helper failed: ' + created.status + ' ' + created.body);
|
||||||
|
}
|
||||||
|
HELPER_ID = JSON.parse(created.body).Id;
|
||||||
|
dbg('helper created id=' + HELPER_ID);
|
||||||
|
|
||||||
|
const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`);
|
||||||
|
if (started.status >= 400) {
|
||||||
|
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
|
||||||
|
}
|
||||||
|
dbg('helper started');
|
||||||
|
// clean any stale staging from a previous failed run
|
||||||
|
await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`);
|
||||||
|
|
||||||
|
// 2) locate the built assets inside THIS container.
|
||||||
|
const jobId = getContainerId();
|
||||||
|
dbg('job container id=' + jobId);
|
||||||
|
|
||||||
|
const distDir = resolveDistPath();
|
||||||
|
if (!distDir) {
|
||||||
|
dbg('dist dir NOT found; cwd listing:');
|
||||||
|
try {
|
||||||
|
dbg(fs.readdirSync(process.cwd()).join(' '));
|
||||||
|
const front = path.join(process.cwd(), 'front');
|
||||||
|
if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' '));
|
||||||
|
} catch (e) {
|
||||||
|
dbg('ls err: ' + e.message);
|
||||||
|
}
|
||||||
|
throw new Error('built dist/prod not found under any candidate path');
|
||||||
|
}
|
||||||
|
dbg('dist dir=' + distDir);
|
||||||
|
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
|
||||||
|
|
||||||
|
// 3) tar of the built assets, fetched from THIS container's own filesystem.
|
||||||
|
// We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/"
|
||||||
|
// folder; we then mv it into place (deterministic, no reliance on the
|
||||||
|
// trailing-slash "contents only" quirk which this moby build ignores).
|
||||||
|
const tar = await getArchive(jobId, distDir);
|
||||||
|
if (tar.statusCode !== 200) {
|
||||||
|
let msg = '';
|
||||||
|
tar.on('data', (c) => (msg += c));
|
||||||
|
await new Promise((r) => tar.on('end', r));
|
||||||
|
throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg);
|
||||||
|
}
|
||||||
|
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
|
||||||
|
|
||||||
|
// 4) upload the tar into the staging dir of the helper's mounted host dir.
|
||||||
|
const put = await putArchive(HELPER_ID, STAGING, tar);
|
||||||
|
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
|
||||||
|
if (put.status >= 400) {
|
||||||
|
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing).
|
||||||
|
const mv = await runExec(
|
||||||
|
HELPER_ID,
|
||||||
|
`rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}`
|
||||||
|
);
|
||||||
|
dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
|
||||||
|
|
||||||
|
// 6) verify on host via exec into helper.
|
||||||
|
const verify = await runExec(
|
||||||
|
HELPER_ID,
|
||||||
|
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` +
|
||||||
|
`echo "has_stray_prod=$(test -d ${HOST_STATIC}/front/prod && echo YES || echo NO)"; ` +
|
||||||
|
`ls -la ${HOST_STATIC}/front | head -20`
|
||||||
|
);
|
||||||
|
dbg('verify output: ' + verify.body.replace(/\n/g, ' | '));
|
||||||
|
|
||||||
|
dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no prod/ nesting)');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
// On success, keep diagnostics only briefly, then remove the debug file so
|
||||||
|
// it is not left web-served at /userbase/_debug.txt. Failures keep it for
|
||||||
|
// post-mortem (see .catch below).
|
||||||
|
await flushDebug();
|
||||||
|
try {
|
||||||
|
if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('cleanup debug file failed:', e.message);
|
||||||
|
}
|
||||||
|
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
dbg('DEPLOY FAILED: ' + err.stack);
|
||||||
|
await flushDebug(); // keep debug file on failure for post-mortem
|
||||||
|
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
||||||
|
console.error('DEPLOY FAILED:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.yunhai.userbase;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.parser.ParserConfig;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableTransactionManagement
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
|
public class UserBaseApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 开启安全模式
|
||||||
|
ParserConfig.getGlobalInstance().setSafeMode(true);
|
||||||
|
SpringApplication.run(UserBaseApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package com.yunhai.userbase.aspect;
|
||||||
|
|
||||||
|
import com.yunhai.platform.apistd.ApiResponse;
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.userbase.common.annotation.OapiLog;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.entity.SysOapiLog;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||||
|
import com.yunhai.userbase.utils.GzipUtil;
|
||||||
|
import com.yunhai.userbase.utils.IpUtils;
|
||||||
|
import com.yunhai.userbase.utils.OperationLogResolver;
|
||||||
|
import com.yunhai.userbase.utils.SensitiveDataMasker;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口(/oapi/**)调用日志切面。<br>
|
||||||
|
* 拦截 {@code controller.oapi} 包下所有方法,规则:<br>
|
||||||
|
* - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br>
|
||||||
|
* - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br>
|
||||||
|
* - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);<br>
|
||||||
|
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
|
||||||
|
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
public class OapiLogAspect {
|
||||||
|
|
||||||
|
/** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */
|
||||||
|
private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController";
|
||||||
|
|
||||||
|
/** GET 默认不记录,开启本配置后全量记录。 */
|
||||||
|
@Value("${oapi.log-get:false}")
|
||||||
|
private boolean logGet;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysOapiLogService sysOapiLogService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||||
|
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||||
|
if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||||
|
return point.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest request = getRequest();
|
||||||
|
String httpMethod = request != null ? request.getMethod() : "";
|
||||||
|
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
||||||
|
|
||||||
|
OapiLog oapiLog = findAnnotation(point);
|
||||||
|
// 决策:POST 全记;GET 仅当注解强制或配置开启才记
|
||||||
|
if (isGet && oapiLog == null && !logGet) {
|
||||||
|
return point.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
SysOapiLog entity = buildEntity(point, request, httpMethod, oapiLog);
|
||||||
|
|
||||||
|
boolean saveResponse = oapiLog != null ? oapiLog.saveResponse() : true;
|
||||||
|
int maxResponseSize = oapiLog != null ? oapiLog.maxResponseSize() : 10240;
|
||||||
|
|
||||||
|
Object result = null;
|
||||||
|
try {
|
||||||
|
result = point.proceed();
|
||||||
|
entity.setStatusCode(200);
|
||||||
|
fillResponse(entity, result, saveResponse, maxResponseSize);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
entity.setStatusCode(500);
|
||||||
|
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||||
|
// 业务异常(DException/BizException)同样构造实际返回的响应体落库,便于详情下载
|
||||||
|
fillErrorResponse(entity, e, saveResponse, maxResponseSize);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
entity.setCostTime(System.currentTimeMillis() - start);
|
||||||
|
sysOapiLogService.saveAsync(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OapiLog findAnnotation(ProceedingJoinPoint point) {
|
||||||
|
try {
|
||||||
|
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||||
|
Method method = signature.getMethod();
|
||||||
|
return method.getAnnotation(OapiLog.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysOapiLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request,
|
||||||
|
String httpMethod, OapiLog oapiLog) {
|
||||||
|
SysOapiLog entity = new SysOapiLog();
|
||||||
|
|
||||||
|
// 调用方应用:验签 attribute → appKey → application_info 解析应用名(失败记 appKey 原值,不阻塞)
|
||||||
|
resolveCallerApp(request, entity);
|
||||||
|
|
||||||
|
String requestUri = "";
|
||||||
|
if (request != null) {
|
||||||
|
entity.setRequestIp(IpUtils.getIpAddr(request));
|
||||||
|
requestUri = request.getRequestURI();
|
||||||
|
entity.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
entity.setRequestParams(maskRequestParams(point, request));
|
||||||
|
} else {
|
||||||
|
entity.setRequestParams(maskRequestParams(point, null));
|
||||||
|
}
|
||||||
|
entity.setRequestUri(requestUri);
|
||||||
|
entity.setRequestMethod(httpMethod);
|
||||||
|
|
||||||
|
String methodName = point.getSignature().getName();
|
||||||
|
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
|
||||||
|
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
|
||||||
|
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||||
|
if (oapiLog != null && StringUtils.isNotBlank(oapiLog.value())) {
|
||||||
|
entity.setOperationDesc(oapiLog.value());
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 request attribute 解析调用方 appKey,并映射应用名。<br>
|
||||||
|
* 无 attribute / 解析失败:appKey 记 0、应用名记 unknown,仅 warn,不抛异常。
|
||||||
|
*/
|
||||||
|
private void resolveCallerApp(HttpServletRequest request, SysOapiLog entity) {
|
||||||
|
entity.setCallerAppKey(0);
|
||||||
|
entity.setCallerAppName("unknown");
|
||||||
|
if (request == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||||
|
if (attr == null) {
|
||||||
|
log.warn("[oapi-log] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
|
||||||
|
request.getRequestURI());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||||
|
if (StringUtils.isBlank(appKeyStr)) {
|
||||||
|
log.warn("[oapi-log] AppAuth attr is blank, uri={}", request.getRequestURI());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Integer appKey;
|
||||||
|
try {
|
||||||
|
appKey = Integer.parseInt(appKeyStr.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("[oapi-log] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entity.setCallerAppKey(appKey);
|
||||||
|
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
|
||||||
|
entity.setCallerAppName(app != null && StringUtils.isNotBlank(app.getName())
|
||||||
|
? app.getName() : String.valueOf(appKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建请求参数:GET 优先 queryString;其余序列化方法入参(过滤 Servlet 三大件),统一过脱敏。
|
||||||
|
*/
|
||||||
|
private String maskRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
String qs = request.getQueryString();
|
||||||
|
if (StringUtils.isNotBlank(qs)) {
|
||||||
|
return SensitiveDataMasker.maskJson(qs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object[] args = point.getArgs();
|
||||||
|
if (args == null || args.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<Object> filtered = new ArrayList<Object>();
|
||||||
|
for (Object arg : args) {
|
||||||
|
if (arg == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||||
|
|| arg instanceof HttpSession) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.add(arg);
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return SensitiveDataMasker.maskObject(filtered);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入响应结果(脱敏 → 截断 → GZIP)。
|
||||||
|
*/
|
||||||
|
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
||||||
|
if (!saveResponse || result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeResponseData(entity, result, maxResponseSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常路径:与 {@code GlobalExceptionHandler} 保持一致的响应体(DException/BizException → ApiResponse),
|
||||||
|
* 使日志下载内容等于调用方实际收到的 body;其余异常不构造(响应不可预期)。
|
||||||
|
*/
|
||||||
|
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse, int maxResponseSize) {
|
||||||
|
if (!saveResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiResponse<?> response = toApiResponse(e);
|
||||||
|
if (response == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeResponseData(entity, response, maxResponseSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApiResponse<?> toApiResponse(Throwable e) {
|
||||||
|
if (e instanceof DException) {
|
||||||
|
DException de = (DException) e;
|
||||||
|
return ApiResponse.fail(de.getCode(), de.getMessage(), de.getData());
|
||||||
|
}
|
||||||
|
if (e instanceof BizException) {
|
||||||
|
BizException be = (BizException) e;
|
||||||
|
return ApiResponse.fail(be.getErrorCode(), be.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeResponseData(SysOapiLog entity, Object payload, int maxResponseSize) {
|
||||||
|
if (payload == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String resp = SensitiveDataMasker.maskObject(payload);
|
||||||
|
if (resp == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp.length() > maxResponseSize) {
|
||||||
|
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
||||||
|
}
|
||||||
|
entity.setResponseData(GzipUtil.compress(resp));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("开放接口日志响应序列化失败, uri={}", entity.getRequestUri(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpServletRequest getRequest() {
|
||||||
|
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
return attrs != null ? attrs.getRequest() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text, int maxLen) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package com.yunhai.userbase.aspect;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.common.context.UserContext;
|
||||||
|
import com.yunhai.userbase.entity.SysOperationLog;
|
||||||
|
import com.yunhai.userbase.service.ISysOperationLogService;
|
||||||
|
import com.yunhai.userbase.utils.GzipUtil;
|
||||||
|
import com.yunhai.userbase.utils.IpUtils;
|
||||||
|
import com.yunhai.userbase.utils.OperationLogResolver;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志切面。<br>
|
||||||
|
* 拦截所有 Controller 方法,按如下规则自动采集并落库:<br>
|
||||||
|
* - GET 请求:自动记录为「一般(查询)」,无需任何注解;<br>
|
||||||
|
* - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);<br>
|
||||||
|
* - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。<br>
|
||||||
|
* 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/28
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
public class OperationLogAspect {
|
||||||
|
|
||||||
|
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
|
||||||
|
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
|
||||||
|
|
||||||
|
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
|
||||||
|
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysOperationLogService sysOperationLogService;
|
||||||
|
|
||||||
|
@Around("execution(* com.yunhai.userbase.controller..*.*(..))"
|
||||||
|
+ " && !execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||||
|
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||||
|
// 排除日志查看接口自身
|
||||||
|
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||||
|
return point.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest request = getRequest();
|
||||||
|
String httpMethod = request != null ? request.getMethod() : "";
|
||||||
|
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
||||||
|
|
||||||
|
// 解析方法上的 @OperationLog 注解(可能为 null)
|
||||||
|
OperationLog operationLog = findAnnotation(point);
|
||||||
|
|
||||||
|
// 决策:GET 自动记录;非 GET 仅在有注解时记录
|
||||||
|
if (!isGet && operationLog == null) {
|
||||||
|
return point.proceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
SysOperationLog entity = buildEntity(point, request, httpMethod);
|
||||||
|
|
||||||
|
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
|
||||||
|
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
|
||||||
|
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
|
||||||
|
|
||||||
|
Object result = null;
|
||||||
|
try {
|
||||||
|
result = point.proceed();
|
||||||
|
entity.setStatusCode(200);
|
||||||
|
fillResponse(entity, result, saveResponse, maxResponseSize);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
entity.setStatusCode(500);
|
||||||
|
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
entity.setCostTime(System.currentTimeMillis() - start);
|
||||||
|
sysOperationLogService.saveAsync(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从连接点解析方法上的 @OperationLog 注解。
|
||||||
|
*/
|
||||||
|
private OperationLog findAnnotation(ProceedingJoinPoint point) {
|
||||||
|
try {
|
||||||
|
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||||
|
Method method = signature.getMethod();
|
||||||
|
return method.getAnnotation(OperationLog.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装日志实体(基础字段 + 级别/类型判定)。
|
||||||
|
*/
|
||||||
|
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
|
||||||
|
SysOperationLog entity = new SysOperationLog();
|
||||||
|
|
||||||
|
Integer userId = UserContext.getCurrentUser();
|
||||||
|
entity.setUserId(userId != null ? userId.longValue() : 0L);
|
||||||
|
String username = request != null ? request.getRemoteUser() : null;
|
||||||
|
entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous");
|
||||||
|
|
||||||
|
String requestUri = "";
|
||||||
|
if (request != null) {
|
||||||
|
entity.setRequestIp(IpUtils.getIpAddr(request));
|
||||||
|
requestUri = request.getRequestURI();
|
||||||
|
entity.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
entity.setRequestParams(buildRequestParams(point, request));
|
||||||
|
} else {
|
||||||
|
entity.setRequestParams(buildRequestParams(point, null));
|
||||||
|
}
|
||||||
|
entity.setRequestUri(requestUri);
|
||||||
|
entity.setRequestMethod(httpMethod);
|
||||||
|
|
||||||
|
String methodName = point.getSignature().getName();
|
||||||
|
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
|
||||||
|
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
|
||||||
|
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建请求参数。<br>
|
||||||
|
* GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。<br>
|
||||||
|
*/
|
||||||
|
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
String qs = request.getQueryString();
|
||||||
|
if (StringUtils.isNotBlank(qs)) {
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object[] args = point.getArgs();
|
||||||
|
if (args == null || args.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<Object> filtered = new ArrayList<Object>();
|
||||||
|
for (Object arg : args) {
|
||||||
|
if (arg == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||||
|
|| arg instanceof HttpSession) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.add(arg);
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return JSON.toJSONString(filtered);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入响应结果(GZIP 压缩 + 截断)。
|
||||||
|
*/
|
||||||
|
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
||||||
|
if (!saveResponse || result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String resp = JSON.toJSONString(result);
|
||||||
|
if (resp.length() > maxResponseSize) {
|
||||||
|
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
||||||
|
}
|
||||||
|
entity.setResponseData(GzipUtil.compress(resp));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpServletRequest getRequest() {
|
||||||
|
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
return attrs != null ? attrs.getRequest() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text, int maxLen) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.yunhai.userbase.common;
|
||||||
|
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.validation.BindException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
|
||||||
|
import javax.validation.ConstraintViolation;
|
||||||
|
import javax.validation.ConstraintViolationException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回,
|
||||||
|
* 避免前端收到整段 Validation failed ... 堆栈文本。<br>
|
||||||
|
* 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400),
|
||||||
|
* 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。<br>
|
||||||
|
* 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理
|
||||||
|
* (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/** 参数校验失败的业务码,前端按「参数错误」处理 */
|
||||||
|
private static final int CODE_PARAM_ERROR = 4;
|
||||||
|
|
||||||
|
/** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
|
||||||
|
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */
|
||||||
|
@ExceptionHandler(BindException.class)
|
||||||
|
public Map<String, Object> handleBind(BindException ex) {
|
||||||
|
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
|
||||||
|
String msg = ex.getConstraintViolations().stream()
|
||||||
|
.map(ConstraintViolation::getMessage)
|
||||||
|
.collect(Collectors.joining(";"));
|
||||||
|
return paramError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺少必需的请求参数(@RequestParam 必填项未传,如 oapi 调用方漏传 groupId)。 */
|
||||||
|
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||||
|
public Map<String, Object> handleMissingParam(MissingServletRequestParameterException ex) {
|
||||||
|
return paramError("缺少必需的请求参数: " + ex.getParameterName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求参数类型不匹配(如 Integer 参数收到非数字字符串)。 */
|
||||||
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
|
public Map<String, Object> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||||
|
return paramError("参数类型不匹配: " + ex.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
|
||||||
|
if (fieldErrors == null || fieldErrors.isEmpty()) {
|
||||||
|
return "参数校验失败";
|
||||||
|
}
|
||||||
|
return fieldErrors.stream()
|
||||||
|
.map(FieldError::getDefaultMessage)
|
||||||
|
.collect(Collectors.joining(";"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */
|
||||||
|
private Map<String, Object> paramError(String msg) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("code", CODE_PARAM_ERROR);
|
||||||
|
body.put("msg", msg);
|
||||||
|
body.put("data", null);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.yunhai.userbase.common.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口日志注解。<br>
|
||||||
|
* 标注在 {@code controller.oapi} 方法上:
|
||||||
|
* <ul>
|
||||||
|
* <li>POST 写操作无需标注,OapiLogAspect 自动全量记录;</li>
|
||||||
|
* <li>GET 默认不记录(高频查询),标注本注解可强制记录(或开启配置 oapi.log-get=true);</li>
|
||||||
|
* <li>value 提供中文操作描述,落入 operation_desc 字段。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface OapiLog {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作描述,可选。
|
||||||
|
*/
|
||||||
|
String value() default "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||||
|
*/
|
||||||
|
boolean saveResponse() default true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
||||||
|
*/
|
||||||
|
int maxResponseSize() default 10240;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yunhai.userbase.common.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志注解。<br>
|
||||||
|
* 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/28
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface OperationLog {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作描述,可选。
|
||||||
|
*/
|
||||||
|
String value() default "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||||
|
*/
|
||||||
|
boolean saveResponse() default true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
||||||
|
*/
|
||||||
|
int maxResponseSize() default 10240;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.yunhai.userbase.common.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口(oapi)调用方来源上下文:在 oapi 请求链路中保存"触发本次数据变更的订阅方 appKey",
|
||||||
|
* 供业务层发布 {@code DataSyncEvent} 时写入事件来源,推送分发器据此回推时排除来源应用自身
|
||||||
|
* (避免应用 A 自己调 oapi 写接口后又收到自己的回声)。<br><br>
|
||||||
|
* <b>生命周期:</b>由 {@code OapiCallerAppInterceptor} 在 {@code preHandle} 写入、
|
||||||
|
* {@code afterCompletion} 强制清除(防线程池复用泄漏)。管理后台 /api/* 写接口不走 oapi 拦截器,
|
||||||
|
* 此处为 null,表示"来源非订阅方",推送不对其做来源排除(全量投递)。<br><br>
|
||||||
|
* 设计与 {@link UserContext} 同套路(ThreadLocal + remove)。
|
||||||
|
*/
|
||||||
|
public class CallerAppContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Integer> CALLER_APP_KEY = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static void setAppKey(Integer appKey) {
|
||||||
|
CALLER_APP_KEY.set(appKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Integer getAppKey() {
|
||||||
|
return CALLER_APP_KEY.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CALLER_APP_KEY.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.context;
|
||||||
|
|
||||||
|
public class UserContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Integer> CURRENT_USER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static void setCurrentUser(Integer userId) {
|
||||||
|
CURRENT_USER.set(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Integer getCurrentUser() {
|
||||||
|
return CURRENT_USER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CURRENT_USER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClientEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class ClientEnumConverter extends CommonIntEnumConverter<ClientEnum> {
|
||||||
|
|
||||||
|
public ClientEnumConverter() {
|
||||||
|
super(ClientEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用「int <-> 实现 IEnum<Integer> 的枚举」JPA 转换器抽象基类。<br>
|
||||||
|
* <p>
|
||||||
|
* <b>绑定机制(通用做法):</b>为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定,
|
||||||
|
* 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如:
|
||||||
|
* <pre>
|
||||||
|
* public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
||||||
|
* public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有<b>独立的转换器实例</b>,
|
||||||
|
* 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的,
|
||||||
|
* 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param <E> 必须实现 IEnum<Integer> 的枚举类型
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public abstract class CommonIntEnumConverter<E extends Enum<E> & IEnum<Integer>>
|
||||||
|
implements AttributeConverter<E, Integer> {
|
||||||
|
|
||||||
|
private final Class<E> enumCls;
|
||||||
|
|
||||||
|
protected CommonIntEnumConverter(Class<E> enumCls) {
|
||||||
|
this.enumCls = enumCls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer convertToDatabaseColumn(E enumVal) {
|
||||||
|
return enumVal == null ? null : enumVal.getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public E convertToEntityAttribute(Integer dbVal) {
|
||||||
|
if (dbVal == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败)
|
||||||
|
return IEnum.getByKey(enumCls, dbVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.GenderEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenderEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class GenderEnumConverter extends CommonIntEnumConverter<GenderEnum> {
|
||||||
|
|
||||||
|
public GenderEnumConverter() {
|
||||||
|
super(GenderEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GroupTypeEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
||||||
|
|
||||||
|
public GroupTypeEnumConverter() {
|
||||||
|
super(GroupTypeEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.IntegEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IntegEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class IntegEnumConverter extends CommonIntEnumConverter<IntegEnum> {
|
||||||
|
|
||||||
|
public IntegEnumConverter() {
|
||||||
|
super(IntegEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
import javax.persistence.Converter;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/14
|
||||||
|
* @description 将 List<Integer> 与 MySQL JSON 字符串互相转换。
|
||||||
|
* 使用 Jackson 的 {@link TypeReference} 明确反序列化为 List<Integer>,
|
||||||
|
* 避免 Hibernate 内置 JSON 类型把数组元素类型读错的问题。
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class ListIntegerJsonConverter implements AttributeConverter<List<Integer>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final TypeReference<List<Integer>> LIST_INTEGER_TYPE = new TypeReference<List<Integer>>() {
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(List<Integer> attribute) {
|
||||||
|
if (attribute == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("角色列表序列化 JSON 失败: " + attribute, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Integer> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Integer> list = MAPPER.readValue(dbData, LIST_INTEGER_TYPE);
|
||||||
|
return list == null ? Collections.emptyList() : list;
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("角色 JSON 解析失败: " + dbData, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.StatusEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class StatusEnumConverter extends CommonIntEnumConverter<StatusEnum> {
|
||||||
|
|
||||||
|
public StatusEnumConverter() {
|
||||||
|
super(StatusEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SysRoleEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。<br>
|
||||||
|
* 注意:sys_user.roles 是「角色 key 的 JSON 列表」,使用 ListIntegerJsonConverter,
|
||||||
|
* 若某处需要「单角色」字段,再引用本转换器即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class SysRoleEnumConverter extends CommonIntEnumConverter<SysRoleEnum> {
|
||||||
|
|
||||||
|
public SysRoleEnumConverter() {
|
||||||
|
super(SysRoleEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserTypeEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||||
|
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
public class UserTypeEnumConverter extends CommonIntEnumConverter<UserTypeEnum> {
|
||||||
|
|
||||||
|
public UserTypeEnumConverter() {
|
||||||
|
super(UserTypeEnum.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.yunhai.userbase.common.interceptor;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.userbase.common.context.UserContext;
|
||||||
|
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前登录用户写入 {@link UserContext}(供 JPA 审计监听器 @PrePersist/@PreUpdate 与操作日志切面使用)。<br><br>
|
||||||
|
* 登录名取自 CAS 客户端写入的 {@code request.getRemoteUser()}(cas-client-core 过滤器已建立会话);
|
||||||
|
* 再经本地 {@link UserCenterService} 组装 {@link BoaUser} 取其 id 写入 ThreadLocal。
|
||||||
|
* 原 bsch 的 {@code BschBaseOpenApi.getUserInfoWithDept} 与 {@code LocalMemCache} 已移除,
|
||||||
|
* 统一改用基础平台用户模型与本地服务中心。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BoaUserContextInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserCenterService userCenterService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler) {
|
||||||
|
String loginName = request.getRemoteUser();
|
||||||
|
if (StringUtils.isBlank(loginName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
BoaUser user = userCenterService.getUserInfoWithDeptByAccount(loginName);
|
||||||
|
if (user != null && user.getId() != null && user.getId() != 0) {
|
||||||
|
UserContext.setCurrentUser(user.getId());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
UserContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.yunhai.userbase.common.listener;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.userbase.common.context.UserContext;
|
||||||
|
import com.yunhai.userbase.entity.BaseEntity;
|
||||||
|
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.PreUpdate;
|
||||||
|
|
||||||
|
public class UserAuditListener {
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist(BaseEntity entity) {
|
||||||
|
System.out.println("=== prePersist 触发 ===");
|
||||||
|
Integer userId = UserContext.getCurrentUser();
|
||||||
|
System.out.println("ThreadLocal userId: " + userId);
|
||||||
|
if (userId != null) {
|
||||||
|
entity.setCreateUser(userId);
|
||||||
|
entity.setUpdateUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
public void preUpdate(BaseEntity entity) {
|
||||||
|
System.out.println("=== preUpdate 触发 ===");
|
||||||
|
Integer userId = UserContext.getCurrentUser();
|
||||||
|
System.out.println("ThreadLocal userId: " + userId);
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
entity.setUpdateUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.yunhai.userbase.common.validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/15 22:11
|
||||||
|
* @description
|
||||||
|
*/
|
||||||
|
public interface ValidationGroups {
|
||||||
|
interface Create {}
|
||||||
|
interface Update {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||||
|
import com.yunhai.platform.appauth.AppSecretProvider;
|
||||||
|
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥
|
||||||
|
* (覆盖全部「已颁发」应用),并以配置式 {@code yh.app-auth.trustedApps} 兜底平台自身默认密钥。
|
||||||
|
* 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验
|
||||||
|
* 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。<br><br>
|
||||||
|
* 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml
|
||||||
|
* 配置 app-key/app-secret。<br>
|
||||||
|
* <b>注意:</b>两个 Bean 都加 {@code @Primary},保证 yh-platform starter 在按类型注入时
|
||||||
|
* (如 AppSignInterceptor 需要 LocalAppIdentityProvider)不会因多个候选而报
|
||||||
|
* 「required a single bean, but 2 were found」。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AppSecretConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入站验签密钥提供方(按类型匹配 AppSecretProvider,@Primary 优先)。
|
||||||
|
* SignKeyCacheService 注入时按具体类名 UserBaseAppSecretProvider 匹配,不走这个接口类型。
|
||||||
|
*/
|
||||||
|
@Bean(name = "appSecretProvider")
|
||||||
|
@Primary
|
||||||
|
public AppSecretProvider appSecretProvider(ApplicationInfoRepository repository,
|
||||||
|
AppAuthProperties properties) {
|
||||||
|
return new UserBaseAppSecretProvider(repository, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出站身份提供方(按类型匹配 LocalAppIdentityProvider,@Primary 优先)。
|
||||||
|
* AppSignInterceptor 等 yh-platform starter 内部按接口类型注入,需要明确唯一候选。
|
||||||
|
*/
|
||||||
|
@Bean(name = "localAppIdentityProvider")
|
||||||
|
@Primary
|
||||||
|
public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) {
|
||||||
|
return new UserBaseLocalAppIdentityProvider(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.yunhai.userbase.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步线程池配置。<br>
|
||||||
|
* 日志类异步落库统一走有界池 {@code oapiLogExecutor}:<br>
|
||||||
|
* - core 4 / max 16 / queue 1000,高并发下不会为每条日志 new 一个线程;<br>
|
||||||
|
* - DiscardPolicy 丢弃策略:池满时直接丢弃新任务,日志宁可丢失也不拖垮/阻塞主流程。<br>
|
||||||
|
* 管理端操作日志(SysOperationLogServiceImpl.saveAsync)与开放接口日志
|
||||||
|
* (SysOapiLogServiceImpl.saveAsync)均绑定本池。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志异步落库线程池(有界)。
|
||||||
|
*/
|
||||||
|
@Bean("oapiLogExecutor")
|
||||||
|
public Executor oapiLogExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(4);
|
||||||
|
executor.setMaxPoolSize(16);
|
||||||
|
executor.setQueueCapacity(1000);
|
||||||
|
executor.setThreadNamePrefix("oplog-");
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.yunhai.userbase.utils.ConstantUtil;
|
||||||
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||||
|
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnection;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||||
|
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@EnableCaching
|
||||||
|
@Configuration
|
||||||
|
public class CacheConfig {
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一级缓存:Redis 分布式缓存管理器。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
|
||||||
|
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
|
||||||
|
jacksonSerializer.setObjectMapper(newCacheObjectMapper());
|
||||||
|
|
||||||
|
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||||
|
.entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期
|
||||||
|
.serializeKeysWith(RedisSerializationContext.SerializationPair
|
||||||
|
.fromSerializer(new StringRedisSerializer())) // key 用 String
|
||||||
|
.serializeValuesWith(RedisSerializationContext.SerializationPair
|
||||||
|
.fromSerializer(jacksonSerializer)) // value 用 Jackson
|
||||||
|
.disableCachingNullValues(); // 禁止缓存 null(防穿透)
|
||||||
|
|
||||||
|
return RedisCacheManager.builder(factory)
|
||||||
|
.cacheDefaults(cacheConfig)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CaffeineCacheManager caffeineCacheManager() {
|
||||||
|
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||||
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致
|
||||||
|
.maximumSize(10000) // 限制本地容量,防止内存溢出
|
||||||
|
.recordStats());
|
||||||
|
manager.setAllowNullValues(false); // 与 Redis 一致:禁止缓存 null
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认 RedisTemplate(Jackson 序列化),供项目已有工具类(如 RedisUtil)及手动缓存管理使用。<br>
|
||||||
|
* 覆盖 Boot 自动配置的 JDK 序列化默认实现,与 Spring Cache 注解缓存读写互不干扰。<br>
|
||||||
|
* 当 @Cacheable 无法满足需求(如 Hash/List/Set 结构、按字段读写)时,注入本模板或 RedisUtil 手动管理即可。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(factory);
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setValueSerializer(jacksonSerializer());
|
||||||
|
template.setHashValueSerializer(jacksonSerializer());
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。
|
||||||
|
* 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
|
||||||
|
*/
|
||||||
|
@Primary
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager(RedisCacheManager redisCacheManager,
|
||||||
|
CaffeineCacheManager caffeineCacheManager,
|
||||||
|
RedisConnectionFactory redisConnectionFactory) {
|
||||||
|
return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, redisConnectionFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。
|
||||||
|
*/
|
||||||
|
static class FallbackCacheManager implements CacheManager {
|
||||||
|
|
||||||
|
private final RedisCacheManager redisManager;
|
||||||
|
private final CaffeineCacheManager caffeineManager;
|
||||||
|
private final RedisConnectionFactory connectionFactory;
|
||||||
|
private final ScheduledExecutorService healthScheduler =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "redis-cache-health");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private volatile boolean redisUp = true;
|
||||||
|
|
||||||
|
FallbackCacheManager(RedisCacheManager redisManager,
|
||||||
|
CaffeineCacheManager caffeineManager,
|
||||||
|
RedisConnectionFactory connectionFactory) {
|
||||||
|
this.redisManager = redisManager;
|
||||||
|
this.caffeineManager = caffeineManager;
|
||||||
|
this.connectionFactory = connectionFactory;
|
||||||
|
if (connectionFactory != null) {
|
||||||
|
// 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回)
|
||||||
|
healthScheduler.scheduleAtFixedRate(this::probe, 0, 10, TimeUnit.SECONDS);
|
||||||
|
} else {
|
||||||
|
redisUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void probe() {
|
||||||
|
if (connectionFactory == null) {
|
||||||
|
redisUp = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RedisConnection connection = null;
|
||||||
|
try {
|
||||||
|
connection = connectionFactory.getConnection();
|
||||||
|
redisUp = connection.ping() != null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
redisUp = false;
|
||||||
|
} finally {
|
||||||
|
if (connection != null) {
|
||||||
|
try {
|
||||||
|
connection.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 关闭失败忽略,下次探活重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cache getCache(String name) {
|
||||||
|
// AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。
|
||||||
|
// 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s),
|
||||||
|
// resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。
|
||||||
|
return redisUp ? redisManager.getCache(name) : caffeineManager.getCache(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<String> getCacheNames() {
|
||||||
|
return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */
|
||||||
|
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
|
||||||
|
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
|
||||||
|
jackson.setObjectMapper(newCacheObjectMapper());
|
||||||
|
return jackson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建缓存专用 ObjectMapper:类型信息 + 可见性 + Java 8 时间类型支持。 */
|
||||||
|
private ObjectMapper newCacheObjectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
// 允许任意字段可见,保证反序列化不依赖 getter/setter
|
||||||
|
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||||
|
// 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失)
|
||||||
|
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
|
||||||
|
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
|
||||||
|
// 支持 Java 8 时间类型(LocalDateTime 等),否则缓存含时间字段的实体(如 ApplicationInfo.createTime)会序列化失败
|
||||||
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import java.util.ResourceBundle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @version 1.0
|
||||||
|
* @date 2025/4/27 14:31
|
||||||
|
*/
|
||||||
|
|
||||||
|
public enum Config
|
||||||
|
{
|
||||||
|
Inst;
|
||||||
|
|
||||||
|
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
||||||
|
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||||
|
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
|
||||||
|
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi
|
||||||
|
public static final String OPEN_API_PATH = "/oapi/";
|
||||||
|
|
||||||
|
public String getString(String key)
|
||||||
|
{
|
||||||
|
ResourceBundle resource = ResourceBundle.getBundle("config");
|
||||||
|
return resource.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getInt(String key)
|
||||||
|
{
|
||||||
|
String value = getString(key);
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class DWebMvcConfig implements WebMvcConfigurer
|
||||||
|
{
|
||||||
|
@Autowired
|
||||||
|
private BoaUserContextInterceptor boaUserContextInterceptor;
|
||||||
|
@Autowired
|
||||||
|
private OapiCallerAppInterceptor oapiCallerAppInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry)
|
||||||
|
{
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOrigins("*")
|
||||||
|
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.maxAge(3600).allowCredentials(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用
|
||||||
|
registry.addInterceptor(oapiCallerAppInterceptor)
|
||||||
|
.addPathPatterns("/oapi/**");
|
||||||
|
registry.addInterceptor(boaUserContextInterceptor)
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.utils.FileUtil;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件存储配置注入。<br>
|
||||||
|
* 在 Spring 启动完成后,将上传根目录与 Web 访问前缀注入 FileUtil 静态字段,<br>
|
||||||
|
* 供上传工具类拼接完整可访问 URL 使用。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class FileStorageConfig {
|
||||||
|
|
||||||
|
@Value("${FILE_PATH}")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@Value("${FILE_WEB_PATH}")
|
||||||
|
private String fileWebPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时注入存储配置到 FileUtil。<br>
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
FileUtil.init(filePath, fileWebPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
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.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 Jackson 日期格式统一。
|
||||||
|
*
|
||||||
|
* <p>Spring Boot 的 {@code spring.jackson.date-format} 只作用于 {@link java.util.Date},
|
||||||
|
* 对 {@code LocalDateTime}/{@code LocalDate} 无效;且 {@code spring.jackson.local-date-time-format}
|
||||||
|
* 并非 {@code JacksonProperties} 的合法绑定项(会被静默忽略)。本配置把 Nacos 中已配置的
|
||||||
|
* {@code spring.jackson.date-format} 同时应用到 JSR-310 时间类型,做到全局统一。
|
||||||
|
*
|
||||||
|
* <p>仅用 {@code serializerByType}/{@code deserializerByType} 覆盖 JavaTimeModule 默认序列化器,
|
||||||
|
* 不 new 整个 ObjectMapper,因此不会禁用 Spring Boot 的 Jackson 自动配置;
|
||||||
|
* 字段上的 {@code @JsonFormat} 优先级更高,不受影响。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer(
|
||||||
|
@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") String dateFormat) {
|
||||||
|
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
|
||||||
|
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
return (Jackson2ObjectMapperBuilder builder) -> {
|
||||||
|
builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
|
||||||
|
builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter));
|
||||||
|
builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter));
|
||||||
|
builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MyWebAppConfigurer implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Value("${FILE_PATH}")
|
||||||
|
public String FILE_PATH;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
String IMG_FILE_WEB_PATH = "/upload/image/";
|
||||||
|
String File_FILE_WEB_PATH = "/upload/file/";
|
||||||
|
String ICON_FILE_WEB_PATH = "/upload/icon/";
|
||||||
|
String WORD_FILE_WEB_PATH = "/upload/word/";
|
||||||
|
String HTML_FILE_WEB_PATH = "/upload/html/";
|
||||||
|
registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
|
||||||
|
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
|
||||||
|
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
|
||||||
|
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
|
||||||
|
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||||
|
import com.yunhai.userbase.common.context.CallerAppContext;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oapi 调用方来源注入拦截器:在 oapi 请求处理前,从 yh-app-auth 验签过滤器写入的
|
||||||
|
* {@link AppAuthFilter#APP_AUTH_ATTR}(调用方 appKey)解析出整型 appKey,
|
||||||
|
* 存入 {@link CallerAppContext};请求结束(无论成功/异常)在 {@code afterCompletion} 强制清除,
|
||||||
|
* 防止线程池复用导致 appKey 串号、进而误把其他订阅方排除。<br><br>
|
||||||
|
* 配合 {@code PushDispatcher} 的来源排除逻辑,实现"应用自己触发的变更不回推给自己"。
|
||||||
|
* 管理后台 /api/* 不走此拦截器 → CallerAppContext 为 null → 推送对该来源不做排除(全量投递)。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OapiCallerAppInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler) {
|
||||||
|
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||||
|
if (attr == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||||
|
if (StringUtils.isBlank(appKeyStr)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CallerAppContext.setAppKey(Integer.valueOf(appKeyStr.trim()));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// 非法 appKey 格式:不设置来源,等同"非订阅方来源",推送全量投递
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
CallerAppContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||||
|
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||||
|
import com.yunhai.platform.auth.UserDetailsProvider;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
|
||||||
|
import javax.servlet.Filter;
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.ServletRequest;
|
||||||
|
import javax.servlet.ServletResponse;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
|
||||||
|
* <br>
|
||||||
|
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
|
||||||
|
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**})
|
||||||
|
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
|
||||||
|
* 已验签(属性存在)则放行,否则返回 401。<br>
|
||||||
|
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
|
||||||
|
* <br>
|
||||||
|
*
|
||||||
|
* 额外注册 {@code UserDetailsProvider}(@Bean @Primary,名=userDetailsProvider):
|
||||||
|
* 覆盖 yh-auth-spring-boot-starter 新版默认的 NoOpUserDetailsProvider(调用直接抛
|
||||||
|
* UnsupportedOperationException「未配置 UserDetailsProvider」)。必须「显式 @Bean
|
||||||
|
* + @Primary」
|
||||||
|
* 而不能只靠 @Component 扫描:starter 新版 AutoConfiguration 运行顺序可能早于 UserBase 组件扫描,
|
||||||
|
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class })
|
||||||
|
public class OpenApiAuthEnforceConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||||
|
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||||
|
bean.setFilter(new RequireAppSignFilter());
|
||||||
|
bean.addUrlPatterns("/open/api/*", "/oapi/*");
|
||||||
|
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||||
|
return bean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部开放接口(/oapi/internal/*)来源 IP 白名单:
|
||||||
|
* 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。
|
||||||
|
* 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<SignKeyInternalIpFilter> signKeyInternalIpFilter(
|
||||||
|
SignKeyInternalProperties properties) {
|
||||||
|
FilterRegistrationBean<SignKeyInternalIpFilter> bean = new FilterRegistrationBean<>();
|
||||||
|
bean.setFilter(new SignKeyInternalIpFilter(properties));
|
||||||
|
bean.addUrlPatterns("/oapi/internal/*");
|
||||||
|
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9);
|
||||||
|
return bean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
|
||||||
|
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
|
||||||
|
* 与 starter 默认同名,即使开启 allow-bean-definition-overriding=false 也能优先匹配。
|
||||||
|
*/
|
||||||
|
@Bean(name = "userDetailsProvider")
|
||||||
|
@Primary
|
||||||
|
public UserDetailsProvider userDetailsProvider(UserCenterService userCenterService) {
|
||||||
|
return new UserBaseUserDetailsProvider(userCenterService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RequireAppSignFilter implements Filter {
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
HttpServletRequest req = (HttpServletRequest) request;
|
||||||
|
HttpServletResponse res = (HttpServletResponse) response;
|
||||||
|
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||||
|
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码编码器配置:统一使用 BCrypt。<br>
|
||||||
|
* 用户中心存库与后续 CAS 登录校验共用同一编码器,保证加密/校验规则一致。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/23
|
||||||
|
* @description BCrypt 密码编码器 Bean
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class PasswordEncoderConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt 密码编码器。<br>
|
||||||
|
* strength 使用默认 10,兼顾安全与性能;CAS 侧用同一 encoder 的 matches 校验即可。<br>
|
||||||
|
*
|
||||||
|
* @return PasswordEncoder
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.springframework.amqp.core.*;
|
||||||
|
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
|
||||||
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.model.push.PushTaskMessage;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送订阅 MQ 配置。<br><br>
|
||||||
|
* 拓扑:
|
||||||
|
* <pre>
|
||||||
|
* push.topic (topic, durable)
|
||||||
|
* └─ push.main (durable, concurrency=1, x-dead-letter-exchange=push.dlx) ← 串行消费,可观测
|
||||||
|
* push.dlx (fanout, durable)
|
||||||
|
* └─ push.dlq (durable) ← 死信归档(重试耗尽)
|
||||||
|
* </pre>
|
||||||
|
* 连接信息(host/port/user/pwd/vhost)由 Nacos {@code application-infra.yaml} 的 {@code spring.rabbitmq.*}
|
||||||
|
* 公共配置注入,本类不写死(也不改 bootstrap.yml / application.yml)。<br>
|
||||||
|
* 可靠性:消费端手动 ack,失败由 {@code PushConsumer} 应用层自管重试(共 3 次),耗尽进死信队列。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/14
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class PushMqConfig {
|
||||||
|
|
||||||
|
public static final String EXCHANGE_TOPIC = "push.topic";
|
||||||
|
public static final String EXCHANGE_DLX = "push.dlx";
|
||||||
|
public static final String QUEUE_MAIN = "push.main";
|
||||||
|
public static final String QUEUE_DLQ = "push.dlq";
|
||||||
|
|
||||||
|
/** 统一 JSON 消息转换器(固定反序列化为 PushTaskMessage,绕过 trusted-packages 版本差异)。 */
|
||||||
|
@Bean
|
||||||
|
public MessageConverter pushMessageConverter() {
|
||||||
|
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
|
||||||
|
converter.setClassMapper(new PushTaskClassMapper(PushTaskMessage.class));
|
||||||
|
return converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TopicExchange pushTopicExchange() {
|
||||||
|
return new TopicExchange(EXCHANGE_TOPIC, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FanoutExchange pushDlxExchange() {
|
||||||
|
return new FanoutExchange(EXCHANGE_DLX, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主队列:单消费者串行 + 死信路由到 push.dlx。 */
|
||||||
|
@Bean
|
||||||
|
public Queue pushMainQueue() {
|
||||||
|
Map<String, Object> args = new HashMap<>();
|
||||||
|
args.put("x-dead-letter-exchange", EXCHANGE_DLX);
|
||||||
|
return new Queue(QUEUE_MAIN, true, false, false, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 死信队列:归档重试耗尽的任务。 */
|
||||||
|
@Bean
|
||||||
|
public Queue pushDlqQueue() {
|
||||||
|
return new Queue(QUEUE_DLQ, true, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding pushMainBinding() {
|
||||||
|
return BindingBuilder.bind(pushMainQueue()).to(pushTopicExchange()).with("#");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding pushDlqBinding() {
|
||||||
|
return BindingBuilder.bind(pushDlqQueue()).to(pushDlxExchange());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RabbitTemplate pushRabbitTemplate(ConnectionFactory connectionFactory) {
|
||||||
|
RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||||
|
template.setMessageConverter(pushMessageConverter());
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费者容器:手动 ack + 单并发 + prefetch=1,保证串行可观测。 */
|
||||||
|
@Bean(name = "pushListenerContainerFactory")
|
||||||
|
public SimpleRabbitListenerContainerFactory pushListenerContainerFactory(ConnectionFactory connectionFactory) {
|
||||||
|
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
|
||||||
|
factory.setConnectionFactory(connectionFactory);
|
||||||
|
factory.setMessageConverter(pushMessageConverter());
|
||||||
|
factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.MANUAL);
|
||||||
|
factory.setConcurrentConsumers(1);
|
||||||
|
factory.setMaxConcurrentConsumers(1);
|
||||||
|
factory.setPrefetchCount(1);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
|
import org.springframework.amqp.support.converter.ClassMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送消息固定反序列化为 {@link PushTaskMessage},绕过 Jackson2JsonMessageConverter 的
|
||||||
|
* trusted-packages 安全限制(不同 spring-amqp 版本该方法签名/默认值不一致,编译期不可靠)。<br>
|
||||||
|
* 生产者端序列化仍由 Jackson2JsonMessageConverter 完成(写入 __TypeId__),消费者端忽略 __TypeId__
|
||||||
|
* 直接按本映射器返回 PushTaskMessage,类型单一且可控。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/14
|
||||||
|
*/
|
||||||
|
public class PushTaskClassMapper implements ClassMapper {
|
||||||
|
|
||||||
|
private final Class<?> payloadClass;
|
||||||
|
|
||||||
|
public PushTaskClassMapper(Class<?> payloadClass) {
|
||||||
|
this.payloadClass = payloadClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Class<?> toClass(MessageProperties properties) {
|
||||||
|
return payloadClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void fromClass(Class<?> clazz, MessageProperties properties) {
|
||||||
|
properties.setHeader("__TypeId__", clazz.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.Inet6Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部开放接口(/oapi/internal/*)来源 IP 白名单过滤器。<p>
|
||||||
|
* 防止密钥兜底端点(/oapi/internal/sign-key/{appId})被公网直接枚举调用;
|
||||||
|
* 只允许内网 / 显式配置的白名单来源通过,未命中返回 403。<p>
|
||||||
|
* 支持 X-Forwarded-For / X-Real-IP 等常见代理头解析,取最左侧真实来源 IP 做校验。
|
||||||
|
*/
|
||||||
|
public class SignKeyInternalIpFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SignKeyInternalIpFilter.class);
|
||||||
|
|
||||||
|
private final SignKeyInternalProperties properties;
|
||||||
|
|
||||||
|
public SignKeyInternalIpFilter(SignKeyInternalProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String sourceIp = resolveSourceIp(request);
|
||||||
|
List<String> effectiveIps = properties.getEffectiveIps();
|
||||||
|
if (CollectionUtils.isEmpty(effectiveIps) || matchAny(sourceIp, effectiveIps)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.warn("[SignKeyInternal] 拒绝非白名单来源访问内部接口: uri={}, sourceIp={}, whitelist={}",
|
||||||
|
request.getRequestURI(), sourceIp, effectiveIps);
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":403,\"msg\":\"内部接口不允许从该来源访问\",\"data\":null}");
|
||||||
|
response.getWriter().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveSourceIp(HttpServletRequest request) {
|
||||||
|
String[] headers = new String[] {
|
||||||
|
"X-Forwarded-For",
|
||||||
|
"X-Real-IP",
|
||||||
|
"Proxy-Client-IP",
|
||||||
|
"WL-Proxy-Client-IP",
|
||||||
|
"HTTP_CLIENT_IP",
|
||||||
|
"HTTP_X_FORWARDED_FOR"
|
||||||
|
};
|
||||||
|
for (String header : headers) {
|
||||||
|
String raw = request.getHeader(header);
|
||||||
|
if (raw == null || raw.isEmpty() || "unknown".equalsIgnoreCase(raw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// X-Forwarded-For 可能是 多个IP逗号分隔,取最左侧
|
||||||
|
String first = raw.split(",")[0].trim();
|
||||||
|
if (!first.isEmpty()) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchAny(String ip, List<String> cidrs) {
|
||||||
|
for (String cidr : cidrs) {
|
||||||
|
if (matchCidr(ip, cidr)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchCidr(String ip, String cidr) {
|
||||||
|
if (ip == null || cidr == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int prefixLen;
|
||||||
|
String network;
|
||||||
|
if (cidr.contains("/")) {
|
||||||
|
String[] parts = cidr.split("/");
|
||||||
|
network = parts[0].trim();
|
||||||
|
prefixLen = Integer.parseInt(parts[1].trim());
|
||||||
|
} else {
|
||||||
|
network = cidr.trim();
|
||||||
|
// 判断地址族决定默认前缀长度
|
||||||
|
InetAddress na = InetAddress.getByName(network);
|
||||||
|
prefixLen = (na instanceof Inet6Address) ? 128 : 32;
|
||||||
|
}
|
||||||
|
InetAddress ipAddr = InetAddress.getByName(ip);
|
||||||
|
InetAddress netAddr = InetAddress.getByName(network);
|
||||||
|
if (ipAddr.getClass() != netAddr.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
byte[] ipBytes = ipAddr.getAddress();
|
||||||
|
byte[] netBytes = netAddr.getAddress();
|
||||||
|
int bits = prefixLen;
|
||||||
|
for (int i = 0; i < ipBytes.length && bits > 0; i++) {
|
||||||
|
int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits));
|
||||||
|
if ((ipBytes[i] & mask) != (netBytes[i] & mask)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bits -= 8;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (UnknownHostException | NumberFormatException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部开放接口(/oapi/internal/*)访问白名单 IP 配置。<br>
|
||||||
|
* 支持单 IP、CIDR(如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1/32)。<br>
|
||||||
|
* <p>
|
||||||
|
* 可在 Nacos 上配置,例如:<pre>
|
||||||
|
* yh:
|
||||||
|
* sign-key-internal:
|
||||||
|
* ips:
|
||||||
|
* - 10.0.0.0/8
|
||||||
|
* - 172.16.0.0/12
|
||||||
|
* - 192.168.0.0/16
|
||||||
|
* - 127.0.0.1/32
|
||||||
|
* - 172.20.10.5/32 # 某台固定机器
|
||||||
|
* </pre>
|
||||||
|
* 未配置时,默认允许 10/172.16-31/192.168 私有段 + 127.0.0.1(方便本地开发/内网部署)。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "yh.sign-key-internal")
|
||||||
|
public class SignKeyInternalProperties {
|
||||||
|
|
||||||
|
/** 显式允许的来源 IP(CIDR 或 单 IP)。为空时使用默认私有段规则。 */
|
||||||
|
private List<String> ips = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 是否启用白名单校验;设置 false 可临时关闭(生产不建议)。 */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
public List<String> getIps() { return ips; }
|
||||||
|
|
||||||
|
public void setIps(List<String> ips) { this.ips = ips; }
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
/** 未配置 ips 时返回默认私有段 + localhost 规则。 */
|
||||||
|
public List<String> getEffectiveIps() {
|
||||||
|
if (ips != null && !ips.isEmpty()) {
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
return Arrays.asList(
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"127.0.0.1/32",
|
||||||
|
"0:0:0:0:0:0:0:1/128"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import org.springdoc.core.customizers.GlobalOperationCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
|
||||||
|
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数,
|
||||||
|
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI userBaseOpenAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("用户中心接口文档")
|
||||||
|
.description("UserBase 用户中心 OpenAPI 文档(springdoc-openapi)")
|
||||||
|
.version("0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 隐藏切面注入的登录用户参数,保持文档整洁。 */
|
||||||
|
@Bean
|
||||||
|
public GlobalOperationCustomizer hideAuthUserParam() {
|
||||||
|
return (operation, handlerMethod) -> {
|
||||||
|
if (operation.getParameters() != null) {
|
||||||
|
operation.getParameters().removeIf(p -> "authUserWithDept".equals(p.getName()));
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||||
|
import com.yunhai.platform.appauth.AppSecretProvider;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserBase 作为「应用密钥权威源」的验签密钥提供方。<br>
|
||||||
|
* <br>
|
||||||
|
* 解析优先级:<br>
|
||||||
|
* 1) 本库 application_info —— 覆盖全部「已颁发」应用,新增/重置密钥后即时生效;<br>
|
||||||
|
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps} —— 平台自身默认密钥(如 应用中心 100616)
|
||||||
|
* 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。<br>
|
||||||
|
* <br>
|
||||||
|
* 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。<br>
|
||||||
|
* <br>
|
||||||
|
* 这样 UserBase 作为颁发方不依赖 Redis 预热、也不局限于 trustedApps 写死的少量密钥,
|
||||||
|
* 能够验签所有它已颁发的应用。
|
||||||
|
*/
|
||||||
|
public class UserBaseAppSecretProvider implements AppSecretProvider {
|
||||||
|
|
||||||
|
private final ApplicationInfoRepository repository;
|
||||||
|
private final Map<String, String> trustedApps;
|
||||||
|
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
private static final long TTL_MS = 60_000L;
|
||||||
|
|
||||||
|
public UserBaseAppSecretProvider(ApplicationInfoRepository repository, AppAuthProperties properties) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.trustedApps = properties.getTrustedApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getSecret(String appKey) {
|
||||||
|
if (appKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
CacheEntry hit = cache.get(appKey);
|
||||||
|
if (hit != null && now - hit.ts < TTL_MS) {
|
||||||
|
return hit.secret;
|
||||||
|
}
|
||||||
|
String secret = null;
|
||||||
|
try {
|
||||||
|
Integer id = parse(appKey);
|
||||||
|
if (id != null) {
|
||||||
|
ApplicationInfo info = repository.findByAppKey(id);
|
||||||
|
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||||
|
secret = info.getAppSecret();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 查询异常不阻断,继续走配置兜底
|
||||||
|
}
|
||||||
|
if (secret == null) {
|
||||||
|
secret = trustedApps.get(appKey);
|
||||||
|
}
|
||||||
|
cache.put(appKey, new CacheEntry(now, secret));
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动失效:立刻清除本地缓存中指定 appKey 的条目,密钥重置后调用 → 下一次 getSecret 直查 DB 拿新值,
|
||||||
|
* 不再受最长 60s TTL 生效窗口限制。
|
||||||
|
*/
|
||||||
|
public void invalidate(String appKey) {
|
||||||
|
if (appKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cache.remove(appKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动失效:立刻清空全部本地缓存(refreshAll / Redis 全量对账时调用)。 */
|
||||||
|
public void invalidateAll() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer parse(String appKey) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(appKey.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CacheEntry {
|
||||||
|
final long ts;
|
||||||
|
final String secret;
|
||||||
|
|
||||||
|
CacheEntry(long ts, String secret) {
|
||||||
|
this.ts = ts;
|
||||||
|
this.secret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserBase 自身作为「出站签名调用方」的身份提供方。<br><br>
|
||||||
|
* 约定:<br>
|
||||||
|
* 1) appKey 固定占用 100000(基石应用段),由 application_bootstrap.sql 初始化脚本幂等插入;<br>
|
||||||
|
* 2) appSecret 从 application_info 表按 appKey=100000 读取,带 60s 本地缓存(密钥重设后 60s 内生效)。<br><br>
|
||||||
|
* 显式覆盖 yh-app-auth-spring-boot-starter 默认的 PropertyLocalAppIdentityProvider(yml 不再需要
|
||||||
|
* 配置 yh.app-auth.app-key / app-secret)。
|
||||||
|
*/
|
||||||
|
public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvider {
|
||||||
|
|
||||||
|
private static final String USER_CENTER_APP_KEY = "100000";
|
||||||
|
private static final int USER_CENTER_APP_KEY_INT = 100000;
|
||||||
|
private static final long TTL_MS = 60_000L;
|
||||||
|
|
||||||
|
private final ApplicationInfoRepository repository;
|
||||||
|
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public UserBaseLocalAppIdentityProvider(ApplicationInfoRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAppKey() {
|
||||||
|
return USER_CENTER_APP_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAppSecret() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
CacheEntry hit = cache.get(USER_CENTER_APP_KEY);
|
||||||
|
if (hit != null && now - hit.ts < TTL_MS) {
|
||||||
|
return hit.secret;
|
||||||
|
}
|
||||||
|
String secret = null;
|
||||||
|
try {
|
||||||
|
ApplicationInfo info = repository.findByAppKey(USER_CENTER_APP_KEY_INT);
|
||||||
|
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||||
|
secret = info.getAppSecret();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
cache.put(USER_CENTER_APP_KEY, new CacheEntry(now, secret));
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动失效:立刻清除 UserBase 自身(appKey=100000)的本地缓存,密钥重置后调用 →
|
||||||
|
* 下一次 getAppSecret 直查 DB 拿新值,不再受最长 60s TTL 生效窗口限制。
|
||||||
|
*/
|
||||||
|
public void invalidate() {
|
||||||
|
cache.remove(USER_CENTER_APP_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CacheEntry {
|
||||||
|
final long ts;
|
||||||
|
final String secret;
|
||||||
|
|
||||||
|
CacheEntry(long ts, String secret) {
|
||||||
|
this.ts = ts;
|
||||||
|
this.secret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.yunhai.userbase.config;
|
||||||
|
|
||||||
|
import com.yunhai.platform.auth.UserDetailsProvider;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||||
|
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后,
|
||||||
|
* 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。<br><br>
|
||||||
|
*
|
||||||
|
* 本类不再加 {@code @Component},改为由 {@link OpenApiAuthEnforceConfiguration}
|
||||||
|
* 显式以 {@code @Bean @Primary} 注册(bean 名固定为 {@code userDetailsProvider}),
|
||||||
|
* 原因:yh-auth-spring-boot-starter 新版 AutoConfiguration 处理顺序可能早于 UserBase 组件扫描,
|
||||||
|
* 导致其默认注册的 {@code NoOpUserDetailsProvider}(直接抛 UnsupportedOperationException)
|
||||||
|
* 抢先被 PathBasedAuthInterceptor 注入。显式 @Bean+@Primary 可稳定覆盖默认实现。
|
||||||
|
*/
|
||||||
|
public class UserBaseUserDetailsProvider implements UserDetailsProvider {
|
||||||
|
|
||||||
|
private final UserCenterService userCenterService;
|
||||||
|
|
||||||
|
public UserBaseUserDetailsProvider(UserCenterService userCenterService) {
|
||||||
|
this.userCenterService = userCenterService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BoaUser loadByLoginName(String loginName) {
|
||||||
|
if (loginName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userCenterService.getUserInfoWithDeptByAccount(loginName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BoaUser loadByPhone(String phone) {
|
||||||
|
if (phone == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userCenterService.getUserInfoWithDeptByPhone(phone.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||||
|
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
|
||||||
|
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
|
||||||
|
import com.yunhai.userbase.model.approle.AppRoleUserVo;
|
||||||
|
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
|
||||||
|
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
|
||||||
|
import com.yunhai.userbase.model.approle.SysAppRoleVo;
|
||||||
|
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
|
||||||
|
import com.yunhai.userbase.service.IAppRoleService;
|
||||||
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.web.SortDefault;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/27
|
||||||
|
* @description 应用业务角色管理(角色 CRUD + 角色模块授权 + 用户角色绑定)。<br>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/approle/")
|
||||||
|
public class AppRoleController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAppRoleService appRoleService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称
|
||||||
|
*/
|
||||||
|
@GetMapping("/RoleList")
|
||||||
|
public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery,
|
||||||
|
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
|
||||||
|
return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色:普通角色(allAppFlag=false,appId 必填,同应用下名称唯一);<br>
|
||||||
|
* 全应用角色(allAppFlag=true,appId 为空,全应用角色之间名称唯一)。
|
||||||
|
*/
|
||||||
|
@OperationLog("新增角色")
|
||||||
|
@PostMapping("RoleAdd")
|
||||||
|
public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) {
|
||||||
|
appRoleService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑角色:仅可改 name/remark/disabled;allAppFlag 与 appId 不允许修改。
|
||||||
|
*/
|
||||||
|
@OperationLog("编辑角色")
|
||||||
|
@PostMapping("RoleEdit/{id}")
|
||||||
|
public void roleEdit(@PathVariable Integer id,
|
||||||
|
@RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) {
|
||||||
|
appRoleService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除角色:级联清理该角色的模块授权与用户绑定记录。
|
||||||
|
*/
|
||||||
|
@OperationLog("删除角色")
|
||||||
|
@PostMapping("RoleDelete/{id}")
|
||||||
|
public void roleDelete(@PathVariable Integer id) {
|
||||||
|
appRoleService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色模块授权回显:返回该角色可授权的应用及模块树(已授权 checked=true)。<br>
|
||||||
|
* 普通角色仅本应用;全应用角色返回全部应用(可跨应用授权)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/RoleModuleTree")
|
||||||
|
public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) {
|
||||||
|
return appRoleService.findRoleModuleTree(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色模块授权保存:全量覆盖式更新该角色的模块授权记录。
|
||||||
|
*/
|
||||||
|
@OperationLog("保存角色模块授权")
|
||||||
|
@PostMapping("SaveRoleModules")
|
||||||
|
public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) {
|
||||||
|
appRoleService.saveRoleModules(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询角色已绑定的用户列表。
|
||||||
|
*/
|
||||||
|
@GetMapping("/RoleUsers")
|
||||||
|
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
|
||||||
|
return appRoleService.findRoleUsers(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色绑定用户保存(角色维度,全量覆盖)。
|
||||||
|
*/
|
||||||
|
@OperationLog("保存角色用户绑定")
|
||||||
|
@PostMapping("SaveRoleUsers")
|
||||||
|
public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) {
|
||||||
|
appRoleService.saveRoleUsers(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户绑定角色保存(用户维度,全量覆盖)。
|
||||||
|
*/
|
||||||
|
@OperationLog("保存用户角色绑定")
|
||||||
|
@PostMapping("SaveUserRoles")
|
||||||
|
public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) {
|
||||||
|
appRoleService.saveUserRoles(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。<br>
|
||||||
|
* 前端「用户管理 - 删除」调用此接口。
|
||||||
|
*/
|
||||||
|
@OperationLog("移除角色用户绑定")
|
||||||
|
@PostMapping("RemoveRoleUser")
|
||||||
|
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
|
||||||
|
appRoleService.removeRoleUser(roleId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||||
|
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||||
|
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
|
||||||
|
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
|
||||||
|
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||||
|
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||||
|
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||||
|
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||||
|
import com.yunhai.userbase.model.application.PushSettingRequest;
|
||||||
|
import com.yunhai.userbase.model.application.PushSettingVo;
|
||||||
|
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
|
||||||
|
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
|
||||||
|
import com.yunhai.userbase.model.appmodule.AppModuleVo;
|
||||||
|
import com.yunhai.userbase.service.IAppCategoryService;
|
||||||
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
|
import com.yunhai.userbase.service.IAppModuleService;
|
||||||
|
import com.yunhai.userbase.service.impl.SignKeyCacheService;
|
||||||
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.web.SortDefault;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/app/")
|
||||||
|
public class AppSettingsController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAppCategoryService appCategoryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IApplicationInfoService applicationInfoService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAppModuleService appModuleService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SignKeyCacheService signKeyCacheService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@OperationLog("新增应用类型")
|
||||||
|
@PostMapping("AddType")
|
||||||
|
public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) {
|
||||||
|
appCategoryService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("编辑应用类型")
|
||||||
|
@PostMapping("EditType/{id}")
|
||||||
|
public void editType(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request) {
|
||||||
|
appCategoryService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("删除应用类型")
|
||||||
|
@PostMapping("DeleteType/{id}")
|
||||||
|
public void deleteType(@PathVariable Integer id) {
|
||||||
|
appCategoryService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/AppTypeList")
|
||||||
|
public List<AppCategoryVo> appTypeList(AppCategoryQuery query) {
|
||||||
|
return appCategoryService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("应用类型排序")
|
||||||
|
@PostMapping("/AppTypeSort")
|
||||||
|
public void appTypeSort(@RequestBody List<Integer> ids) {
|
||||||
|
appCategoryService.sort(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br>
|
||||||
|
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
|
||||||
|
*/
|
||||||
|
@GetMapping("/AppAuthzList")
|
||||||
|
public List<AppAuthzCategoryVo> appAuthzList() {
|
||||||
|
return appCategoryService.listWithApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("新增应用")
|
||||||
|
@PostMapping("AddApp")
|
||||||
|
public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) {
|
||||||
|
applicationInfoService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("编辑应用")
|
||||||
|
@PostMapping("EditApp/{id}")
|
||||||
|
public void editApp(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request) {
|
||||||
|
applicationInfoService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("删除应用")
|
||||||
|
@PostMapping("DeleteApp/{id}")
|
||||||
|
public void deleteApp(@PathVariable Integer id) {
|
||||||
|
applicationInfoService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("重置应用密钥")
|
||||||
|
@PostMapping("ResetAppSecret/{id}")
|
||||||
|
public String resetAppSecret(@PathVariable Integer id) {
|
||||||
|
applicationInfoService.resetAppSecret(id);
|
||||||
|
ApplicationInfo info= applicationInfoService.getById(id);
|
||||||
|
return info.getAppSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("全量刷新应用签名密钥到Redis")
|
||||||
|
@PostMapping("RefreshSignKeys")
|
||||||
|
public Map<String, Object> refreshSignKeys() {
|
||||||
|
int count = signKeyCacheService.refreshAll();
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("refreshed", count);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/AppList")
|
||||||
|
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
|
||||||
|
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort) {
|
||||||
|
return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("应用排序")
|
||||||
|
@PostMapping("/AppSort")
|
||||||
|
public void appSort(@RequestBody List<Integer> ids) {
|
||||||
|
applicationInfoService.sort(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/AppSortList")
|
||||||
|
public List<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId) {
|
||||||
|
return applicationInfoService.findListByCategoryId(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("新增模块")
|
||||||
|
@PostMapping("AddModule")
|
||||||
|
public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) {
|
||||||
|
appModuleService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("编辑模块")
|
||||||
|
@PostMapping("EditModule/{id}")
|
||||||
|
public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) {
|
||||||
|
appModuleService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("删除模块")
|
||||||
|
@PostMapping("DeleteModule/{id}")
|
||||||
|
public void deleteModule(@PathVariable Integer id) {
|
||||||
|
appModuleService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ModuleList")
|
||||||
|
public List<AppModuleVo> moduleList(AppModuleQuery query) {
|
||||||
|
return appModuleService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("保存应用推送设置")
|
||||||
|
@PostMapping("SavePushSetting/{id}")
|
||||||
|
public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) {
|
||||||
|
applicationInfoService.savePushSetting(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("GetPushSetting/{id}")
|
||||||
|
public PushSettingVo getPushSetting(@PathVariable Integer id) {
|
||||||
|
return applicationInfoService.getPushSetting(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.enums.*;
|
||||||
|
import com.yunhai.userbase.utils.*;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import lombok.var;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.view.RedirectView;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH +"/base/")
|
||||||
|
public class BaseController {
|
||||||
|
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisUtil redisUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一般hashMap初始化大小
|
||||||
|
*/
|
||||||
|
public static final int initialCapacity = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用上传允许的子目录(与 MyWebAppConfigurer.addResourceHandlers 的资源映射一一对应)。<br>
|
||||||
|
* 注:html 因 FileUtil 的危险后缀黑名单被禁止通过此接口上传,故未列入,避免存储可被浏览器执行的 HTML 引发 XSS。
|
||||||
|
*/
|
||||||
|
private static final Set<String> ALLOWED_UPLOAD_TYPES = new HashSet<>(Arrays.asList("image", "file", "icon", "word"));
|
||||||
|
|
||||||
|
@GetMapping("GetUserInfo")
|
||||||
|
public Object getUserInfo(BoaUser authUserWithDept){
|
||||||
|
return authUserWithDept;
|
||||||
|
}
|
||||||
|
|
||||||
|
//返回系统枚举2
|
||||||
|
@GetMapping("GetSysDict")
|
||||||
|
public Map getSysDict(BoaUser authUserWithDept){
|
||||||
|
Map<String, List> resultMap = new HashMap<>(7);
|
||||||
|
resultMap.put("grouptype", IEnum.toSimpleMapList(GroupTypeEnum.class, "k", "v"));
|
||||||
|
resultMap.put("gender", IEnum.toSimpleMapList(GenderEnum.class, "k", "v"));
|
||||||
|
resultMap.put("client", IEnum.toSimpleMapList(ClientEnum.class, "k", "v"));
|
||||||
|
resultMap.put("integ", IEnum.toSimpleMapList(IntegEnum.class, "k", "v"));
|
||||||
|
resultMap.put("status", IEnum.toSimpleMapList(StatusEnum.class, "k", "v"));
|
||||||
|
resultMap.put("sysrole", IEnum.toSimpleMapList(SysRoleEnum.class, "k", "v"));
|
||||||
|
resultMap.put("mode", IEnum.toSimpleMapList(SysModeEnum.class, "k", "v"));
|
||||||
|
return resultMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。<br>
|
||||||
|
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
|
||||||
|
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
|
||||||
|
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<br>
|
||||||
|
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。
|
||||||
|
*
|
||||||
|
* @return 本次演示各步骤的结果快照
|
||||||
|
*/
|
||||||
|
@GetMapping("ManualHashCacheDemo")
|
||||||
|
public Map<String, Object> manualHashCacheDemo() {
|
||||||
|
Map<String, Object> result = new HashMap<>(8);
|
||||||
|
String hashKey = "demo:user:1001";
|
||||||
|
// 1) 写入整张 hash(带 TTL):适合一次性缓存一个对象的多个属性
|
||||||
|
Map<String, Object> profile = new HashMap<>(4);
|
||||||
|
profile.put("name", "yunhai");
|
||||||
|
profile.put("age", 18);
|
||||||
|
profile.put("role", "admin");
|
||||||
|
redisUtil.hmset(hashKey, profile, 300);
|
||||||
|
// 2) 只更新其中一个字段,无需整体覆盖
|
||||||
|
redisUtil.hset(hashKey, "role", "superadmin");
|
||||||
|
// 3) 读取单个字段
|
||||||
|
result.put("roleAfterUpdate", redisUtil.hget(hashKey, "role"));
|
||||||
|
// 4) 读取整张 hash
|
||||||
|
result.put("wholeHash", redisUtil.hmget(hashKey));
|
||||||
|
// 5) 判断字段是否存在
|
||||||
|
result.put("hasAgeField", redisUtil.hHasKey(hashKey, "age"));
|
||||||
|
// 6) Cache-Aside(复用下方私有方法):字段不存在时回源并写回 Hash
|
||||||
|
Object nickname = getHashFieldWithFallback(hashKey, "nickname", () -> "yunhai-nick");
|
||||||
|
result.put("nickname", nickname);
|
||||||
|
// 7) 删除某个字段
|
||||||
|
redisUtil.hdel(hashKey, "age");
|
||||||
|
result.put("afterDelAge", redisUtil.hmget(hashKey));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("RedirectTo/{id}")
|
||||||
|
public RedirectView RedirectTo(BoaUser authUserWithDept, @PathVariable(name = "id") String id) {
|
||||||
|
String url= OtherUtil.hexToString(id);
|
||||||
|
return new RedirectView(url,true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用文件上传接口(一个接口覆盖所有类型,无需按类型拆分)。<br>
|
||||||
|
* type 决定存储子目录,与 MyWebAppConfigurer.addResourceHandlers 的 /upload/{type}/** 映射一一对应,<br>
|
||||||
|
* 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br>
|
||||||
|
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
|
||||||
|
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<br>
|
||||||
|
*
|
||||||
|
* @param file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置)
|
||||||
|
* @param type 存储子目录类型,默认 image
|
||||||
|
* @return 文件对外可访问的 Web URL
|
||||||
|
*/
|
||||||
|
@PostMapping("UploadFile")
|
||||||
|
public String uploadFile(@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "type", defaultValue = "image") String type) {
|
||||||
|
if (!ALLOWED_UPLOAD_TYPES.contains(type)) {
|
||||||
|
throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word");
|
||||||
|
}
|
||||||
|
// FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接
|
||||||
|
return FileUtil.upload(file, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 私有方法(可复用):演示 Hash 结构的 Cache-Aside(缓存旁路)模式。<br>
|
||||||
|
* 先从 Redis Hash 读字段;读不到则回源(此处用 fallback 模拟数据库查询),再把结果写回 Hash。<br>
|
||||||
|
*
|
||||||
|
* @param hashKey hash 键
|
||||||
|
* @param field 字段名
|
||||||
|
* @param fallback 回源加载器(真实场景替换为查数据库/远程调用)
|
||||||
|
* @return 字段值
|
||||||
|
*/
|
||||||
|
private Object getHashFieldWithFallback(String hashKey, String field, Supplier<Object> fallback) {
|
||||||
|
if (redisUtil.hHasKey(hashKey, field)) {
|
||||||
|
return redisUtil.hget(hashKey, field);
|
||||||
|
}
|
||||||
|
Object value = fallback.get();
|
||||||
|
if (value != null) {
|
||||||
|
redisUtil.hset(hashKey, field, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupAppVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupAuthzCategoryVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.UserGroupNodeVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo;
|
||||||
|
import com.yunhai.userbase.model.groupauthz.UserRoleVo;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/23
|
||||||
|
* @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
|
||||||
|
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/groupauthz/")
|
||||||
|
public class GroupAuthzController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。
|
||||||
|
*/
|
||||||
|
@GetMapping("/GroupAppList")
|
||||||
|
public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) {
|
||||||
|
return groupAuthzService.listGroupApps(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能1保存:全量覆盖式更新该组的应用开通记录。
|
||||||
|
*/
|
||||||
|
@OperationLog("保存组应用开通")
|
||||||
|
@PostMapping("AssignApps")
|
||||||
|
public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) {
|
||||||
|
groupAuthzService.assignAppsToGroup(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能2数据加载:返回该组已开通的应用及其模块权限树(含勾选与子组继承回显)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/GroupModuleTree")
|
||||||
|
public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
|
||||||
|
return groupAuthzService.findGroupAppsWithModules(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能2数据加载(按应用分类分组):供组授权页面按分类面板展示,<br>
|
||||||
|
* 返回全部分类及该组已开通的应用模块权限树。
|
||||||
|
*/
|
||||||
|
@GetMapping("/GroupModuleTreeByCategory")
|
||||||
|
public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) {
|
||||||
|
return groupAuthzService.findGroupAppsWithModulesByCategory(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能2保存:全量覆盖式更新该组的模块权限(含子组继承开关)。
|
||||||
|
*/
|
||||||
|
@OperationLog("保存组模块权限")
|
||||||
|
@PostMapping("SaveModulePerms")
|
||||||
|
public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) {
|
||||||
|
groupAuthzService.saveModulePermissions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限判定:判断用户对某模块是否有权限(供登录/拦截器/联调验证使用)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/HasModulePerm")
|
||||||
|
public boolean hasModulePerm(@RequestParam Integer userId, @RequestParam Integer moduleId) {
|
||||||
|
return groupAuthzService.hasModulePermission(userId, moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限一览:根据组ID获取该组下的直接用户列表(不含子组,不分页)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/GroupUsers")
|
||||||
|
public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
|
||||||
|
return groupAuthzService.findGroupUsers(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
|
||||||
|
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。
|
||||||
|
*/
|
||||||
|
@GetMapping("/SearchUsers")
|
||||||
|
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
|
||||||
|
return groupAuthzService.searchUsers(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
|
||||||
|
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
|
||||||
|
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/UserPermissionTree")
|
||||||
|
public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId,
|
||||||
|
@RequestParam(required = false) Integer groupId) {
|
||||||
|
return groupAuthzService.findUserPermissionTree(groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
|
||||||
|
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
|
||||||
|
*/
|
||||||
|
@GetMapping("/UserRoles")
|
||||||
|
public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
|
||||||
|
return groupAuthzService.findUserRoles(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
|
||||||
|
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/UserGroupTree")
|
||||||
|
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
|
||||||
|
return groupAuthzService.findUserGroupTree(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能3回显:点击左侧树节点后,返回该组「已开通的应用ID列表」,供右侧 AppSettings.appAuthzList 回显勾选状态。<br>
|
||||||
|
* <b>交互</b>:左树节点 → 右 appAuthzList(全量应用)→ 本接口拿到已选 id → 前端把对应多选框置为勾选。<br>
|
||||||
|
* <b>实现</b>:应用开通不继承,直接查 sys_group_app_rel(group_id=nodeId) 返回 app_id 列表(详见 service.findAuthorizedAppIds)。<br>
|
||||||
|
* 与 GroupAppList(返回全量应用+checked)互为补充:本接口更轻量,只给 id 集合。
|
||||||
|
*/
|
||||||
|
@GetMapping("/GetSchoolAppId")
|
||||||
|
public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) {
|
||||||
|
return groupAuthzService.findAuthorizedAppIds(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能3保存(应用维):点击应用多选框触发,对该组做应用开通/取消。<br>
|
||||||
|
* <b>入参(请求体,JSON)</b>:{@link SchoolAppAuthzRequest} —— schid=组ID(nodeId);appid=单个应用ID(一次只传一个,逐次点击);mode=操作模式。<br>
|
||||||
|
* <b>mode 语义(SysModeEnum)</b>:0=追加(开通应用) / 2=删除(取消开通);1=覆盖 不适用,会被拒绝。<br>
|
||||||
|
* <b>实现</b>:应用开通不继承,仅操作本组;删除时同步清理该组在该应用下模块的权限残留(详见 service.setSchoolApps)。<br>
|
||||||
|
* <b>注意</b>:appid 为单个 Integer,后端不再做多值/逗号解析;每次点击提交一次请求。
|
||||||
|
*/
|
||||||
|
@OperationLog("设置组应用开通")
|
||||||
|
@PostMapping("SetSchoolApp")
|
||||||
|
public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) {
|
||||||
|
groupAuthzService.setSchoolApps(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能4保存(模块维):前端「组授权」面板逐条勾选/取消时提交,对 (组,应用,模块) 做增量授权/取消。<br>
|
||||||
|
* <b>入参</b>:请求体为数组,元素见 {@link GroupModuleAuthzItem}:{groupId, appId, moduleId, mode, isAll}。<br>
|
||||||
|
* <b>mode</b>:0=追加 / 2=删除(1=覆盖不适用)。<br>
|
||||||
|
* <b>isAll</b>:是否对子组继承,映射 sys_group_module_perm.inherit_flag(1=子孙组自动继承 / 0=仅本组)。<br>
|
||||||
|
* <b>moduleId=0</b>:表示「该应用下全部模块」,后端展开为应用下每个真实模块各写/删一条。<br>
|
||||||
|
* <b>实现</b>:逐条处理、顺序即执行顺序;追加前要求该组已开通该应用;越权写别的应用模块会被拒绝(详见 service.setGroupModulePerms)。<br>
|
||||||
|
* 与现有 SaveModulePerms(全量覆盖)共存:本接口是单条增量,二者操作同一张表、互不冲突。
|
||||||
|
*/
|
||||||
|
@OperationLog("设置组模块权限")
|
||||||
|
@PostMapping("SetGroupModule")
|
||||||
|
public void setGroupModule(@RequestBody @Valid List<GroupModuleAuthzItem> items) {
|
||||||
|
groupAuthzService.setGroupModulePerms(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.identity.IdentityCurrentVo;
|
||||||
|
import com.yunhai.userbase.model.identity.IdentityOptionVo;
|
||||||
|
import com.yunhai.userbase.model.identity.IdentitySwitchRequest;
|
||||||
|
import com.yunhai.userbase.service.impl.IdentityService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户身份(平台身份角色 × 学校)管理接口。<br><br>
|
||||||
|
* 走 CAS 会话路线({@code /{platform}/identity/}),当前用户从 CAS 会话注入的
|
||||||
|
* {@link BoaUser#getId()} 取,不接受外部传入 userId(防越权)。<br>
|
||||||
|
* 三个端点对应「切换身份」业务:<br>
|
||||||
|
* ① {@code GET current} 当前激活身份(无切换记录则兜底主身份);<br>
|
||||||
|
* ② {@code GET available} 全部可选身份清单;<br>
|
||||||
|
* ③ {@code POST switch} 切换到指定 (roleId, schoolId),服务端校验属主后写入当前激活状态。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/18
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.API_PATH + "/identity/")
|
||||||
|
public class IdentityController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IdentityService identityService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前激活身份。
|
||||||
|
*/
|
||||||
|
@GetMapping("current")
|
||||||
|
public IdentityCurrentVo current(BoaUser authUserWithDept) {
|
||||||
|
return identityService.getCurrent(authUserWithDept.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部可选身份(正常态),供「切换身份」页渲染。
|
||||||
|
*/
|
||||||
|
@GetMapping("available")
|
||||||
|
public List<IdentityOptionVo> available(BoaUser authUserWithDept) {
|
||||||
|
return identityService.listAvailable(authUserWithDept.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换身份:校验属主后在 sys_user_current_role 写当前激活状态。
|
||||||
|
*/
|
||||||
|
@PostMapping("switch")
|
||||||
|
public IdentityCurrentVo switchIdentity(BoaUser authUserWithDept,
|
||||||
|
@RequestBody @Validated IdentitySwitchRequest request) {
|
||||||
|
return identityService.switchIdentity(authUserWithDept.getId(), request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.push.PushRecordListVo;
|
||||||
|
import com.yunhai.userbase.model.push.PushRecordQuery;
|
||||||
|
import com.yunhai.userbase.model.push.PushTestRequest;
|
||||||
|
import com.yunhai.userbase.service.push.PushDispatcher;
|
||||||
|
import com.yunhai.userbase.service.push.PushRecordService;
|
||||||
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.web.SortDefault;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送订阅控制器:测试推送 + 推送日志记录 / 重试。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/14
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Slf4j
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/push/")
|
||||||
|
public class PushSubscriptionController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PushDispatcher pushDispatcher;
|
||||||
|
@Autowired
|
||||||
|
private PushRecordService pushRecordService;
|
||||||
|
|
||||||
|
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
|
||||||
|
@PostMapping("Test")
|
||||||
|
public Map<String, Object> test(@RequestBody PushTestRequest request) {
|
||||||
|
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||||
|
Map<String, Object> preview = pushDispatcher.previewManual(
|
||||||
|
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("sent", true);
|
||||||
|
result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时");
|
||||||
|
result.put("preview", preview);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送日志列表(不含 payload,避免全量推送数据量过大;payload 经 Download 接口单独获取)。 */
|
||||||
|
@GetMapping("RecordList")
|
||||||
|
public BasePageResult<PushRecordListVo> recordList(PushRecordQuery query, BasePageQuery pageQuery,
|
||||||
|
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
|
||||||
|
return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动重试一条失败的推送记录。 */
|
||||||
|
@PostMapping("Retry/{id}")
|
||||||
|
public void retry(@PathVariable Long id) {
|
||||||
|
pushRecordService.retry(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载某条推送记录的请求体原文(payload 文本文件)。<br>
|
||||||
|
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
|
||||||
|
* 若该记录未记录 payload,返回一段说明文本文件而非 404,便于排查。
|
||||||
|
*/
|
||||||
|
@GetMapping("/Download/{id}")
|
||||||
|
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||||
|
String data = pushRecordService.getPayload(id);
|
||||||
|
if (data == null) {
|
||||||
|
data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=" + new String(("payload-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||||
|
headers.add("Pragma", "no-cache");
|
||||||
|
headers.add("Expires", "0");
|
||||||
|
return ResponseEntity.ok().headers(headers)
|
||||||
|
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||||
|
.body(new InputStreamResource(bis));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("推送日志请求体下载失败, id={}", id, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||||
|
import com.yunhai.userbase.service.ISysGroupService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/20 16:00
|
||||||
|
* @description 系统组管理(组织架构树)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/sysgroup/")
|
||||||
|
public class SysGroupController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysGroupService sysGroupService;
|
||||||
|
|
||||||
|
@OperationLog("新增组")
|
||||||
|
@Operation(operationId = "sysGroupAdd")
|
||||||
|
@PostMapping("Add")
|
||||||
|
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
|
||||||
|
sysGroupService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("编辑组")
|
||||||
|
@Operation(operationId = "sysGroupEdit")
|
||||||
|
@PostMapping("Edit/{id}")
|
||||||
|
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
|
||||||
|
sysGroupService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("删除组")
|
||||||
|
@Operation(operationId = "sysGroupRemove")
|
||||||
|
@PostMapping("Delete/{id}")
|
||||||
|
public void delete(@PathVariable Integer id) {
|
||||||
|
sysGroupService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "sysGroupList")
|
||||||
|
@GetMapping("/List")
|
||||||
|
public List<SysGroupVo> list(SysGroupQuery query) {
|
||||||
|
return sysGroupService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。
|
||||||
|
*/
|
||||||
|
@GetMapping("GetTree")
|
||||||
|
public List<SysGroupVo> getTree() {
|
||||||
|
return sysGroupService.getTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
|
||||||
|
* 子树中无学校组的分支整体剪掉;根节点始终保留。
|
||||||
|
*/
|
||||||
|
@GetMapping("GetSchoolTree")
|
||||||
|
public List<SysGroupVo> getSchoolTree() {
|
||||||
|
return sysGroupService.getSchoolTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增子组:请求体 { pid, type, items:[{name},...] }(JSON)。
|
||||||
|
*/
|
||||||
|
@OperationLog("批量新增子组")
|
||||||
|
@PostMapping("BatchAdd")
|
||||||
|
public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
|
||||||
|
sysGroupService.batchAdd(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子组排序:传入 pid 与有序 ids(必须是 pid 的直接子节点),按传入顺序落库。
|
||||||
|
*/
|
||||||
|
@OperationLog("子组排序")
|
||||||
|
@PostMapping("SortChildren")
|
||||||
|
public void sortChildren(@Validated SysGroupSortRequest request) {
|
||||||
|
sysGroupService.sortChildren(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。
|
||||||
|
*/
|
||||||
|
@OperationLog("移动组")
|
||||||
|
@PostMapping("Move")
|
||||||
|
public void move(@Validated SysGroupMoveRequest request) {
|
||||||
|
sysGroupService.move(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||||
|
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||||
|
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口日志查询与下载接口。<br>
|
||||||
|
* 本控制器位于 {@code controller.api} 包,不会被 OapiLogAspect(切 oapi 包)拦截。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/oapilog/")
|
||||||
|
public class SysOapiLogController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysOapiLogService sysOapiLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询开放接口日志列表(不含响应结果)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList")
|
||||||
|
@GetMapping("/List")
|
||||||
|
public BasePageResult<SysOapiLogListVo> list(SysOapiLogQuery query, BasePageQuery pageQuery) {
|
||||||
|
return sysOapiLogService.pageQuery(query, pageQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。<br>
|
||||||
|
* 与 {@code SysOperationLogController.Download} 一致的 octet-stream + attachment 写法。<br>
|
||||||
|
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload")
|
||||||
|
@GetMapping("/Download/{id}")
|
||||||
|
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||||
|
String data = sysOapiLogService.getResponseData(id);
|
||||||
|
if (data == null) {
|
||||||
|
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||||
|
headers.add("Pragma", "no-cache");
|
||||||
|
headers.add("Expires", "0");
|
||||||
|
return ResponseEntity.ok().headers(headers)
|
||||||
|
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||||
|
.body(new InputStreamResource(bis));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("开放接口日志响应结果下载失败, id={}", id, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.oplog.SysOperationLogListVo;
|
||||||
|
import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
|
||||||
|
import com.yunhai.userbase.service.ISysOperationLogService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志查询与下载接口。<br>
|
||||||
|
* 本控制器自身不标注 @OperationLog,避免记录日志查看行为。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/28
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/oplog/")
|
||||||
|
public class SysOperationLogController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysOperationLogService sysOperationLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询操作日志列表(不含响应结果)。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "操作日志分页查询", operationId = "operationLogList")
|
||||||
|
@GetMapping("/List")
|
||||||
|
public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) {
|
||||||
|
return sysOperationLogService.pageQuery(query, pageQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载某条日志的响应结果(GZIP 解压后原文, 文本文件)。<br>
|
||||||
|
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
|
||||||
|
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "下载日志响应原文", operationId = "operationLogDownload")
|
||||||
|
@GetMapping("/Download/{id}")
|
||||||
|
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||||
|
String data = sysOperationLogService.getResponseData(id);
|
||||||
|
if (data == null) {
|
||||||
|
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||||
|
headers.add("Pragma", "no-cache");
|
||||||
|
headers.add("Expires", "0");
|
||||||
|
return ResponseEntity.ok().headers(headers)
|
||||||
|
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||||
|
.body(new InputStreamResource(bis));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("操作日志响应结果下载失败, id={}", id, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.yunhai.userbase.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.BatchDeleteRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
||||||
|
import com.yunhai.userbase.model.sysuser.ChangeGroupsRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.ChangeSortNoRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||||
|
import com.yunhai.userbase.service.ISysUserService;
|
||||||
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/20 16:14
|
||||||
|
* @description 组用户管理
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/sysuser/")
|
||||||
|
public class SysUserController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysUserService sysUserService;
|
||||||
|
|
||||||
|
@OperationLog("新增用户")
|
||||||
|
@PostMapping("Add")
|
||||||
|
public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
|
||||||
|
List<String> data=new ArrayList<>();
|
||||||
|
|
||||||
|
return sysUserService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("编辑用户")
|
||||||
|
@PostMapping("Edit/{id}")
|
||||||
|
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||||
|
sysUserService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。
|
||||||
|
*/
|
||||||
|
@OperationLog("从组中删除用户")
|
||||||
|
@PostMapping("DeleteFromGroup/{userId}/{groupId}")
|
||||||
|
public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) {
|
||||||
|
sysUserService.deleteFromGroup(userId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量从指定组中移除用户;无其它组关联者级联删除用户。
|
||||||
|
*/
|
||||||
|
@OperationLog("批量从组中删除用户")
|
||||||
|
@PostMapping("BatchDelete")
|
||||||
|
public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) {
|
||||||
|
sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁用用户(disabled=true) */
|
||||||
|
@OperationLog("禁用用户")
|
||||||
|
@PostMapping("Disable/{id}")
|
||||||
|
public void disable(@PathVariable Integer id) {
|
||||||
|
sysUserService.changeStatus(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用用户(disabled=false) */
|
||||||
|
@OperationLog("启用用户")
|
||||||
|
@PostMapping("Enable/{id}")
|
||||||
|
public void enable(@PathVariable Integer id) {
|
||||||
|
sysUserService.changeStatus(id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量设置用户状态 */
|
||||||
|
@OperationLog("批量设置用户状态")
|
||||||
|
@PostMapping("BatchChangeStatus")
|
||||||
|
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
|
||||||
|
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/List")
|
||||||
|
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) {
|
||||||
|
// 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制
|
||||||
|
// (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。
|
||||||
|
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调整用户在某组内的排序号(sort_no):1~999,可空表示清除 */
|
||||||
|
@OperationLog("设置用户组内排序号")
|
||||||
|
@PostMapping("ChangeSortNo/{userId}")
|
||||||
|
public void changeSortNo(@PathVariable Integer userId,
|
||||||
|
@RequestBody @Validated ChangeSortNoRequest request) {
|
||||||
|
sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("调整组")
|
||||||
|
@PostMapping("ChangeGroups")
|
||||||
|
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) {
|
||||||
|
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("Groups/{userId}")
|
||||||
|
public List<Integer> getUserGroups(@PathVariable Integer userId) {
|
||||||
|
return sysUserService.findGroupIdsByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog("批量导入用户")
|
||||||
|
@PostMapping("BatchImport")
|
||||||
|
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
|
||||||
|
return sysUserService.batchImport(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载用户批量导入模板(真实 .xlsx):平铺当前组及子孙组,仅填充组名(路径)与组id;其余列留空供填写。
|
||||||
|
*/
|
||||||
|
@GetMapping("DownloadTemplate")
|
||||||
|
public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
sysUserService.downloadImportTemplate(groupId, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出组下所有用户(含子组)为 Excel;与导入模板同构,末尾追加用户id列。
|
||||||
|
* 前端直接跳转此地址触发浏览器下载。
|
||||||
|
*/
|
||||||
|
@GetMapping("Export")
|
||||||
|
public void export(@RequestParam(value = "groupId", required = false) Integer groupId,
|
||||||
|
@RequestParam(value = "withSub", defaultValue = "1") Integer withSub,
|
||||||
|
@RequestParam(value = "name", required = false) String name,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
sysUserService.exportUsers(groupId, withSub, name, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||||
|
import com.yunhai.userbase.service.IAppCategoryService;
|
||||||
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用域开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/app/...})。<br>
|
||||||
|
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求
|
||||||
|
* (URL pattern {@code /oapi/*} 已覆盖本控制器),未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.OPEN_API_PATH + "app/")
|
||||||
|
public class AppController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAppCategoryService appCategoryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IApplicationInfoService applicationInfoService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按主键 ID 查询应用信息(带缓存)。<br>
|
||||||
|
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
||||||
|
* 返回 {@link ApplicationInfoVo} 并清空 appSecret,与 list 接口一致不透出签名密钥。
|
||||||
|
*
|
||||||
|
* @param appId 应用主键 ID(必填)
|
||||||
|
* @return 应用信息视图;不存在时返回 null
|
||||||
|
*/
|
||||||
|
@GetMapping("{appId}")
|
||||||
|
public ApplicationInfoVo byId(@PathVariable Integer appId) {
|
||||||
|
ApplicationInfo info = applicationInfoService.getById(appId);
|
||||||
|
if (info == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ApplicationInfoVo vo = new ApplicationInfoVo();
|
||||||
|
BeanUtils.copyProperties(info, vo);
|
||||||
|
// 不透出签名密钥(与 list 契约一致)
|
||||||
|
vo.setAppSecret(null);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
|
||||||
|
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
|
||||||
|
* 超级管理员返回全部;不透出 appSecret。<br>
|
||||||
|
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID(必填)
|
||||||
|
* @param clientType 客户端类型 key(可选)
|
||||||
|
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
|
||||||
|
*/
|
||||||
|
@GetMapping("list")
|
||||||
|
public List<BoaAppType> list(@RequestParam("userId") Integer userId,
|
||||||
|
@RequestParam(value = "clientType", required = false) Integer clientType) {
|
||||||
|
ClientEnum clientEnum = (clientType == null) ? null
|
||||||
|
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||||
|
return appCategoryService.listBoaAppType(userId, clientEnum);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||||
|
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||||
|
import com.yunhai.userbase.service.ISysGroupService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织架构开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/group/...})。<br>
|
||||||
|
* <br>
|
||||||
|
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient}
|
||||||
|
* 硬编码的契约严格一致:
|
||||||
|
* {@code GET /oapi/group/tree}、{@code /byId?groupId=&withSub=}、{@code /list?ids=&withSub=}。<br>
|
||||||
|
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
|
||||||
|
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.OPEN_API_PATH + "group/")
|
||||||
|
public class GroupController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysGroupService sysGroupService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取整个平台的组树(从根节点开始,children 递归嵌套全部后代)。
|
||||||
|
*/
|
||||||
|
@GetMapping("tree")
|
||||||
|
public BoaGroup tree() {
|
||||||
|
return sysGroupService.getGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。
|
||||||
|
*
|
||||||
|
* @param groupId 组ID(必填)
|
||||||
|
* @param withSub 是否展开子节点,缺省 false
|
||||||
|
* @return 组节点;不存在返回 null
|
||||||
|
*/
|
||||||
|
@GetMapping("byId")
|
||||||
|
public BoaGroup byId(@RequestParam("groupId") Integer groupId,
|
||||||
|
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
||||||
|
return sysGroupService.getGroupById(groupId, withSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的跳过。
|
||||||
|
*
|
||||||
|
* @param ids 组ID列表(逗号/空格分隔)
|
||||||
|
* @param withSub 是否展开子节点,缺省 false
|
||||||
|
* @return 组节点列表(按入参顺序;不存在的跳过)
|
||||||
|
*/
|
||||||
|
@GetMapping("list")
|
||||||
|
public List<BoaGroup> list(@RequestParam("ids") String ids,
|
||||||
|
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
||||||
|
return sysGroupService.getGroupList(ids, withSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增组:返回创建后的组节点(含 id)。<br>
|
||||||
|
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
|
||||||
|
*/
|
||||||
|
@PostMapping("add")
|
||||||
|
public BoaGroup add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
|
||||||
|
return sysGroupService.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑组(不含移动,移动请走 group/move 后台接口):返回更新后的组节点(含 id)。
|
||||||
|
*/
|
||||||
|
@PostMapping("edit/{id}")
|
||||||
|
public BoaGroup edit(@PathVariable Integer id,
|
||||||
|
@RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
|
||||||
|
return sysGroupService.edit(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。<br>
|
||||||
|
* 新增 {@code schoolId} 边界:服务端强制 id 属于该校子树且不等于 schoolId,避免删学校根组/跨校删组。
|
||||||
|
*/
|
||||||
|
@PostMapping("delete/{id}/{schoolId}")
|
||||||
|
public void delete(@PathVariable Integer id, @PathVariable Integer schoolId) {
|
||||||
|
sysGroupService.delete(id, schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增子组:请求体 { pid, type, items:[{name},...] },返回创建后的组节点列表(均含 id)。
|
||||||
|
*/
|
||||||
|
@PostMapping("batchAdd")
|
||||||
|
public List<BoaGroup> batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
|
||||||
|
return sysGroupService.batchAdd(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动组(学校限定):仅允许在同一学校(组)子树内移动——moveId、pid 都必须落在 schoolId 子树内。<br>
|
||||||
|
* 语义复用后台 Move(环检测/类型约束/重排),额外叠加学校边界。鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
|
||||||
|
*/
|
||||||
|
@PostMapping("move/{schoolId}")
|
||||||
|
public void move(@PathVariable Integer schoolId,
|
||||||
|
@RequestBody @Validated SysGroupMoveRequest request) {
|
||||||
|
sysGroupService.move(request, schoolId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。<br><br>
|
||||||
|
* 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由
|
||||||
|
* {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:<br>
|
||||||
|
* {@code {"appKey":"100616","secret":"...","enabled":true}}。<br><br>
|
||||||
|
* 解析优先级:<br>
|
||||||
|
* 1) 本库 application_info(覆盖全部「已颁发」应用的实时密钥);<br>
|
||||||
|
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps}(平台自身默认密钥,如 应用中心 100616,
|
||||||
|
* 即便尚未落库也可解析,避免「默认 appKey 未建行」时此处返回 404 导致验签链路失败);<br>
|
||||||
|
* 两者皆无 → 404。<br><br>
|
||||||
|
* 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.OPEN_API_PATH + "internal")
|
||||||
|
public class SignKeyInternalController {
|
||||||
|
|
||||||
|
private final ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
private final AppAuthProperties appAuthProperties;
|
||||||
|
|
||||||
|
public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository,
|
||||||
|
AppAuthProperties appAuthProperties) {
|
||||||
|
this.applicationInfoRepository = applicationInfoRepository;
|
||||||
|
this.appAuthProperties = appAuthProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("sign-key/{appId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> signKey(@PathVariable String appId) {
|
||||||
|
Integer key;
|
||||||
|
try {
|
||||||
|
key = Integer.parseInt(appId.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
ApplicationInfo info = applicationInfoRepository.findByAppKey(key);
|
||||||
|
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||||
|
return ok(info.getAppKey() == null ? appId : String.valueOf(info.getAppKey()),
|
||||||
|
info.getAppSecret());
|
||||||
|
}
|
||||||
|
// 库内无此应用密钥:回退到配置式受信应用(平台自身默认密钥 100616 等),
|
||||||
|
// 避免「应用中心默认 appKey 未落库」时兜底查询返回 404。
|
||||||
|
String cfgSecret = appAuthProperties.getTrustedApps().get(appId);
|
||||||
|
if (cfgSecret != null && !cfgSecret.trim().isEmpty()) {
|
||||||
|
return ok(appId, cfgSecret);
|
||||||
|
}
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<Map<String, Object>> ok(String appKey, String secret) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("appKey", appKey);
|
||||||
|
body.put("secret", secret);
|
||||||
|
body.put("enabled", true);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
import com.yunhai.platform.utils.model.BoaModule;
|
||||||
|
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserPasswordRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import com.yunhai.userbase.service.ISysUserService;
|
||||||
|
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/user/...})。<br>
|
||||||
|
* <br>
|
||||||
|
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient}
|
||||||
|
* 硬编码的契约严格一致:
|
||||||
|
* {@code GET /oapi/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、
|
||||||
|
* {@code /oapi/user/modules?userId=&schId=}。<br>
|
||||||
|
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
|
||||||
|
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(Config.OPEN_API_PATH + "user/")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UserController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserCenterService userCenterService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ISysUserService sysUserService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@GetMapping("byId")
|
||||||
|
public BoaUser byId(@RequestParam("id") Integer id) {
|
||||||
|
return userCenterService.getUserInfoWithDeptById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("byLoginName")
|
||||||
|
public BoaUser byLoginName(@RequestParam("loginName") String loginName) {
|
||||||
|
if (loginName == null || loginName.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userCenterService.getUserInfoWithDeptByAccount(loginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("byPhone")
|
||||||
|
public BoaUser byPhone(@RequestParam("phone") String phone) {
|
||||||
|
if (phone == null || phone.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userCenterService.getUserInfoWithDeptByPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||||
|
* <br>
|
||||||
|
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
|
||||||
|
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
|
||||||
|
* 已保证非空);<br>
|
||||||
|
* ② 查 application_info 映射到具体 appId;<br>
|
||||||
|
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
|
||||||
|
* <br>
|
||||||
|
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
|
||||||
|
*
|
||||||
|
* @param request 用于取 AppAuthFilter 设置的 appKey
|
||||||
|
* @param userId 用户ID(必填)
|
||||||
|
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||||
|
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||||
|
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
||||||
|
*/
|
||||||
|
@GetMapping("modules")
|
||||||
|
public List<BoaModule> modules(HttpServletRequest request,
|
||||||
|
@RequestParam("userId") Integer userId,
|
||||||
|
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||||
|
if (userId == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Integer appId = resolveCallerAppId(request);
|
||||||
|
if (appId == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return groupAuthzService.findUserModulesForApp(appId, schId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id。<br>
|
||||||
|
* 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。
|
||||||
|
*/
|
||||||
|
private Integer resolveCallerAppId(HttpServletRequest request) {
|
||||||
|
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||||
|
if (attr == null) {
|
||||||
|
log.warn("[oapi:user:modules] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
|
||||||
|
request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||||
|
if (appKeyStr.trim().isEmpty()) {
|
||||||
|
log.warn("[oapi:user:modules] AppAuth attr is blank, uri={}", request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Integer appKey;
|
||||||
|
try {
|
||||||
|
appKey = Integer.parseInt(appKeyStr.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
|
||||||
|
if (app == null) {
|
||||||
|
log.warn("[oapi:user:modules] appKey={} not found in application_info, uri={}", appKey,
|
||||||
|
request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return app.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增用户:走完整校验(账号唯一/手机号/身份证/密码强度),BCrypt 加密并写入组关联;
|
||||||
|
* 返回创建后的用户(含 id 与组织链)。<br>
|
||||||
|
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
|
||||||
|
*/
|
||||||
|
@PostMapping("add")
|
||||||
|
public BoaUser add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
|
||||||
|
Integer id = sysUserService.add(request);
|
||||||
|
return userCenterService.getUserInfoWithDeptById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
|
||||||
|
*/
|
||||||
|
@PostMapping("edit/{id}")
|
||||||
|
public BoaUser edit(@PathVariable Integer id,
|
||||||
|
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||||
|
sysUserService.edit(id, request);
|
||||||
|
return userCenterService.getUserInfoWithDeptById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;<br>
|
||||||
|
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。<br>
|
||||||
|
* 这样多校用户只会被退出指定学校,而不会被整体误删。<br>
|
||||||
|
* 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。
|
||||||
|
*/
|
||||||
|
@PostMapping("delete/{id}/{groupId}/{schoolId}")
|
||||||
|
public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) {
|
||||||
|
sysUserService.deleteFromGroup(id, groupId, schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);<br>
|
||||||
|
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。
|
||||||
|
*/
|
||||||
|
@PostMapping("deleteUsers")
|
||||||
|
public void deleteUsers(@RequestBody List<SysUserGroupDeleteItem> items) {
|
||||||
|
sysUserService.deleteUsers(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword);
|
||||||
|
* 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。
|
||||||
|
*/
|
||||||
|
@PostMapping("batchAdd")
|
||||||
|
public List<BoaUser> batchAdd(@RequestBody List<SysUserRequest> requests) {
|
||||||
|
List<Integer> ids = sysUserService.batchAdd(requests);
|
||||||
|
List<BoaUser> result = new ArrayList<>();
|
||||||
|
for (Integer id : ids) {
|
||||||
|
result.add(userCenterService.getUserInfoWithDeptById(id));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内,
|
||||||
|
* 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。<br>
|
||||||
|
* 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。
|
||||||
|
*/
|
||||||
|
@PostMapping("moveToGroup")
|
||||||
|
public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) {
|
||||||
|
sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId());
|
||||||
|
return userCenterService.getUserInfoWithDeptById(request.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。<br>
|
||||||
|
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
|
||||||
|
*/
|
||||||
|
@GetMapping("roles")
|
||||||
|
public List<BoaRoleInfo> roles(@RequestParam("userId") Integer userId) {
|
||||||
|
return sysUserService.findUserRoles(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
|
||||||
|
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
|
||||||
|
*/
|
||||||
|
@PostMapping("password")
|
||||||
|
public void password(@RequestBody @Validated SysUserPasswordRequest request) {
|
||||||
|
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/15 15:53
|
||||||
|
* @description
|
||||||
|
*/
|
||||||
|
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "app_category")
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class AppCategory extends BaseEntity {
|
||||||
|
//类别名称
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "sort_no")
|
||||||
|
private Integer sortNo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/22
|
||||||
|
* @description 应用模块(树形,逻辑外键,最深5层)<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "app_module",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_app_module_app", columnList = "app_id"),
|
||||||
|
@Index(name = "idx_app_module_parent", columnList = "parent_id")
|
||||||
|
},
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_app_module_custom", columnNames = {"app_id", "custom_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class AppModule extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属应用ID(逻辑外键 → ApplicationInfo.id),创建后不可改。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "app_id", nullable = false, updatable = false)
|
||||||
|
private Integer appId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父模块ID(逻辑外键 → AppModule.id);根模块为 null。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "parent_id")
|
||||||
|
private Integer parentId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块名称。<br>
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "custom_id", nullable = false, length = 64)
|
||||||
|
private String customId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块路径(前端路由/资源路径,如 "/system/user")。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "path", length = 255)
|
||||||
|
private String path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "level", nullable = false)
|
||||||
|
private Integer level;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序号(沿用 AppCategory 约定)。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_no")
|
||||||
|
private Integer sortNo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Convert;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/15 15:53
|
||||||
|
* @description
|
||||||
|
*/
|
||||||
|
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "application_info")
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class ApplicationInfo extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用名称(页面*必填)
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用类型ID
|
||||||
|
*/
|
||||||
|
private Integer categoryId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序号,仅在同一个应用类型内生效;默认为 null(新增时不赋值)。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_no")
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用描述
|
||||||
|
*/
|
||||||
|
@Column(name = "app_desc", length = 500)
|
||||||
|
private String desc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "app_key", unique = true, nullable = false)
|
||||||
|
private Integer appKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appSecret 密钥,支持重设
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "app_secret", nullable = false, length = 128)
|
||||||
|
private String appSecret;
|
||||||
|
|
||||||
|
//应用前端类型
|
||||||
|
@Convert(converter = ListIntegerJsonConverter.class)
|
||||||
|
// @Column(name = "clients", columnDefinition = "json")
|
||||||
|
@Column(name = "clients", columnDefinition = "TEXT")
|
||||||
|
private List<Integer> clients;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC应用访问地址
|
||||||
|
*/
|
||||||
|
@Column(name = "pc_url")
|
||||||
|
private String pcUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC应用图标 图片URL
|
||||||
|
*/
|
||||||
|
@Column(name = "pc_icon")
|
||||||
|
private String pcIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App访问地址(移动端主地址)
|
||||||
|
*/
|
||||||
|
@Column(name = "app_url")
|
||||||
|
private String appUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App应用图标 图片URL
|
||||||
|
*/
|
||||||
|
@Column(name = "app_icon")
|
||||||
|
private String appIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公众号应用地址
|
||||||
|
*/
|
||||||
|
@Column(name = "official_account_url")
|
||||||
|
private String officialAccountUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉应用地址
|
||||||
|
*/
|
||||||
|
@Column(name = "dingtalk_url")
|
||||||
|
private String dingtalkUrl;
|
||||||
|
|
||||||
|
// ===================== 推送订阅配置(文档《弘育基础平台数据同步接口 2》) =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送外部接口地址(不含固定后缀),如 http://host/notice/api/base/ 。
|
||||||
|
* 完整调用地址 = pushBaseUrl + 接口路径(如 group/AddGroup)。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_base_url")
|
||||||
|
private String pushBaseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送总开关:0关 1开(默认 0,自动推送默认不触发)。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_enabled", nullable = false)
|
||||||
|
private Integer pushEnabled = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用的接口路径,逗号分隔(如 group/AddGroup,user/AddUser);空=不推送任何接口。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_event_types")
|
||||||
|
private String pushEventTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联学校组 id,逗号分隔;空=全局(监听所有学校变化)。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_school_ids")
|
||||||
|
private String pushSchoolIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送角色 key,逗号分隔;空=全部角色。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_role_ids")
|
||||||
|
private String pushRoleIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送签名密钥;空则复用 appSecret。
|
||||||
|
*/
|
||||||
|
@Column(name = "push_secret")
|
||||||
|
private String pushSecret;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.listener.UserAuditListener;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/14 10:46
|
||||||
|
* @description
|
||||||
|
*/
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@EntityListeners(UserAuditListener.class)
|
||||||
|
public abstract class BaseEntity implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@NotNull(groups = ValidationGroups.Update.class)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人ID
|
||||||
|
*/
|
||||||
|
@Column(name = "create_user", nullable = false, updatable = false)
|
||||||
|
private Integer createUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "create_time", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人ID
|
||||||
|
*/
|
||||||
|
@Column(name = "update_user")
|
||||||
|
private Integer updateUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "update_time")
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/27
|
||||||
|
* @description 应用业务角色。<br>
|
||||||
|
* 普通角色:allAppFlag=false 且 appId 必填;<br>
|
||||||
|
* 全应用角色:allAppFlag=true 且 appId 为空;<br>
|
||||||
|
* 注意:这里的“业务角色”不等同于 {@code sys_user.roles} 对应的系统身份枚举。<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_app_role", indexes = {
|
||||||
|
@Index(name = "idx_app_role_app", columnList = "app_id"),
|
||||||
|
@Index(name = "idx_app_role_all_app", columnList = "all_app_flag"),
|
||||||
|
@Index(name = "idx_app_role_name", columnList = "name")
|
||||||
|
})
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysAppRole extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色名称。<br>
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "name", nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属应用ID(逻辑外键 → ApplicationInfo.id)。<br>
|
||||||
|
* 普通角色必填;全应用角色为空。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "app_id")
|
||||||
|
private Integer appId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否全应用角色。<br>
|
||||||
|
* true=可跨应用勾选模块;false=仅能授权所属应用下的模块。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "all_app_flag", nullable = false)
|
||||||
|
private Boolean allAppFlag = Boolean.FALSE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色备注。<br>
|
||||||
|
*/
|
||||||
|
@Column(name = "remark", length = 500)
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否禁用。<br>
|
||||||
|
* 禁用后角色关系可保留,但权限计算时应忽略该角色。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "disabled", nullable = false)
|
||||||
|
private Boolean disabled = Boolean.FALSE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/27
|
||||||
|
* @description 业务角色-模块权限关系表(逻辑外键,无物理 FK)。<br>
|
||||||
|
* 存在记录即代表该角色显式拥有该模块。<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_app_role_module_rel",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_app_role_module_role", columnList = "role_id"),
|
||||||
|
@Index(name = "idx_app_role_module_module", columnList = "module_id")
|
||||||
|
},
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_app_role_module", columnNames = {"role_id", "module_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysAppRoleModuleRel extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "role_id", nullable = false)
|
||||||
|
private Integer roleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块ID(逻辑外键 → AppModule.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "module_id", nullable = false)
|
||||||
|
private Integer moduleId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.converter.GroupTypeEnumConverter;
|
||||||
|
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/13 22:48
|
||||||
|
* @description 系统节点(组)表
|
||||||
|
*/
|
||||||
|
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_group")
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysGroup extends BaseEntity {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "parent_id", nullable = false)
|
||||||
|
private Integer parentId;
|
||||||
|
|
||||||
|
@Column(name = "type")
|
||||||
|
@Convert(converter = GroupTypeEnumConverter.class)
|
||||||
|
private GroupTypeEnum type;
|
||||||
|
|
||||||
|
private Integer sort;
|
||||||
|
|
||||||
|
|
||||||
|
// 一对多反向关联中间表
|
||||||
|
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "groupId", insertable = false, updatable = false)
|
||||||
|
private List<SysUserGroupRel> userGroupRelList;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/23
|
||||||
|
* @description 组应用开通表(逻辑外键,无物理 FK)。<br>
|
||||||
|
* 记录某个组显式开通了哪些应用;仅当本表存在记录时,「组授权」界面右侧才显示该应用。<br>
|
||||||
|
* 开通不继承:子组要使用应用必须自己开通。<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_group_app_rel",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_group_app_rel_app", columnList = "app_id")
|
||||||
|
},
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_group_app", columnNames = {"group_id", "app_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysGroupAppRel extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组ID(逻辑外键 → SysGroup.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "group_id", nullable = false)
|
||||||
|
private Integer groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用ID(逻辑外键 → ApplicationInfo.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "app_id", nullable = false)
|
||||||
|
private Integer appId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/23
|
||||||
|
* @description 组模块权限表(逻辑外键,无物理 FK)。<br>
|
||||||
|
* 存在记录即代表该组勾选了该模块;inheritFlag=true 时当前组及其所有子孙组自动继承该模块权限。<br>
|
||||||
|
* 取消勾选时物理删除记录。<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_group_module_perm",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_group_module_perm_module", columnList = "module_id")
|
||||||
|
},
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_group_module", columnNames = {"group_id", "module_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysGroupModulePerm extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组ID(逻辑外键 → SysGroup.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "group_id", nullable = false)
|
||||||
|
private Integer groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块ID(逻辑外键 → AppModule.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "module_id", nullable = false)
|
||||||
|
private Integer moduleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否子组继承(false=仅本组生效,true=子孙组自动继承),对应 UI「子组继承」勾选框。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "inherit_flag", nullable = false)
|
||||||
|
private Boolean inheritFlag = Boolean.FALSE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口调用日志实体。<br>
|
||||||
|
* 记录 yh-app-auth 签名调用方(YschBaseOpenApi 门面背后的 /oapi/** 端点)的操作,
|
||||||
|
* 身份主体为「调用方应用」(callerAppKey/callerAppName),区别于管理端 sys_operation_log 的登录用户维度。<br>
|
||||||
|
* 请求参数与响应均已脱敏后入库(敏感字段见 SensitiveDataMasker)。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_oapi_log", indexes = {
|
||||||
|
@Index(name = "idx_create_time", columnList = "create_time"),
|
||||||
|
@Index(name = "idx_appkey_time", columnList = "caller_app_key, create_time"),
|
||||||
|
@Index(name = "idx_uri_time", columnList = "request_uri, create_time"),
|
||||||
|
@Index(name = "idx_level_time", columnList = "log_level, create_time"),
|
||||||
|
@Index(name = "idx_ip_time", columnList = "request_ip, create_time")
|
||||||
|
})
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysOapiLog implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键
|
||||||
|
*/
|
||||||
|
@EqualsAndHashCode.Include
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)
|
||||||
|
*/
|
||||||
|
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
private Integer logLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用方应用 appKey(验签后写入 request attribute 的 AppAuthFilter.APP_AUTH_ATTR)
|
||||||
|
*/
|
||||||
|
@Column(name = "caller_app_key", nullable = false)
|
||||||
|
private Integer callerAppKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用方应用名(application_info 解析;解析失败记 appKey 原值)
|
||||||
|
*/
|
||||||
|
@Column(name = "caller_app_name", length = 128)
|
||||||
|
private String callerAppName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求IP
|
||||||
|
*/
|
||||||
|
@Column(name = "request_ip", nullable = false, length = 64)
|
||||||
|
private String requestIp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求路径
|
||||||
|
*/
|
||||||
|
@Column(name = "request_uri", nullable = false, length = 512)
|
||||||
|
private String requestUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求方法 GET/POST/PUT/DELETE
|
||||||
|
*/
|
||||||
|
@Column(name = "request_method", nullable = false, length = 16)
|
||||||
|
private String requestMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作类型 DELETE/CREATE/UPDATE/QUERY
|
||||||
|
*/
|
||||||
|
@Column(name = "operation_type", nullable = false, length = 32)
|
||||||
|
private String operationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类名.方法名
|
||||||
|
*/
|
||||||
|
@Column(name = "class_method", nullable = false, length = 512)
|
||||||
|
private String classMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作描述(@OapiLog 注解 value 提供,可选)
|
||||||
|
*/
|
||||||
|
@Column(name = "operation_desc", length = 255)
|
||||||
|
private String operationDesc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求参数JSON(脱敏后)
|
||||||
|
*/
|
||||||
|
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||||
|
private String requestParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应结果(GZIP 压缩,脱敏后)
|
||||||
|
*/
|
||||||
|
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||||
|
private byte[] responseData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP状态码
|
||||||
|
*/
|
||||||
|
@Column(name = "status_code", nullable = false)
|
||||||
|
private Integer statusCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 耗时(ms)
|
||||||
|
*/
|
||||||
|
@Column(name = "cost_time", nullable = false)
|
||||||
|
private Long costTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用方UA
|
||||||
|
*/
|
||||||
|
@Column(name = "user_agent", length = 512)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误信息
|
||||||
|
*/
|
||||||
|
@Column(name = "error_msg", columnDefinition = "TEXT")
|
||||||
|
private String errorMsg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用时间
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统操作日志实体。<br>
|
||||||
|
* 记录用户的关键操作(增删改查),便于审计与问题排查。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/28
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_operation_log", indexes = {
|
||||||
|
@Index(name = "idx_create_time", columnList = "create_time"),
|
||||||
|
@Index(name = "idx_user_time", columnList = "user_id, create_time"),
|
||||||
|
@Index(name = "idx_uri_time", columnList = "request_uri, create_time"),
|
||||||
|
@Index(name = "idx_level_time", columnList = "log_level, create_time"),
|
||||||
|
@Index(name = "idx_ip_time", columnList = "request_ip, create_time")
|
||||||
|
})
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysOperationLog implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键
|
||||||
|
*/
|
||||||
|
@EqualsAndHashCode.Include
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)
|
||||||
|
*/
|
||||||
|
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
private Integer logLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作用户ID
|
||||||
|
*/
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作用户名
|
||||||
|
*/
|
||||||
|
@Column(name = "username", nullable = false, length = 64)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求IP
|
||||||
|
*/
|
||||||
|
@Column(name = "request_ip", nullable = false, length = 64)
|
||||||
|
private String requestIp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求路径
|
||||||
|
*/
|
||||||
|
@Column(name = "request_uri", nullable = false, length = 512)
|
||||||
|
private String requestUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求方法 GET/POST/PUT/DELETE
|
||||||
|
*/
|
||||||
|
@Column(name = "request_method", nullable = false, length = 16)
|
||||||
|
private String requestMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作类型 DELETE/CREATE/UPDATE/QUERY
|
||||||
|
*/
|
||||||
|
@Column(name = "operation_type", nullable = false, length = 32)
|
||||||
|
private String operationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类名.方法名
|
||||||
|
*/
|
||||||
|
@Column(name = "class_method", nullable = false, length = 512)
|
||||||
|
private String classMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求参数JSON
|
||||||
|
*/
|
||||||
|
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||||
|
private String requestParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应结果(GZIP压缩)
|
||||||
|
*/
|
||||||
|
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||||
|
private byte[] responseData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP状态码
|
||||||
|
*/
|
||||||
|
@Column(name = "status_code", nullable = false)
|
||||||
|
private Integer statusCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 耗时(ms)
|
||||||
|
*/
|
||||||
|
@Column(name = "cost_time", nullable = false)
|
||||||
|
private Long costTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器UA
|
||||||
|
*/
|
||||||
|
@Column(name = "user_agent", length = 512)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误信息
|
||||||
|
*/
|
||||||
|
@Column(name = "error_msg", columnDefinition = "TEXT")
|
||||||
|
private String errorMsg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作时间
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
|
||||||
|
import com.yunhai.userbase.common.converter.StatusEnumConverter;
|
||||||
|
import com.yunhai.userbase.common.converter.UserTypeEnumConverter;
|
||||||
|
import com.yunhai.userbase.enums.StatusEnum;
|
||||||
|
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/13 23:30
|
||||||
|
* @description 系统用户表
|
||||||
|
*/
|
||||||
|
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_user")
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysUser extends BaseEntity {
|
||||||
|
|
||||||
|
@Column(name = "username", nullable = false, unique = true)
|
||||||
|
private String account;
|
||||||
|
private String password;
|
||||||
|
//出生日期
|
||||||
|
private LocalDate birthday;
|
||||||
|
|
||||||
|
@JsonProperty("name")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
private String idcard;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
private Integer gender;
|
||||||
|
|
||||||
|
//头像
|
||||||
|
private String profilePicture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色列表,存储为 MySQL JSON 类型,读取时自动映射为 List<Long>
|
||||||
|
* 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3]
|
||||||
|
*/
|
||||||
|
@Convert(converter = ListIntegerJsonConverter.class)
|
||||||
|
// @Column(name = "roles", columnDefinition = "json")
|
||||||
|
@Column(name = "roles",columnDefinition = "TEXT")
|
||||||
|
private List<Integer> roles;
|
||||||
|
|
||||||
|
// 一对多:关联用户分组中间表,实现多对多业务
|
||||||
|
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "userId", insertable = false, updatable = false)
|
||||||
|
private List<SysUserGroupRel> userGroupRelList;
|
||||||
|
|
||||||
|
// 是否禁用:存储为 StatusEnum(0=正常/未禁用,1=禁用),与 GroupTypeEnum 同机制;默认启用
|
||||||
|
@Convert(converter = StatusEnumConverter.class)
|
||||||
|
private StatusEnum disabled;
|
||||||
|
//是否过期
|
||||||
|
private Boolean expired=false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户类型:0=普通用户,1=超级管理员,2=学校管理员(见 UserTypeEnum)。
|
||||||
|
* 由应用层根据用户账号/角色推导:内置超级管理员账号固定为 1,其余账号含 ADMIN(7) 角色则为 2,默认 0。
|
||||||
|
*/
|
||||||
|
@Convert(converter = UserTypeEnumConverter.class)
|
||||||
|
private UserTypeEnum type;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import javax.persistence.UniqueConstraint;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/27
|
||||||
|
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。<br>
|
||||||
|
* 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。<br>
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_user_app_role_rel",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
|
||||||
|
@Index(name = "idx_user_app_role_role", columnList = "role_id")
|
||||||
|
},
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysUserAppRoleRel extends BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID(逻辑外键 → SysUser.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "role_id", nullable = false)
|
||||||
|
private Integer roleId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/15 15:05
|
||||||
|
* @description 用户当前角色
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_user_current_role",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}),
|
||||||
|
indexes = {@Index(columnList = "userId")}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysUserCurrentRole {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@NotNull(groups = ValidationGroups.Update.class)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID,一对一唯一
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null)
|
||||||
|
*/
|
||||||
|
private Integer schoolId;
|
||||||
|
/**
|
||||||
|
* 用户切换后生效角色ID(SysRoleEnum.key)
|
||||||
|
*/
|
||||||
|
private Integer roleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次切换时间(服务端 now() 写入)
|
||||||
|
*/
|
||||||
|
@Column(name = "switch_time")
|
||||||
|
private LocalDateTime switchTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校名冗余(避免每次 join sys_group 取 name)
|
||||||
|
*/
|
||||||
|
@Column(name = "school_name", length = 100)
|
||||||
|
private String schoolName;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/15 15:03
|
||||||
|
* @description 组用户多对多关联表
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sys_user_group_rel")
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysUserGroupRel {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@NotNull(groups = Update.class)
|
||||||
|
private Integer id;
|
||||||
|
/** 用户ID */
|
||||||
|
private Integer userId;
|
||||||
|
/** 分组ID */
|
||||||
|
private Integer groupId;
|
||||||
|
/** 是否主归属 1=本职主学校/部门 0=兼职兼任 */
|
||||||
|
private Integer isMain;
|
||||||
|
|
||||||
|
/** 组内排序号:越小越靠前;null 表示未设置,列表按 user_id 兜底升序 */
|
||||||
|
@Column(name = "sort_no")
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
// 关联用户,仅查询,不维护外键
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "userId", referencedColumnName = "id", insertable = false, updatable = false)
|
||||||
|
private SysUser user;
|
||||||
|
|
||||||
|
// 关联分组,仅查询,不维护外键
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "groupId", referencedColumnName = "id", insertable = false, updatable = false)
|
||||||
|
private SysGroup group;
|
||||||
|
|
||||||
|
|
||||||
|
public @interface Update {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.yunhai.userbase.entity;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.converter.StatusEnumConverter;
|
||||||
|
import com.yunhai.userbase.enums.StatusEnum;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/18
|
||||||
|
* @description 用户-SysRoleEnum角色-学校 三元关联(平台身份角色的权威源)。
|
||||||
|
* <p>区别于 {@link SysUserAppRoleRel}(业务权限角色):本表表达"用户在哪个学校下激活哪个平台身份角色"
|
||||||
|
* (如"刘文娟在 A 学校是老师"),角色取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key。</p>
|
||||||
|
* <p>不继承 BaseEntity:高频读写关系表,自定义字段更轻量,与 {@link SysUserGroupRel} 风格一致。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "sys_user_role_rel",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "roleId", "schoolId"}),
|
||||||
|
indexes = {
|
||||||
|
@Index(columnList = "userId"),
|
||||||
|
@Index(columnList = "schoolId"),
|
||||||
|
@Index(columnList = "roleId")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
|
public class SysUserRoleRel {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@NotNull(groups = Update.class)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色ID(SysRoleEnum.key,1-7)
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer roleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校组ID(=type=SCHOOL_GROUP 的 sys_group.id);区域/学段/根直属用户填 0
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer schoolId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否主身份标记:每 userId 最多 1 条 isMain=1(应用层校验,不靠 uk)
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer isMain = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序号:越小越靠前
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_no", nullable = false)
|
||||||
|
private Integer sortNo = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0=禁用 1=正常(StatusEnum)
|
||||||
|
*/
|
||||||
|
@Convert(converter = StatusEnumConverter.class)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private StatusEnum status = StatusEnum.NORMAL;
|
||||||
|
|
||||||
|
@Column(name = "create_time", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@Column(name = "update_time", nullable = false)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
public @interface Update {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/1/5 11:15
|
||||||
|
* @description 虚拟节点类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum BizType {
|
||||||
|
|
||||||
|
ROOT (1),
|
||||||
|
TEXTBOOK (2),
|
||||||
|
GRADE (3),
|
||||||
|
KNOWLEDGE (4),
|
||||||
|
REALLY (5),
|
||||||
|
;
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
|
||||||
|
public static BizType getByKey(Integer key){
|
||||||
|
BizType[] enums = BizType.values();
|
||||||
|
Optional<BizType> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
|
||||||
|
if(opt.isPresent()){
|
||||||
|
return opt.get();
|
||||||
|
}else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/1/16 9:32
|
||||||
|
* @description 资源类型
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum BookType {
|
||||||
|
/** 教材 */
|
||||||
|
TextBook (1,"教材"),
|
||||||
|
/** 教辅 */
|
||||||
|
TeachingTutoring (2,"教辅"),
|
||||||
|
/** 知识点 */
|
||||||
|
KnowledgePoint (3,"知识点"),
|
||||||
|
;
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
public static BookType getByKey(Integer key){
|
||||||
|
BookType[] enums = BookType.values();
|
||||||
|
Optional<BookType> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
|
||||||
|
if(opt.isPresent()){
|
||||||
|
return opt.get();
|
||||||
|
}else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getValue(Integer key) {
|
||||||
|
for (BookType bookType : BookType.values()) {
|
||||||
|
if (bookType.getKey().equals(key)) {
|
||||||
|
return bookType.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/13
|
||||||
|
* @description 性别
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum GenderEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
FEMALE(0, "女"),
|
||||||
|
MALE(1, "男");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/13
|
||||||
|
* @description 分组类型
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum GroupTypeEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
NORMAL_GROUP(1, "普通组"),
|
||||||
|
SCHOOL_GROUP(2, "学校组"),
|
||||||
|
GRADE_GROUP(3, "年级组"),
|
||||||
|
CLASS_GROUP(4, "班级组"),
|
||||||
|
AREA_GROUP(5, "区域组"),
|
||||||
|
STAGE_GROUP(6, "学段组"),
|
||||||
|
TEACHER_GROUP(7, "教师组"),
|
||||||
|
STUDENT_GROUP(8, "学生组");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/13
|
||||||
|
* @description 集成方式
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum IntegEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
INNER(0, "内置"),
|
||||||
|
WEB(1, "Web"),
|
||||||
|
APP_JUMP(3, "App跳转");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志级别。<br>
|
||||||
|
* 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/28
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum LogLevel {
|
||||||
|
|
||||||
|
IMPORTANT(1, "重要(删除)"),
|
||||||
|
SECONDARY(2, "次要(新增/编辑)"),
|
||||||
|
GENERAL(3, "一般(查询)");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String desc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 code 反查级别。
|
||||||
|
*
|
||||||
|
* @param code 级别编码
|
||||||
|
* @return 对应级别,未知编码默认次要
|
||||||
|
*/
|
||||||
|
public static LogLevel fromCode(Integer code) {
|
||||||
|
if (code == null) {
|
||||||
|
return SECONDARY;
|
||||||
|
}
|
||||||
|
for (LogLevel level : values()) {
|
||||||
|
if (level.code == code) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SECONDARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/1/5 13:53
|
||||||
|
* @description 资源来源
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public enum ResourceSourceEnum {
|
||||||
|
ZIYUAN("ZIYUAN","资源库"),
|
||||||
|
LOCAL("LOCAL","平台资源库"),
|
||||||
|
;
|
||||||
|
|
||||||
|
private final String enKey;
|
||||||
|
private final String cnKey;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2025/12/15 13:29
|
||||||
|
* @description 根目录树类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum RootNode {
|
||||||
|
|
||||||
|
ZIYUANKU (1,"ZIYUAN","资源"),
|
||||||
|
TUTORING (2,"TUTORING","辅导"),
|
||||||
|
QUESTION (3,"QUESTION","题库"),
|
||||||
|
SHIJUAN (4,"SHIJUAN","试卷"),
|
||||||
|
ZHUANXIANG (5,"ZHUANXIANG","专项"),
|
||||||
|
;
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String enKey;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
|
||||||
|
public static RootNode getByKey(Integer key){
|
||||||
|
RootNode[] enums = RootNode.values();
|
||||||
|
Optional<RootNode> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
|
||||||
|
if(opt.isPresent()){
|
||||||
|
return opt.get();
|
||||||
|
}else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RootNode fromKey(Integer key) {
|
||||||
|
for (RootNode n : values()) if (n.key.equals(key)) return n;
|
||||||
|
throw new IllegalArgumentException("非法 RootNode key: " + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2025/12/15 15:55
|
||||||
|
* @description 学段
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum SchoolStage {
|
||||||
|
|
||||||
|
PRIMARY (2,"PRIMARY","小学"),
|
||||||
|
MIDDLE (3,"MIDDLE","初中"),
|
||||||
|
HIGH (4,"HIGH","高中"),
|
||||||
|
;
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String enKey;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
|
||||||
|
public static SchoolStage getByKey(Integer key){
|
||||||
|
SchoolStage[] enums = SchoolStage.values();
|
||||||
|
Optional<SchoolStage> opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny();
|
||||||
|
if(opt.isPresent()){
|
||||||
|
return opt.get();
|
||||||
|
}else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getName(Integer key) {
|
||||||
|
for (SchoolStage stage : SchoolStage.values()) {
|
||||||
|
if (stage.getKey().equals(key)) {
|
||||||
|
return stage.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static SchoolStage getByValue(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(item -> item.getValue().equals(value))
|
||||||
|
.findAny()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static SchoolStage fromKey(Integer key) {
|
||||||
|
for (SchoolStage n : values()) if (n.key.equals(key)) return n;
|
||||||
|
throw new IllegalArgumentException("非法 SchoolStage key: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/13
|
||||||
|
* @description 通用启用禁用状态
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum StatusEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
NORMAL(0, "正常"),
|
||||||
|
DISABLED(1, "禁用");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/25
|
||||||
|
* @description 操作模式枚举(应用授权等场景:追加/覆盖/删除)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum SysModeEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
APPEND(0, "追加"),
|
||||||
|
OVERWRITE(1, "覆盖"),
|
||||||
|
DELETE(2, "删除");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/07/13
|
||||||
|
* @description 用户角色枚举
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum SysRoleEnum implements IEnum<Integer>{
|
||||||
|
|
||||||
|
TEACHER(1, "老师"),
|
||||||
|
SCHOOL_LEADER(2, "校领导"),
|
||||||
|
STUDENT(3, "学生"),
|
||||||
|
PARENT(4, "家长"),
|
||||||
|
BUREAU_LEADER(5, "局领导"),
|
||||||
|
WORK_ACCOUNT(6, "工作号"),
|
||||||
|
ADMIN(7, "管理员");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ Description : java类作用描述
|
||||||
|
* @Author : cls
|
||||||
|
* @ CreateDate : 2020/2/21 18:39
|
||||||
|
* @ Company : bestsch
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public enum SystemGrade {
|
||||||
|
|
||||||
|
one(1,"一年级",2),two(2,"二年级",2),
|
||||||
|
three(3,"三年级",2),four(4,"四年级",2),
|
||||||
|
five(5,"五年级",2),six(6,"六年级",2),
|
||||||
|
seven(7,"七年级",3),eight(8,"八年级",3),
|
||||||
|
nine(9,"九年级",3),ten(10,"高一",4),
|
||||||
|
eleven(11,"高二",4),twelve(12,"高三",4);
|
||||||
|
|
||||||
|
private Integer gradeId;
|
||||||
|
private String gradeName;
|
||||||
|
private Integer stage;
|
||||||
|
|
||||||
|
public static String getName(Integer gradeId) {
|
||||||
|
for (SystemGrade grade : SystemGrade.values()) {
|
||||||
|
if (grade.getGradeId().equals(gradeId)) {
|
||||||
|
return grade.gradeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @program: UserProfile
|
||||||
|
* @description: 用户类别
|
||||||
|
* @author: chenx
|
||||||
|
* @create: 2023-05-26 10:46
|
||||||
|
**/
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum UserType {
|
||||||
|
|
||||||
|
TEACHER (1,"教师","Teacher"),
|
||||||
|
STUDENT (2,"学生","Student"),
|
||||||
|
;
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.yunhai.userbase.enums;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/08/03
|
||||||
|
* @description 用户类型枚举
|
||||||
|
* - NORMAL(0) 普通用户:默认类型
|
||||||
|
* - SUPER_ADMIN(1) 超级管理员:系统内置种子账号(yunhaisch / yunhaisch2 / yunhaisch3),由初始化脚本写入
|
||||||
|
* - SCHOOL_ADMIN(2) 学校管理员:拥有管理身份(角色含 ADMIN(7))的普通账号(非超级管理员)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum UserTypeEnum implements IEnum<Integer> {
|
||||||
|
|
||||||
|
NORMAL(0, "普通用户"),
|
||||||
|
SUPER_ADMIN(1, "超级管理员"),
|
||||||
|
SCHOOL_ADMIN(2, "学校管理员");
|
||||||
|
|
||||||
|
private Integer key;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yunhai.userbase.event;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用密钥/元数据变更事件:在 ApplicationInfoService 的 save/add/edit/delete/resetSecret 等事务内发布,
|
||||||
|
* 由 SignKeyCacheService 的 @TransactionalEventListener(phase = AFTER_COMMIT 订阅,
|
||||||
|
* 保证 DB 真正提交成功后再写共享 Redis / 清本地缓存,避免事务回滚导致的脏数据。
|
||||||
|
*/
|
||||||
|
public class ApplicationInfoCacheChangedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
public enum Type {
|
||||||
|
UPSERT, DELETE
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Type type;
|
||||||
|
private final Integer appKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除事件:appKey 必填;upsert 事件:可以传 entity 非空。
|
||||||
|
*/
|
||||||
|
private final ApplicationInfo upsertEntity;
|
||||||
|
|
||||||
|
public ApplicationInfoCacheChangedEvent(Object source, Type type, Integer appKey, ApplicationInfo upsertEntity) {
|
||||||
|
super(source);
|
||||||
|
this.type = type;
|
||||||
|
this.appKey = appKey;
|
||||||
|
this.upsertEntity = upsertEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationInfoCacheChangedEvent upsert(Object source, ApplicationInfo entity) {
|
||||||
|
return new ApplicationInfoCacheChangedEvent(source, Type.UPSERT, entity.getAppKey(), entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationInfoCacheChangedEvent delete(Object source, Integer appKey) {
|
||||||
|
return new ApplicationInfoCacheChangedEvent(source, Type.DELETE, appKey, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type getType() { return type; }
|
||||||
|
|
||||||
|
public Integer getAppKey() { return appKey; }
|
||||||
|
|
||||||
|
public ApplicationInfo getUpsertEntity() { return upsertEntity; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.yunhai.userbase.event;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.model.push.PushInterfaceType;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据同步事件:组 / 用户变更后由 {@code SysGroupService}/{@code SysUserService} 在事务内 publish,
|
||||||
|
* {@code PushDispatcher} 用 {@code @TransactionalEventListener(AFTER_COMMIT)} 消费(事务提交后才推送,
|
||||||
|
* 推送失败不影响主业务)。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/14
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class DataSyncEvent {
|
||||||
|
|
||||||
|
/** 接口类型,如 group/AddGroup */
|
||||||
|
private PushInterfaceType type;
|
||||||
|
/** 组事件携带的受影响组 id 列表 */
|
||||||
|
private List<Integer> groupIds;
|
||||||
|
/** 用户事件携带的受影响用户 id 列表 */
|
||||||
|
private List<Integer> userIds;
|
||||||
|
/** 组维度的关联事件(如删除用户=解除组关系)携带的组 id */
|
||||||
|
private Integer groupId;
|
||||||
|
/** 触发本次变更的 oapi 调用方 appKey;非 oapi 来源(如管理后台)为 null。
|
||||||
|
* 推送分发器据此回推时排除来源应用自身,避免收到自己的回声。 */
|
||||||
|
private Integer sourceAppKey;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.yunhai.userbase.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @program: QualityEvaluate
|
||||||
|
* @description: 系统字典类
|
||||||
|
* @author: chenx
|
||||||
|
* @create: 2023-05-15 17:17
|
||||||
|
**/
|
||||||
|
public class BaseDict {
|
||||||
|
public Integer k;
|
||||||
|
public String v;
|
||||||
|
|
||||||
|
|
||||||
|
public int getK() {
|
||||||
|
return this.k;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setK(int k) {
|
||||||
|
this.k = k;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getV() {
|
||||||
|
return this.v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setV(String v) {
|
||||||
|
this.v = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BaseDict(Integer k, String v) {
|
||||||
|
this.k = k;
|
||||||
|
this.v = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.yunhai.userbase.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/21 21:41
|
||||||
|
* @description
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class BasePageQuery {
|
||||||
|
private int pageIndex = 1;
|
||||||
|
private int pageSize = 10;
|
||||||
|
/** 通用模糊搜索关键词 */
|
||||||
|
private String keyword;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.yunhai.userbase.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一分页响应外壳。<br>
|
||||||
|
* 由 Controller 层负责装配(见 {@link com.yunhai.userbase.utils.PageUtils#toResult}),
|
||||||
|
* Service 层仍返回 Spring Data 的 {@link org.springframework.data.domain.Page},保持与传输协议解耦。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/7/21 21:40
|
||||||
|
* @description 统一分页响应外壳
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class BasePageResult<T> {
|
||||||
|
/** 总记录数 */
|
||||||
|
private int total;
|
||||||
|
/** 当前页数据 */
|
||||||
|
private List<T> content;
|
||||||
|
/** 当前页码(从 1 起,与 {@link BasePageQuery#getPageIndex()} 约定一致) */
|
||||||
|
private int pageIndex;
|
||||||
|
/** 每页条数 */
|
||||||
|
private int pageSize;
|
||||||
|
/** 总页数 */
|
||||||
|
private int totalPages;
|
||||||
|
/** 是否还有下一页 */
|
||||||
|
private boolean hasNext;
|
||||||
|
|
||||||
|
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
|
||||||
|
this.content = content;
|
||||||
|
this.total = total;
|
||||||
|
this.pageIndex = pageIndex;
|
||||||
|
this.pageSize = pageSize;
|
||||||
|
this.totalPages = totalPages;
|
||||||
|
this.hasNext = hasNext;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.yunhai.userbase.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
public class BaseTree {
|
||||||
|
public Integer id;
|
||||||
|
public String name;
|
||||||
|
public Integer pid ;
|
||||||
|
public Integer type;
|
||||||
|
public List<BaseTree> children;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.yunhai.userbase.model.Excel;
|
||||||
|
|
||||||
|
import cn.afterturn.easypoi.excel.annotation.Excel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/1/19 19:45
|
||||||
|
* @description 教材导入
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ExcelKnowledge {
|
||||||
|
|
||||||
|
@Excel(name = "第一层", width = 20)
|
||||||
|
private String first;
|
||||||
|
|
||||||
|
@Excel(name = "第二层", width = 20)
|
||||||
|
private String second;
|
||||||
|
|
||||||
|
@Excel(name = "第三层", width =20)
|
||||||
|
private String third;
|
||||||
|
|
||||||
|
@Excel(name = "第四层", width = 20)
|
||||||
|
private String fourth;
|
||||||
|
|
||||||
|
@Excel(name = "第五层", width = 20)
|
||||||
|
private String five;
|
||||||
|
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user