Compare commits

..
24 Commits
Author SHA1 Message Date
ops 08a21c76aa fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:28:49 +00:00
ops 426169054e ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:56 +00:00
chenxing 751a482456 ci
ci-userbase-service / build-and-push (push) Failing after 1m44s
2026-09-07 20:12:41 +08:00
chenxing 07e803b440 env-config修订
ci-userbase-service / build-and-push (push) Successful in 1m29s
2026-09-07 18:17:02 +08:00
chenxing 1047d2387f 后端缓存管理
ci-userbase-service / build-and-push (push) Successful in 2m20s
2026-09-07 09:33:10 +08:00
chenxing 79b1d99db9 上传头像功能修订
ci-userbase-service / build-and-push (push) Successful in 1m37s
2026-09-02 11:43:27 +08:00
chenxing d018567642 上传头像功能实现
ci-userbase-service / build-and-push (push) Successful in 2m1s
2026-09-02 10:23:41 +08:00
chenxing 53b2fedb7a 上传头像功能实现 2026-09-02 10:23:31 +08:00
chenxing 73e404633d bug修订
ci-userbase-service / build-and-push (push) Successful in 57s
2026-09-02 08:40:18 +08:00
chenxing a937984f0b ci: webhook 响应体校验(projman 业务错误也返回 HTTP 200,检测 code 字段防止静默失败)
ci-userbase-service / build-and-push (push) Successful in 44s
2026-09-01 20:18:56 +08:00
chenxing 3e38d702e6 ci: 切换 projman 发布链路(构建->Nexus->webhook 通知 projman 部署 10001),移除 docker run 8060 自部署
ci-userbase-service / build-and-push (push) Successful in 41s
2026-09-01 20:16:06 +08:00
chenxing 71adaf6acb 健康检查功能添加
ci-userbase-service / build-and-push (push) Successful in 2m47s
2026-09-01 11:50:49 +08:00
chenxing bf4e1552b8 GetUserMod模块列表改按customId配置编码排序
ci-userbase-service / build-and-push (push) Successful in 46s
2026-08-31 22:04:24 +08:00
chenxing c588dedea7 跨校身份修订
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-27 20:14:12 +08:00
chenxing ebbaeee181 用户启用不启用接口开发
ci-userbase-service / build-and-push (push) Successful in 45s
2026-08-27 10:08:58 +08:00
chenxing 6675ccc8d5 转出状态2
ci-userbase-service / build-and-push (push) Successful in 46s
2026-08-26 12:38:55 +08:00
chenxing 5627252eab 转出状态以及相关接口更改 2026-08-26 12:38:31 +08:00
chenxing 0b0be6b8e5 Merge remote-tracking branch 'origin/main' (仓库 .git 修复)
ci-userbase-service / build-and-push (push) Successful in 48s
2026-08-21 10:46:58 +08:00
chenxing f182fe44ce 本地工作区快照:恢复 .git 仓库(合并远端 main 前基线) 2026-08-21 10:46:47 +08:00
chenxing e4d6e1da11 修复添加顶层组的bug
ci-userbase-service / build-and-push (push) Successful in 45s
2026-08-20 19:05:06 +08:00
chenxing 355920746b 修改推送日志功能
ci-userbase-service / build-and-push (push) Successful in 1m0s
2026-08-20 12:15:48 +08:00
chenxing db0d5e1a2d 修改推送日志功能
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-20 10:57:23 +08:00
chenxing e001bc9d77 添加推送日志功能
ci-userbase-service / build-and-push (push) Successful in 1m49s
2026-08-20 10:24:47 +08:00
chenxing 99414e2887 推送订阅添加批量删除功能
ci-userbase-service / build-and-push (push) Successful in 43s
2026-08-20 09:31:50 +08:00
136 changed files with 8247 additions and 1099 deletions
+45 -105
View File
@@ -1,21 +1,14 @@
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 推送权)
# 后端标准化 CI(projman 发布链路版):
# maven 构建 jar -> docker build -> 推 Nexus docker-hosted -> webhook 通知 projman 建档
# 部署由 projman 执行(app userbase id=18,目标 10001):manual 模式待手动部署 / auto 模式自动部署
# 触发:push 到 main / 手动 Run workflow
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号)/ DEPLOY_TOKEN(projman webhook 鉴权)
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:
@@ -27,25 +20,28 @@ jobs:
container:
image: docker:24
steps:
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
- name: Install git (alpine)
run: apk add --no-cache git
# docker:24 基于 alpine,默认无 git/curl;先装再手动 clone 本仓库
- name: Install git/curl (alpine)
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${GITHUB_REPOSITORY}.git" /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
- 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
run: echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Build jar with Maven
run: |
@@ -55,28 +51,26 @@ jobs:
# 直接 -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 \
# settings.xml profile 把 pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/。
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--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'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" 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 缓存(缺 CasSsoAutoConfiguration 的 08-05 本地安装版),强制从 Nexus 拉 -7
docker exec "$BUILD_CONTAINER" rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
docker exec "$BUILD_CONTAINER" 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
docker cp "$BUILD_CONTAINER":/build/target/userbase.jar "${PWD}/target/userbase.jar"
docker rm -f "$BUILD_CONTAINER"
- name: Login to Nexus docker registry
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
@@ -87,70 +81,16 @@ jobs:
- name: Push image
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
# 通知 projman 建档(Release);部署由 projman 按 app.deploy_mode 决定(manual=待部署 / auto=自动部署 10001)
# 注意:projman 业务错误也返回 HTTP 200(体含 {"code":N}),必须检查响应体,否则失败会静默
- name: Notify projman (webhook)
run: |
RESP=$(curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
-d "{\"app\":\"userbase\",\"tag\":\"${{ steps.meta.outputs.tag }}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}")
echo "webhook resp: ${RESP:-<empty=ok>}"
if echo "${RESP}" | grep -q '"code"'; then echo "!! webhook rejected by projman"; exit 8; fi
- 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
+25 -12
View File
@@ -1,4 +1,4 @@
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底)
# UserBase 生产配置(镜像内自足:配置中心已停用,运行时密码由 Gitea Secrets / projman .env 环境变量注入)
server:
port: 8060
servlet:
@@ -20,8 +20,7 @@ spring:
host: redis-server
port: 6379
database: 0
# 生产由 nacos application-infra.yaml 注入;此处仅作 nacos 不可达时的兜底,
# 密码不写死,运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets,不入镜像)
# 兜底密码运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets / projman .env,不入镜像)
password: ${REDIS_PASSWORD}
timeout: 2000ms
data:
@@ -29,14 +28,14 @@ spring:
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
# 镜像内兜底:db 密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets / projman .env,不入镜像)
datasource:
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
@@ -54,7 +53,7 @@ spring:
host: rabbitmq
port: 5672
username: admin
# 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(来自 Gitea Secrets)
# 兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(值来自 Gitea Secrets / projman .env,不入镜像)
password: ${RABBITMQ_PASSWORD}
listener:
direct:
@@ -62,6 +61,20 @@ spring:
simple:
auto-startup: false
# 健康检查(projman 健康总览探活口径:容器 Running + HTTP 200 即健康):
# 仅暴露 health;DB/Redis 计入总体状态(连不上即 DOWN=503,projman 显示 fail),rabbit 推送链路不算
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
health:
rabbit:
enabled: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
+3 -4
View File
@@ -1,10 +1,9 @@
#!/bin/sh
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
# UserBase 启动入口:配置中心(nacos)已停用,生产配置=镜像内烤好的 application-prod.yml
# + 运行时环境变量注入(Gitea Secrets / projman .env:DB_PASSWORD/REDIS_PASSWORD/RABBITMQ_PASSWORD)。
set -e
echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
echo "[run-app] starting UserBase (profile=prod) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
--spring.profiles.active=prod
+16 -15
View File
@@ -31,14 +31,6 @@
<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>
@@ -76,17 +68,19 @@
<scope>test</scope>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<!-- 健康检查端点 /actuator/health:供 projman 健康总览探活(容器间直连,不走 Caddy/鉴权) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点:暴露 /actuator/prometheus 供压测观测(方案 §2.5)。
压测结束后整段删除,并同步回退 application.yml 的 management 段。见《埋点移除清单.md》。 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-END] -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -194,6 +188,13 @@
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</artifactId>
</dependency>
<!-- 前端运行时配置(projman 双写 Nacos 免登录端点 GET /api/env-config?target=xxx)。
原自研 EnvConfigController/EnvConfigService/EnvConfigPublicConfig 三件套已上提平台,
本工程仅保留 ManageAccessInterceptor 白名单放行。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-env-config-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon
// Deploy front/dist 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:
@@ -153,8 +153,8 @@ async function flushDebug() {
function resolveDistPath() {
const candidates = [
path.join(process.cwd(), 'front', 'dist', 'prod'),
'/workspace/admin/userbase/front/dist/prod',
path.join(process.cwd(), 'front', 'dist'),
'/workspace/admin/userbase/front/dist',
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
@@ -201,13 +201,13 @@ async function main() {
} catch (e) {
dbg('ls err: ' + e.message);
}
throw new Error('built dist/prod not found under any candidate path');
throw new Error('built dist 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/"
// We fetch WITHOUT a trailing slash so the tar contains a top-level "dist/"
// 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);
@@ -226,10 +226,10 @@ async function main() {
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
}
// 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing).
// 5) move staging/dist -> 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}`
`rm -rf ${HOST_STATIC}/front && mv ${STAGING}/dist ${HOST_STATIC}/front && rm -rf ${STAGING}`
);
dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
@@ -237,12 +237,12 @@ async function main() {
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)"; ` +
`echo "has_stray_dist=$(test -d ${HOST_STATIC}/front/dist && 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)');
dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no dist/ nesting)');
}
main()
@@ -0,0 +1,292 @@
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 org.springframework.web.multipart.MultipartFile;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 开放接口(/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;
}
// multipart 文件不序列化内容(避免读文件字节/写爆 request_params),仅记文件名元数据
if (arg instanceof MultipartFile) {
MultipartFile mf = (MultipartFile) arg;
Map<String, Object> meta = new LinkedHashMap<String, Object>();
meta.put("fileName", mf.getOriginalFilename());
meta.put("contentType", mf.getContentType());
meta.put("size", mf.getSize());
filtered.add(meta);
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;
}
}
@@ -51,7 +51,8 @@ public class OperationLogAspect {
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.userbase.controller..*.*(..))")
@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())) {
@@ -5,8 +5,10 @@ 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;
@@ -52,6 +54,18 @@ public class GlobalExceptionHandler {
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 "参数校验失败";
@@ -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;
}
@@ -1,6 +1,6 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.ClientEnum;
import com.yunhai.platform.utils.enums.ClientEnum;
import javax.persistence.AttributeConverter;
@@ -1,6 +1,6 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.platform.utils.enums.IEnum;
import javax.persistence.AttributeConverter;
@@ -0,0 +1,18 @@
package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
import javax.persistence.AttributeConverter;
/**
* UserGroupRelStatusEnum 专用 JPA 转换器(int &lt;-&gt; 枚举)。<br>
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
*
* @author chenx
*/
public class UserGroupRelStatusEnumConverter extends CommonIntEnumConverter<UserGroupRelStatusEnum> {
public UserGroupRelStatusEnumConverter() {
super(UserGroupRelStatusEnum.class);
}
}
@@ -3,6 +3,7 @@ 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 com.yunhai.userbase.service.resolve.AccessScopeService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -44,5 +45,7 @@ public class BoaUserContextInterceptor implements HandlerInterceptor {
HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
// 访问范围请求级缓存与用户上下文同生命周期,一并清理(防线程复用串档)
AccessScopeService.clearCache();
}
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.common.interceptor;
import com.yunhai.userbase.enums.AccessLevelEnum;
import com.yunhai.userbase.service.resolve.AccessScope;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.List;
/**
* 管理端统一防线:NONE 级(普通用户/未登录)一律拦截,HTTP 200 + {code:403, msg:"暂无权限"}。<br>
* 与前端 alova 约定一致(status!=200 会被当作网络错误,业务码走 errorHandle default 分支弹 msg)。<br>
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
*
* @author chenx
* @date 2026/9/4
*/
@Component
public class ManageAccessInterceptor implements HandlerInterceptor {
/**
* 白名单(Ant 风格,* 匹配 platform 前缀):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
*/
private static final List<String> WHITELIST = Arrays.asList(
"/*/base/GetUserInfo",
"/*/base/GetUserScope",
"/*/base/GetSysDict",
"/*/base/RedirectTo/*",
"/*/env-config");
private static final AntPathMatcher MATCHER = new AntPathMatcher();
@Autowired
private AccessScopeService accessScopeService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String path = request.getRequestURI().substring(request.getContextPath().length());
if (isWhitelisted(path)) {
return true;
}
AccessScope scope = accessScopeService.resolveCurrentScope();
if (scope.getLevel() != AccessLevelEnum.NONE) {
return true;
}
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"暂无权限\",\"data\":null}");
return false;
}
private boolean isWhitelisted(String path) {
for (String pattern : WHITELIST) {
if (MATCHER.match(pattern, path)) {
return true;
}
}
return false;
}
}
@@ -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;
}
}
@@ -8,9 +8,16 @@ 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.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.Weigher;
import com.yunhai.userbase.config.CacheNames.Meta;
import com.yunhai.userbase.utils.ConstantUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
@@ -18,56 +25,123 @@ 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.cache.RedisCacheWriter;
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.RedisSerializationContext.SerializationPair;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
/**
* 缓存统一基建(Spring Cache 抽象)。<br><br>
*
* 组成(缓存名单一定义、一注册、一路由,全部由 {@link CacheNames#REGISTRY} 驱动):
* <ul>
* <li><b>redisCacheManager</b>:注册表中 REDIS 存放的缓存按名定制 TTL(30s~2h 分级定价),
* 写入经抖动装饰器打散过期点(±10%,防同时过期型雪崩);未注册名兜底 5min;</li>
* <li><b>caffeineCacheManager</b>:Redis 降级兜底,按字节限重(maximumWeight + 启发式估算,
* 防巨型条目绕过条数限制撑爆内存);</li>
* <li><b>localCacheManager</b>:LOCAL 存放专用(groupSnapshot / appKeyToId),Caffeine 独立实例,
* 永不进 Redis;</li>
* <li><b>FallbackCacheManager</b>(@Primary):本地专用名恒路由 localCacheManager,
* 其余 Redis 健康走 Redis、故障降级 Caffeine,后台 10s 探活自动切回;</li>
* <li><b>CacheErrorHandler</b>:堵探活间隙——Redis 读写异常降级为 log+miss 而非上冒 500,
* 驱逐失败打 error(脏数据存活至 TTL 后自愈)。</li>
* </ul>
*/
@EnableCaching
@Configuration
public class CacheConfig {
public class CacheConfig implements CachingConfigurer {
private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
/** 降级 Caffeine 的字节限重上限(按估算权重,防巨型条目绕过条数统计)。 */
private static final long CAFFEINE_MAX_WEIGHT_BYTES = 64L * 1024 * 1024;
/** groupSnapshot 本地实例的字节限重上限(快照约 4~8MB,留出翻倍余量)。 */
private static final long SNAPSHOT_MAX_WEIGHT_BYTES = 64L * 1024 * 1024;
/** appKeyToId 本地实例的条目上限(application_info 仅十几条,宽松限量)。 */
private static final long APP_KEY_TO_ID_MAX_ENTRIES = 512;
/**
* 一级缓存:Redis 分布式缓存管理器。
* 一级缓存:Redis 分布式缓存管理器。<br>
* 各缓存 TTL 由 {@link CacheNames#REGISTRY} 定价(配置型 1h~2h / 用户身份型 5min / 空值哨兵 30s),
* 未注册名兜底全局 5min;每次写入 TTL 加 ±10% 随机抖动。
*/
@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(防穿透)
RedisCacheConfiguration baseConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(jacksonSerializer))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(cacheConfig)
Map<String, RedisCacheConfiguration> perCache = new HashMap<>();
for (Meta meta : CacheNames.REGISTRY.values()) {
if (meta.getStore() == CacheNames.Store.REDIS) {
perCache.put(meta.getName(), baseConfig.entryTtl(meta.getTtl()));
}
}
return RedisCacheManager.builder(new JitteringRedisCacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(factory)))
.cacheDefaults(baseConfig.entryTtl(ConstantUtil.CACHE_TTL))
.withInitialCacheConfigurations(perCache)
.build();
}
/**
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。<br>
* 按字节限重(原 maximumSize(10000) 条数统计会被巨型条目欺骗),权重为保守启发式估算。
*/
@Bean
public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致
.maximumSize(10000) // 限制本地容量,防止内存溢出
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 未注册名默认 TTL 保持一致
.maximumWeight(CAFFEINE_MAX_WEIGHT_BYTES)
.weigher((Weigher<Object, Object>) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4)))
.recordStats());
manager.setAllowNullValues(false); // 与 Redis 一致:禁止缓存 null
manager.setAllowNullValues(false);
return manager;
}
/**
* 本地专用缓存管理器:仅承载注册表 Store.LOCAL 的缓存(groupSnapshot / appKeyToId),
* 每缓存独立 Caffeine 实例(独立 TTL + 每条目抖动 Expiry + 独立命中率统计),静态模式不动态建缓存。
*/
@Bean
public CaffeineCacheManager localCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.registerCustomCache(CacheNames.GROUP_SNAPSHOT, Caffeine.newBuilder()
.maximumWeight(SNAPSHOT_MAX_WEIGHT_BYTES)
.weigher((Weigher<Object, Object>) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4)))
.expireAfter(jitterExpiry(Duration.ofMinutes(30)))
.recordStats()
.build());
manager.registerCustomCache(CacheNames.APP_KEY_TO_ID, Caffeine.newBuilder()
.maximumSize(APP_KEY_TO_ID_MAX_ENTRIES)
.expireAfter(jitterExpiry(Duration.ofHours(2)))
.recordStats()
.build());
manager.setCacheNames(java.util.Arrays.asList(
CacheNames.GROUP_SNAPSHOT, CacheNames.APP_KEY_TO_ID));
return manager;
}
@@ -89,24 +163,63 @@ public class CacheConfig {
}
/**
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。
* 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。<br>
* 注册表 LOCAL 存放的缓存恒路由本地实例;其余优先 Redis,
* Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
*/
@Primary
@Bean
public CacheManager cacheManager(RedisCacheManager redisCacheManager,
CaffeineCacheManager caffeineCacheManager,
CaffeineCacheManager localCacheManager,
RedisConnectionFactory redisConnectionFactory) {
return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, redisConnectionFactory);
return new FallbackCacheManager(redisCacheManager, caffeineCacheManager,
localCacheManager, redisConnectionFactory);
}
/**
* 缓存异常处理器(堵探活间隙):后台探活周期 10s,0~10s 内 redisUp 仍为 true 时 Redis 可能刚宕,
* 此时读写异常若上冒会让接口 500。这里统一降级:GET 失败视为 miss(穿透到 DB,慢但可用);
* PUT 失败跳过(本条不缓存);EVICT/CLEAR 失败打 error——脏数据将存活至 TTL 后自愈,
* 该日志频率是事件链路健康度的观测点(频繁出现必须排查)。
*/
@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.warn("[Cache] GET 降级: cache={} key={} 视为未命中,穿透回源DB: {}",
cache.getName(), key, exception.getMessage());
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
log.warn("[Cache] PUT 降级: cache={} key={} 本次不缓存: {}",
cache.getName(), key, exception.getMessage());
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
log.error("[Cache] EVICT 失败: cache={} key={} 驱逐异常,脏数据将存活至TTL后自愈,请检查事件失效链路: {}",
cache.getName(), key, exception.getMessage());
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
log.error("[Cache] CLEAR 失败: cache={} 整体清空异常,脏数据将存活至TTL后自愈,请检查事件失效链路: {}",
cache.getName(), exception.getMessage());
}
};
}
/**
* 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。
*/
static class FallbackCacheManager implements CacheManager {
public static class FallbackCacheManager implements CacheManager {
private final RedisCacheManager redisManager;
private final CaffeineCacheManager caffeineManager;
private final CaffeineCacheManager localManager;
private final RedisConnectionFactory connectionFactory;
private final ScheduledExecutorService healthScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
@@ -118,9 +231,11 @@ public class CacheConfig {
FallbackCacheManager(RedisCacheManager redisManager,
CaffeineCacheManager caffeineManager,
CaffeineCacheManager localManager,
RedisConnectionFactory connectionFactory) {
this.redisManager = redisManager;
this.caffeineManager = caffeineManager;
this.localManager = localManager;
this.connectionFactory = connectionFactory;
if (connectionFactory != null) {
// 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回)
@@ -152,8 +267,17 @@ public class CacheConfig {
}
}
/** Redis 是否健康(对账接口上报降级状态用)。 */
public boolean isRedisUp() {
return redisUp;
}
@Override
public Cache getCache(String name) {
// 本地专用缓存(groupSnapshot/appKeyToId)恒走本地实例,与 Redis 状态无关
if (CacheNames.isLocalOnly(name)) {
return localManager.getCache(name);
}
// AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。
// 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s),
// resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。
@@ -162,10 +286,150 @@ public class CacheConfig {
@Override
public Collection<String> getCacheNames() {
return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames();
Set<String> names = new LinkedHashSet<>();
names.addAll(localManager.getCacheNames());
names.addAll(redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames());
return names;
}
}
// ===================== TTL 随机抖动 =====================
/**
* RedisCacheWriter 装饰器:每次写入在缓存配置 TTL 基础上加 ±10% 随机抖动,
* 打散同一缓存所有条目的过期时刻,防「同时过期型雪崩」。无 TTL(不过期)条目原样透传。
*/
static class JitteringRedisCacheWriter implements RedisCacheWriter {
private final RedisCacheWriter delegate;
private final double jitterRatio;
JitteringRedisCacheWriter(RedisCacheWriter delegate) {
this(delegate, 0.1);
}
JitteringRedisCacheWriter(RedisCacheWriter delegate, double jitterRatio) {
this.delegate = delegate;
this.jitterRatio = jitterRatio;
}
@Override
public void put(String name, byte[] key, byte[] value, Duration ttl) {
delegate.put(name, key, value, jitter(ttl));
}
@Override
public byte[] get(String name, byte[] key) {
return delegate.get(name, key);
}
@Override
public byte[] putIfAbsent(String name, byte[] key, byte[] value, Duration ttl) {
return delegate.putIfAbsent(name, key, value, jitter(ttl));
}
@Override
public void remove(String name, byte[] key) {
delegate.remove(name, key);
}
@Override
public void clean(String name, byte[] pattern) {
delegate.clean(name, pattern);
}
@Override
public void clearStatistics(String name) {
delegate.clearStatistics(name);
}
@Override
public org.springframework.data.redis.cache.CacheStatistics getCacheStatistics(String name) {
return delegate.getCacheStatistics(name);
}
@Override
public RedisCacheWriter withStatisticsCollector(
org.springframework.data.redis.cache.CacheStatisticsCollector collector) {
return delegate.withStatisticsCollector(collector);
}
private Duration jitter(Duration ttl) {
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
return ttl;
}
return Duration.ofNanos(jitteredNanos(ttl.toNanos(), jitterRatio));
}
}
/** 均匀抖动到 [1-ratio, 1+ratio) 区间(ratio=0.1 即 ±10%)。 */
static long jitteredNanos(long baseNanos, double ratio) {
double factor = 1 - ratio + 2 * ratio * ThreadLocalRandom.current().nextDouble();
return Math.max(1L, (long) (baseNanos * factor));
}
/** Caffeine 变长过期:每条目在基础 TTL 上独立抖动(本地缓存同款防雪崩语义)。 */
private static Expiry<Object, Object> jitterExpiry(Duration base) {
long baseNanos = base.toNanos();
return new Expiry<Object, Object>() {
@Override
public long expireAfterCreate(Object key, Object value, long currentTime) {
return jitteredNanos(baseNanos, 0.1);
}
@Override
public long expireAfterUpdate(Object key, Object value, long currentTime, long currentDuration) {
return jitteredNanos(baseNanos, 0.1);
}
@Override
public long expireAfterRead(Object key, Object value, long currentTime, long currentDuration) {
return currentDuration; // 读不续期
}
};
}
// ===================== 权重估算(Caffeine 按字节限重用) =====================
/**
* 保守的对象图字节估算(非精确值,只要量级正确即可完成「防巨型条目」使命):
* 字符串按 UTF-16 长度、集合/Map 递归求和(限深度)、单实体对象按 2KB 保底。
*/
static long estimateWeight(Object value, int depth) {
if (value == null) {
return 128;
}
if (value instanceof String) {
return 64 + 2L * ((String) value).length();
}
if (value instanceof byte[]) {
return 64 + ((byte[]) value).length;
}
if (value instanceof Collection) {
Collection<?> c = (Collection<?>) value;
if (depth <= 0) {
return 1024L * c.size();
}
long w = 64;
for (Object o : c) {
w += estimateWeight(o, depth - 1);
}
return w;
}
if (value instanceof Map) {
Map<?, ?> m = (Map<?, ?>) value;
if (depth <= 0) {
return 2048L * m.size();
}
long w = 64;
for (Map.Entry<?, ?> e : m.entrySet()) {
w += estimateWeight(e.getKey(), depth - 1) + estimateWeight(e.getValue(), depth - 1);
}
return w;
}
return 2048;
}
/** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
@@ -0,0 +1,185 @@
package com.yunhai.userbase.config;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 缓存名注册表(方案 C:代码即文档 + 运行时对账)。<br><br>
*
* <b>本类是全工程所有 Spring Cache 缓存名的单一声明处</b>:
* <ul>
* <li>{@code @Cacheable/@CacheEvict/@CachePut} 的 value 一律引用本类常量,禁止硬编码字符串;</li>
* <li>{@link CacheConfig} 依据 {@link #REGISTRY} 为每个缓存定制 TTL(含随机抖动)与存放路由
* (Redis / 本地 Caffeine);</li>
* <li>对账接口 {@code GET /api/cache/registry} 输出「注册表声明 × 运行时实际 × 命中率」对照,
* 注册表里声明但运行时从未出现 = 声明了没人用;运行时出现但注册表没有 = 有人硬编码缓存名。</li>
* </ul>
*
* <b>新增缓存流程</b>(详见《userbase缓存决策方案.md》第七章):
* 在本类加常量与 Meta(javadoc 必须写明:内容、key 规则、TTL、存放、依赖表、<b>谁负责失效</b>)→
* 在 {@link CacheConfig} 无需额外动作(配置由 REGISTRY 驱动)→ 读侧加 {@code @Cacheable(sync=true)} →
* 写侧事务提交后发失效事件 → 自检调对账接口。<br><br>
*
* <b>禁止事项</b>:不设 TTL(唯一特例 {@code sign:app:} 裸 Redis key,属 SignKeyCacheService
* 的跨服务验签契约,有每日对账兜底,不走 Spring Cache、不在本注册表管辖);
* 在 {@code @Transactional} 方法上用 {@code @CacheEvict/@CachePut}(驱逐发生在 commit 前,
* 有回源竞态,写失效一律走 AFTER_COMMIT 事件)。
*/
public final class CacheNames {
private CacheNames() {
}
// ===================== 缓存名常量 =====================
/**
* application_info 单条记录(按主键 id)。<br>
* key 规则:{@code #id};TTL 1h(配置型:变更以月计,脏了只是旧名字/旧图标,事件失效保即时性);<br>
* 存放:Redis;依赖表:application_info;<br>
* 失效责任:{@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (ApplicationInfoService 的 add/edit/delete/resetAppSecret/save/savePushSetting/sort 发布,
* {@link com.yunhai.userbase.service.impl.CacheInvalidationListener} AFTER_COMMIT 清整缓存)。
*/
public static final String APP_INFO = "appInfo";
/**
* BoaUser 完整用户(用户中心最高频读路径:CAS 会话、开放接口 byLoginName/byPhone/byId、认证链路)。<br>
* key 规则(三个维度各自成 key):{@code 'login:' + account}、{@code 'phone:' + phone}、{@code 'id:' + userId};<br>
* TTL 5min(用户身份型:脏 currentRole = 用户以错误身份操作,权限事故级);<br>
* 存放:Redis;依赖表:sys_user、sys_user_group_rel、sys_user_role_rel、sys_user_current_role、sys_group;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
* (SysUserService 全部写口 + IdentityService.switchIdentity 身份切换——<b>身份切换必须立即失效</b>;
* 监听器按 id/login/phone 精确驱逐,用户被物理删除时整体 clear 兜底)。
*/
public static final String BOA_USER = "boaUser";
/**
* boaUser 的<b>空值哨兵</b>(byLoginName/byPhone/byId 查无此人时的穿透防护)。<br>
* key 规则:与 {@link #BOA_USER} 相同的三维 key;值是哨兵对象,消费侧只判「key 存在」、绝不读值;<br>
* TTL 30s(反向哨兵:脏「不存在」会阻新用户使用,新注册用户须尽快可查);<br>
* 存放:Redis;失效责任:同 {@link #BOA_USER}(UserChangedEvent,随真实 key 一起驱逐)。<br>
* 设计说明:{@code unless} 在 {@code sync=true} 下被 Spring 忽略,无法用注解把哨兵与真实值分流到
* 不同 TTL 的缓存,故哨兵缓存由 UserCenterService 手动 put(30s 短 TTL 由本注册表驱动配置)。
*/
public static final String BOA_USER_NULL = "boaUserNull";
/**
* sys_group 全表快照(单条目:不可变 List + 派生 byId/childrenMap 索引)。<br>
* key 规则:固定单 key {@code 'all'}(整体驱逐+重建,不做增量维护);<br>
* TTL 30min(组织架构型:变更以天/周计,脏数据影响祖先链→开通判定;组写频次极低,命中率 &gt;99%);<br>
* 存放:<b>Caffeine 仅本地</b>(约 1 万行 ≈ 序列化 2MB/JVM 4~8MB,进 Redis 则每次读取网络传输 2MB,
* 权限计算一次请求读 N 次,无意义;本地=对象引用传递,零序列化零网络。单副本前提,多副本时需改两层结构或广播);<br>
* 依赖表:sys_group;<br>
* 失效责任:{@link com.yunhai.userbase.event.GroupChangedEvent}
* (SysGroupService 组域写口 add/batchAdd/edit/move/delete/sortChildren 发布,
* 监听器驱逐整条快照,下次读取 sync=true 排队重建)。
*/
public static final String GROUP_SNAPSHOT = "groupSnapshot";
/**
* appKey→appId 映射(application_info 十几条,消除 /oapi/user/modules 每请求一次 findByAppKey)。<br>
* key 规则:{@code #appKey};TTL 2h(配置型);<br>
* 存放:Caffeine 仅本地;依赖表:application_info;<br>
* 失效责任:搭车 {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (监听器整缓存 clear,条目仅十几条,clear 成本可忽略)。
*/
public static final String APP_KEY_TO_ID = "appKeyToId";
// ===================== 注册表元数据(驱动 CacheConfig 与对账接口) =====================
/** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */
public enum Store {
REDIS, LOCAL
}
/**
* 单个缓存的注册元数据:TTL、抖动比例、存放、key 规则、失效责任方、依赖表。
* javadoc 语义见各常量声明处,这里保留机器可读字段供 CacheConfig / 对账接口消费。
*/
public static final class Meta {
private final String name;
private final Store store;
private final Duration ttl;
/** TTL 随机抖动比例(±10% = 0.1),每次写入打散过期点,防同时过期型雪崩。 */
private final double jitterRatio;
private final String keyRule;
private final String invalidationOwner;
private final String dependsOn;
Meta(String name, Store store, Duration ttl, double jitterRatio,
String keyRule, String invalidationOwner, String dependsOn) {
this.name = name;
this.store = store;
this.ttl = ttl;
this.jitterRatio = jitterRatio;
this.keyRule = keyRule;
this.invalidationOwner = invalidationOwner;
this.dependsOn = dependsOn;
}
public String getName() {
return name;
}
public Store getStore() {
return store;
}
public Duration getTtl() {
return ttl;
}
public double getJitterRatio() {
return jitterRatio;
}
public String getKeyRule() {
return keyRule;
}
public String getInvalidationOwner() {
return invalidationOwner;
}
public String getDependsOn() {
return dependsOn;
}
}
/** 注册表本体:保持声明顺序(对账接口按此顺序输出)。不可变。 */
public static final Map<String, Meta> REGISTRY;
static {
Map<String, Meta> m = new LinkedHashMap<>();
m.put(APP_INFO, new Meta(APP_INFO, Store.REDIS, Duration.ofHours(1), 0.1,
"#id",
"ApplicationInfoCacheChangedEvent → CacheInvalidationListener",
"application_info"));
m.put(BOA_USER, new Meta(BOA_USER, Store.REDIS, Duration.ofMinutes(5), 0.1,
"'login:'+account | 'phone:'+phone | 'id:'+userId",
"UserChangedEvent → CacheInvalidationListener",
"sys_user, sys_user_group_rel, sys_user_role_rel, sys_user_current_role, sys_group"));
m.put(BOA_USER_NULL, new Meta(BOA_USER_NULL, Store.REDIS, Duration.ofSeconds(30), 0.1,
"同 boaUser 三维 key(值=哨兵对象,只判存在)",
"UserChangedEvent → CacheInvalidationListener(随真实 key 一起驱逐)",
"sys_user"));
m.put(GROUP_SNAPSHOT, new Meta(GROUP_SNAPSHOT, Store.LOCAL, Duration.ofMinutes(30), 0.1,
"固定单 key 'all'(整体驱逐+重建)",
"GroupChangedEvent → CacheInvalidationListener",
"sys_group"));
m.put(APP_KEY_TO_ID, new Meta(APP_KEY_TO_ID, Store.LOCAL, Duration.ofHours(2), 0.1,
"#appKey",
"ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)",
"application_info"));
REGISTRY = Collections.unmodifiableMap(m);
}
/** 是否本地专用缓存(FallbackCacheManager 据此路由到本地 CacheManager,永不进 Redis)。 */
public static boolean isLocalOnly(String cacheName) {
Meta meta = REGISTRY.get(cacheName);
return meta != null && meta.store == Store.LOCAL;
}
}
@@ -1,6 +1,7 @@
package com.yunhai.userbase.config;
import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor;
import com.yunhai.userbase.common.interceptor.ManageAccessInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -14,6 +15,8 @@ public class DWebMvcConfig implements WebMvcConfigurer
private BoaUserContextInterceptor boaUserContextInterceptor;
@Autowired
private OapiCallerAppInterceptor oapiCallerAppInterceptor;
@Autowired
private ManageAccessInterceptor manageAccessInterceptor;
@Override
public void addCorsMappings(CorsRegistry registry)
@@ -30,8 +33,18 @@ public class DWebMvcConfig implements WebMvcConfigurer
// oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用
registry.addInterceptor(oapiCallerAppInterceptor)
.addPathPatterns("/oapi/**");
// 用户上下文必须先于访问范围拦截器注册(后者依赖 UserContext 判定 NONE)
registry.addInterceptor(boaUserContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
// /v3/api-docs、/swagger-ui:接口文档工具链。
registry.addInterceptor(manageAccessInterceptor)
.addPathPatterns("/{platform}/**")
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
"/webjars/**");
}
}
@@ -18,11 +18,13 @@ public class MyWebAppConfigurer implements WebMvcConfigurer {
String ICON_FILE_WEB_PATH = "/upload/icon/";
String WORD_FILE_WEB_PATH = "/upload/word/";
String HTML_FILE_WEB_PATH = "/upload/html/";
String AVATAR_FILE_WEB_PATH = "/upload/avatar/";
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/");
registry.addResourceHandler(AVATAR_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/avatar/");
}
@@ -2,6 +2,7 @@ package com.yunhai.userbase.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.GroupedOpenApi;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -10,7 +11,14 @@ import org.springframework.web.method.HandlerMethod;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数,
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。<br><br>
* 文档分组:
* <ul>
* <li>{@code oapi}——开放接口(/oapi/**,不含 /oapi/internal/**),是双端 SDK(yh-platform / yh-platform-dotnet)
* 契约的权威源,可经 {@code GET /v3/api-docs/oapi} 导出 OpenAPI 3 快照入库对拍;</li>
* <li>{@code api}——业务接口(/api/**,含会话登录态,供前端联调)。</li>
* </ul>
* 生产环境建议关闭文档入口(见 application.yml 的 springdoc 注释)。
*/
@Configuration
public class SwaggerConfig {
@@ -24,6 +32,28 @@ public class SwaggerConfig {
.version("0.0.1"));
}
/**
* 开放接口分组:/oapi/** 但排除 /oapi/internal/**(内部密钥分发端点,仅内网 + IP 白名单,不对外暴露契约)。
* 导出快照:GET /v3/api-docs/oapi
*/
@Bean
public GroupedOpenApi oapiGroup() {
return GroupedOpenApi.builder()
.group("oapi")
.pathsToMatch("/oapi/**")
.pathsToExclude("/oapi/internal/**")
.build();
}
/** 业务接口分组:/api/**(会话鉴权的面向前端接口,与开放接口契约隔离)。 */
@Bean
public GroupedOpenApi apiGroup() {
return GroupedOpenApi.builder()
.group("api")
.pathsToMatch("/api/**")
.build();
}
/** 隐藏切面注入的登录用户参数,保持文档整洁。 */
@Bean
public GlobalOperationCustomizer hideAuthUserParam() {
@@ -14,6 +14,7 @@ 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.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
@@ -41,12 +42,16 @@ public class AppRoleController {
@Autowired
private IAppRoleService appRoleService;
@Autowired
private AccessScopeService accessScopeService;
/**
* 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称
*/
@GetMapping("/RoleList")
public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort)));
}
@@ -57,6 +62,7 @@ public class AppRoleController {
@OperationLog("新增角色")
@PostMapping("RoleAdd")
public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.add(request);
}
@@ -67,6 +73,7 @@ public class AppRoleController {
@PostMapping("RoleEdit/{id}")
public void roleEdit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.edit(id, request);
}
@@ -76,6 +83,7 @@ public class AppRoleController {
@OperationLog("删除角色")
@PostMapping("RoleDelete/{id}")
public void roleDelete(@PathVariable Integer id) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.delete(id);
}
@@ -85,6 +93,7 @@ public class AppRoleController {
*/
@GetMapping("/RoleModuleTree")
public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return appRoleService.findRoleModuleTree(roleId);
}
@@ -94,6 +103,7 @@ public class AppRoleController {
@OperationLog("保存角色模块授权")
@PostMapping("SaveRoleModules")
public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveRoleModules(request);
}
@@ -102,6 +112,7 @@ public class AppRoleController {
*/
@GetMapping("/RoleUsers")
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return appRoleService.findRoleUsers(roleId);
}
@@ -111,6 +122,7 @@ public class AppRoleController {
@OperationLog("保存角色用户绑定")
@PostMapping("SaveRoleUsers")
public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveRoleUsers(request);
}
@@ -120,6 +132,7 @@ public class AppRoleController {
@OperationLog("保存用户角色绑定")
@PostMapping("SaveUserRoles")
public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveUserRoles(request);
}
@@ -130,6 +143,7 @@ public class AppRoleController {
@OperationLog("移除角色用户绑定")
@PostMapping("RemoveRoleUser")
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.removeRoleUser(roleId, userId);
}
}
@@ -4,6 +4,7 @@ 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.enums.AccessLevelEnum;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
@@ -22,7 +23,10 @@ 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.SchoolAppOverrideService;
import com.yunhai.userbase.service.impl.SignKeyCacheService;
import com.yunhai.userbase.service.resolve.AccessScope;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
@@ -30,10 +34,20 @@ import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 应用管理 + 学校私有覆盖(管理端权限分级改造 §3.4)。<br>
* 访问矩阵:<br>
* - 仅超管:新增/删除应用、密钥、推送设置、模块管理、新增/删除类型;<br>
* - 超管+局端:AppAuthzList(应用开通页数据源);<br>
* - 学校管理员:AppList/AppTypeList/AppSortList/EditApp/AppSort/EditType/AppTypeSort 走覆盖表
* (仅本校已开通应用,名称/描述/图标/排序,均只对该校生效);<br>
* - 局端不开放应用维护(局端的应用动作是「开通」,在应用开通页),落到写接口一律 requireSuper。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/app/")
public class AppSettingsController {
@@ -50,67 +64,120 @@ public class AppSettingsController {
@Autowired
private SignKeyCacheService signKeyCacheService;
@Autowired
private AccessScopeService accessScopeService;
@Autowired
private SchoolAppOverrideService schoolAppOverrideService;
/** 当前 scope 是否学校管理员(覆盖表通道) */
private boolean isSchoolLevel() {
return accessScopeService.resolveCurrentScope().getLevel() == AccessLevelEnum.SCHOOL;
}
@OperationLog("新增应用类型")
@PostMapping("AddType")
public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) {
accessScopeService.requireSuper();
appCategoryService.add(request);
}
@OperationLog("编辑应用类型")
@PostMapping("EditType/{id}")
public void editType(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request) {
public void editType(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
// 学校:仅改名,写分类覆盖表(该校私有)
schoolAppOverrideService.editCategoryForSchool(id, request.getName(),
accessScopeService.resolveCurrentScope(), schoolId);
return;
}
// 主表维护仅超管(局端不开放应用管理)
accessScopeService.requireSuper();
appCategoryService.edit(id, request);
}
@OperationLog("删除应用类型")
@PostMapping("DeleteType/{id}")
public void deleteType(@PathVariable Integer id) {
accessScopeService.requireSuper();
// 级联清理该类型在所有学校的覆盖行
schoolAppOverrideService.deleteCategoryOverrides(id);
appCategoryService.delete(id);
}
@GetMapping("/AppTypeList")
public List<AppCategoryVo> appTypeList(AppCategoryQuery query) {
return appCategoryService.list(query);
public List<AppCategoryVo> appTypeList(AppCategoryQuery query,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
List<AppCategoryVo> base = appCategoryService.list(query);
if (isSchoolLevel()) {
// 合并学校私有改名/排序
return schoolAppOverrideService.mergeCategoryList(base,
accessScopeService.resolveCurrentScope(), schoolId);
}
return base;
}
@OperationLog("应用类型排序")
@PostMapping("/AppTypeSort")
public void appTypeSort(@RequestBody List<Integer> ids) {
public void appTypeSort(@RequestBody List<Integer> ids,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
schoolAppOverrideService.saveCategorySortForSchool(ids,
accessScopeService.resolveCurrentScope(), schoolId);
return;
}
accessScopeService.requireSuper();
appCategoryService.sort(ids);
}
/**
* 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。<br>
* 应用开通页数据源,仅超管+局端。
*/
@GetMapping("/AppAuthzList")
public List<AppAuthzCategoryVo> appAuthzList() {
accessScopeService.requireSuperOrBureau();
return appCategoryService.listWithApps();
}
@OperationLog("新增应用")
@PostMapping("AddApp")
public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) {
accessScopeService.requireSuper();
applicationInfoService.add(request);
}
@OperationLog("编辑应用")
@PostMapping("EditApp/{id}")
public void editApp(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request) {
public void editApp(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
// 学校:只接受 名称/描述/图标 三字段 upsert 覆盖表,appKey/回调/推送等一律不可动
schoolAppOverrideService.editAppForSchool(id, request,
accessScopeService.resolveCurrentScope(), schoolId);
return;
}
accessScopeService.requireSuper();
applicationInfoService.edit(id, request);
}
@OperationLog("删除应用")
@PostMapping("DeleteApp/{id}")
public void deleteApp(@PathVariable Integer id) {
accessScopeService.requireSuper();
// 级联清理该应用在所有学校的覆盖行
schoolAppOverrideService.deleteAppOverrides(id);
applicationInfoService.delete(id);
}
@OperationLog("重置应用密钥")
@PostMapping("ResetAppSecret/{id}")
public String resetAppSecret(@PathVariable Integer id) {
accessScopeService.requireSuper();
applicationInfoService.resetAppSecret(id);
ApplicationInfo info= applicationInfoService.getById(id);
return info.getAppSecret();
@@ -119,6 +186,7 @@ public class AppSettingsController {
@OperationLog("全量刷新应用签名密钥到Redis")
@PostMapping("RefreshSignKeys")
public Map<String, Object> refreshSignKeys() {
accessScopeService.requireSuper();
int count = signKeyCacheService.refreshAll();
Map<String, Object> result = new LinkedHashMap<>();
result.put("refreshed", count);
@@ -127,52 +195,93 @@ public class AppSettingsController {
@GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort) {
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
AccessScope scope = accessScopeService.resolveCurrentScope();
return PageUtils.toResult(
schoolAppOverrideService.listSchoolApps(query, PageUtils.toPageable(pageQuery, sort), scope, schoolId));
}
return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort)));
}
@OperationLog("应用排序")
@PostMapping("/AppSort")
public void appSort(@RequestBody List<Integer> ids) {
public void appSort(@RequestBody List<Integer> ids,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
// 学校:写覆盖表 sort_no(整校快照,仅该校生效)
schoolAppOverrideService.saveAppSortForSchool(ids,
accessScopeService.resolveCurrentScope(), schoolId);
return;
}
accessScopeService.requireSuper();
applicationInfoService.sort(ids);
}
@GetMapping("/AppSortList")
public List<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId) {
public List<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
if (isSchoolLevel()) {
// 学校:仅返回本校已开通应用(拖拽排序数据源,override 生效排序)
return schoolAppOverrideService.schoolSortList(categoryId,
accessScopeService.resolveCurrentScope(), schoolId);
}
return applicationInfoService.findListByCategoryId(categoryId);
}
/**
* 可管理学校列表(多校管理员应用管理页切换下拉用):{id, name}。<br>
* 仅 SCHOOL 级返回数据;SUPER/BUREAU 返回空数组(前端不渲染下拉)。
*/
@GetMapping("/SchoolList")
public List<Map<String, Object>> schoolList() {
accessScopeService.requireAdmin();
AccessScope scope = accessScopeService.resolveCurrentScope();
if (scope.getLevel() != AccessLevelEnum.SCHOOL) {
return Collections.emptyList();
}
return schoolAppOverrideService.manageableSchools(scope);
}
@OperationLog("新增模块")
@PostMapping("AddModule")
public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) {
accessScopeService.requireSuper();
appModuleService.add(request);
}
@OperationLog("编辑模块")
@PostMapping("EditModule/{id}")
public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) {
accessScopeService.requireSuper();
appModuleService.edit(id, request);
}
@OperationLog("删除模块")
@PostMapping("DeleteModule/{id}")
public void deleteModule(@PathVariable Integer id) {
accessScopeService.requireSuper();
appModuleService.delete(id);
}
@GetMapping("/ModuleList")
public List<AppModuleVo> moduleList(AppModuleQuery query) {
accessScopeService.requireSuper();
return appModuleService.list(query);
}
@OperationLog("保存应用推送设置")
@PostMapping("SavePushSetting/{id}")
public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) {
accessScopeService.requireSuper();
applicationInfoService.savePushSetting(id, request);
}
@GetMapping("GetPushSetting/{id}")
public PushSettingVo getPushSetting(@PathVariable Integer id) {
accessScopeService.requireSuper();
return applicationInfoService.getPushSetting(id);
}
}
@@ -1,8 +1,14 @@
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.platform.apistd.ApiResponse;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.*;
import com.yunhai.userbase.model.other.UserScopeVo;
import com.yunhai.userbase.service.resolve.AccessScope;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.*;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.web.multipart.MultipartFile;
@@ -22,6 +28,9 @@ public class BaseController {
@Autowired
private RedisUtil redisUtil;
@Autowired
private AccessScopeService accessScopeService;
/**
* 一般hashMap初始化大小
*/
@@ -38,6 +47,22 @@ public class BaseController {
return authUserWithDept;
}
/**
* 当前登录用户的管理端访问范围(四级访问控制)。<br>
* 不复用 GetUserInfo 的原因:BoaUser 是 yh-utils-core 外部类不可加字段,且 scope 规则
* (身份行 × 组类型)由后端单点计算,前端只消费结果、不重复实现判定逻辑,避免双端规则漂移。<br>
* 在 NONE 拦截器白名单内(登录即可访问),NONE 用户靠它拿到 level=4 后前端整体渲染「暂无权限」。
*/
@GetMapping("GetUserScope")
public UserScopeVo getUserScope(BoaUser authUserWithDept) {
AccessScope scope = accessScopeService.resolveCurrentScope();
UserScopeVo vo = new UserScopeVo();
vo.setLevel(scope.getLevel().getKey());
vo.setBureauGroupId(scope.getBureauGroupId());
vo.setSchoolGroupIds(scope.schoolGroupIds().isEmpty() ? null : scope.schoolGroupIds());
return vo;
}
//返回系统枚举2
@GetMapping("GetSysDict")
public Map getSysDict(BoaUser authUserWithDept){
@@ -53,11 +78,13 @@ public class BaseController {
}
/**
* 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。<br>
* 手动管理 Redis Hash 的示例(@Cacheable 注解无法满足的场景)。<br>
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<br>
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。<br>
* <b>缓存统一管理后本端点仅为 Hash 结构用法演示</b>(《userbase缓存决策方案.md》存量收敛:
* 业务缓存一律走 Spring Cache + CacheNames 注册表,RedisUtil 已 @Deprecated,勿以本端点为新缓存范本)。
*
* @return 本次演示各步骤的结果快照
*/
@@ -102,19 +129,22 @@ public class BaseController {
* 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br>
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<br>
* 返回 {@link ApiResponse}({code,msg,data},data=文件 URL):
* String 返回类型会走 StringHttpMessageConverter,被 ApiResponseAdvice 跳过包装,
* 前端 Upload 组件拿到裸字符串无法按统一结构取 data。
*
* @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) {
public ApiResponse<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);
return ApiResponse.success(FileUtil.upload(file, type));
}
/**
@@ -0,0 +1,123 @@
package com.yunhai.userbase.controller.api;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import com.yunhai.userbase.config.CacheConfig;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.config.CacheNames.Meta;
import com.yunhai.userbase.config.Config;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
*
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
* <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
* </ul>
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
* 注:Redis 侧不提供条目数/命中率(无 stats),TTL 抖动效果用 redis-cli TTL 命令核对(验收清单 8.1)。
*/
@RestController
@RequestMapping(Config.API_PATH + "/cache")
public class CacheRegistryController {
@Autowired
private CacheManager cacheManager;
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
@Autowired
@Qualifier("caffeineCacheManager")
private CaffeineCacheManager caffeineCacheManager;
/** 本地专用实例(groupSnapshot / appKeyToId,恒为权威数据源)。 */
@Autowired
@Qualifier("localCacheManager")
private CaffeineCacheManager localCacheManager;
@Operation(summary = "缓存注册表对账(声明 × 运行时 × 命中率)", operationId = "cacheRegistry")
@GetMapping("registry")
public Map<String, Object> registry() {
boolean redisUp = !(cacheManager instanceof CacheConfig.FallbackCacheManager)
|| ((CacheConfig.FallbackCacheManager) cacheManager).isRedisUp();
// 运行时实际出现的缓存名:复合管理器 = 本地专用名 + 当前活跃侧(Redis 或降级 Caffeine)名
Set<String> runtimeNames = new TreeSet<>(cacheManager.getCacheNames());
List<Map<String, Object>> caches = new ArrayList<>();
for (Meta meta : CacheNames.REGISTRY.values()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", meta.getName());
item.put("declaredStore", meta.getStore().name());
item.put("ttl", meta.getTtl().toString());
item.put("jitter", "±" + Math.round(meta.getJitterRatio() * 100) + "%");
item.put("keyRule", meta.getKeyRule());
item.put("invalidationOwner", meta.getInvalidationOwner());
item.put("dependsOn", meta.getDependsOn());
item.put("runtimePresent", runtimeNames.contains(meta.getName()));
// 命中率:本地专用缓存恒有意义;Redis 型缓存仅降级期条目落在 Caffeine,正常期为 null
item.put("caffeineStats", statsOf(meta.getName()));
caches.add(item);
}
Set<String> unregistered = new TreeSet<>(runtimeNames);
unregistered.removeAll(CacheNames.REGISTRY.keySet());
Set<String> unused = new TreeSet<>(CacheNames.REGISTRY.keySet());
unused.removeAll(runtimeNames);
Map<String, Object> result = new LinkedHashMap<>();
result.put("degraded", !redisUp);
result.put("degradedNote", redisUp ? null
: "Redis 探活失败已降级 Caffeine:Redis 侧缓存名不可见属预期,恢复后自动切回");
result.put("caches", caches);
result.put("unregisteredRuntimeCaches", unregistered);
result.put("declaredButUnusedCaches", unused);
return result;
}
/** 读取缓存对应的 Caffeine 实例统计;缓存尚未在任何 Caffeine 实例中出现时返回 null(不主动创建)。 */
private Map<String, Object> statsOf(String name) {
CaffeineCache cache = findExistingCaffeineCache(name);
if (cache == null) {
return null;
}
CacheStats stats = cache.getNativeCache().stats();
Map<String, Object> m = new LinkedHashMap<>();
m.put("hitCount", stats.hitCount());
m.put("missCount", stats.missCount());
m.put("hitRate", stats.hitRate());
m.put("evictionCount", stats.evictionCount());
m.put("estimatedSize", cache.getNativeCache().estimatedSize());
return m;
}
/** 只读查找(getCache 会按需创建空缓存,污染运行时名单,必须先判 getCacheNames)。 */
private CaffeineCache findExistingCaffeineCache(String name) {
if (localCacheManager.getCacheNames().contains(name)) {
return (CaffeineCache) localCacheManager.getCache(name);
}
if (caffeineCacheManager.getCacheNames().contains(name)) {
return (CaffeineCache) caffeineCacheManager.getCache(name);
}
return null;
}
}
@@ -14,6 +14,7 @@ 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 com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;
@@ -24,13 +25,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author chenx
* @date 2026/7/23
* @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
* 访问控制:应用开通类 = 超管+局端;组授权/权限一览 = 管理员(数据范围内组);
* HasModulePerm 保留登录即可(判定接口,自身已有超管直通)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/groupauthz/")
@@ -39,11 +44,17 @@ public class GroupAuthzController {
@Autowired
private IGroupAuthzService groupAuthzService;
@Autowired
private AccessScopeService accessScopeService;
/**
* 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。
*/
@GetMapping("/GroupAppList")
public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) {
// 应用开通动作仅超管+局端(学校无此菜单);局端目标组须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.listGroupApps(groupId);
}
@@ -53,6 +64,9 @@ public class GroupAuthzController {
@OperationLog("保存组应用开通")
@PostMapping("AssignApps")
public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) {
// 局端目标组须在本区域范围内(验收矩阵:局端仅可开通本区域学校)
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(request.getGroupId());
groupAuthzService.assignAppsToGroup(request);
}
@@ -61,6 +75,7 @@ public class GroupAuthzController {
*/
@GetMapping("/GroupModuleTree")
public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupAppsWithModules(groupId);
}
@@ -70,6 +85,7 @@ public class GroupAuthzController {
*/
@GetMapping("/GroupModuleTreeByCategory")
public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupAppsWithModulesByCategory(groupId);
}
@@ -79,6 +95,7 @@ public class GroupAuthzController {
@OperationLog("保存组模块权限")
@PostMapping("SaveModulePerms")
public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
groupAuthzService.saveModulePermissions(request);
}
@@ -95,26 +112,37 @@ public class GroupAuthzController {
*/
@GetMapping("/GroupUsers")
public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupUsers(groupId);
}
/**
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。<br>
* 非超管结果按数据范围过滤:仅返回归属组与范围有交集的用户。
*/
@GetMapping("/SearchUsers")
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
return groupAuthzService.searchUsers(keyword);
return groupAuthzService.searchUsers(keyword, accessScopeService.scopeGroupIds());
}
/**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合(只看得到本校/本区域用户)。
*/
@GetMapping("/UserPermissionTree")
public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId,
@RequestParam(required = false) Integer groupId) {
// 组上下文模式:显式传入的 groupId 也须在数据范围内(防借在范围用户推演范围外组的权限数据)
if (groupId != null) {
accessScopeService.requireGroupInScope(groupId);
}
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserPermissionTree(groupId, userId);
}
@@ -124,15 +152,24 @@ public class GroupAuthzController {
*/
@GetMapping("/UserRoles")
public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserRoles(userId);
}
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
*/
@GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserGroupTree(userId);
}
@@ -146,6 +183,9 @@ public class GroupAuthzController {
*/
@GetMapping("/GetSchoolAppId")
public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) {
// 应用开通动作仅超管+局端;局端目标节点须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(nodeId);
return groupAuthzService.findAuthorizedAppIds(nodeId);
}
@@ -159,6 +199,9 @@ public class GroupAuthzController {
@OperationLog("设置组应用开通")
@PostMapping("SetSchoolApp")
public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) {
// 应用开通动作仅超管+局端;局端目标学校须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(request.getSchid());
groupAuthzService.setSchoolApps(request);
}
@@ -174,6 +217,10 @@ public class GroupAuthzController {
@OperationLog("设置组模块权限")
@PostMapping("SetGroupModule")
public void setGroupModule(@RequestBody @Valid List<GroupModuleAuthzItem> items) {
// 增量授权逐条校验目标组均在数据范围内
accessScopeService.requireGroupsInScope(
items == null ? Collections.emptyList()
: items.stream().map(GroupModuleAuthzItem::getGroupId).collect(Collectors.toList()));
groupAuthzService.setGroupModulePerms(items);
}
}
@@ -1,62 +0,0 @@
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);
}
}
@@ -8,6 +8,7 @@ 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.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -41,9 +42,13 @@ public class PushSubscriptionController {
@Autowired
private PushRecordService pushRecordService;
@Autowired
private AccessScopeService accessScopeService;
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
@PostMapping("Test")
public Map<String, Object> test(@RequestBody PushTestRequest request) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
Map<String, Object> preview = pushDispatcher.previewManual(
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
@@ -58,12 +63,14 @@ public class PushSubscriptionController {
@GetMapping("RecordList")
public BasePageResult<PushRecordListVo> recordList(PushRecordQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort)));
}
/** 手动重试一条失败的推送记录。 */
@PostMapping("Retry/{id}")
public void retry(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
pushRecordService.retry(id);
}
@@ -74,6 +81,7 @@ public class PushSubscriptionController {
*/
@GetMapping("/Download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = pushRecordService.getPayload(id);
if (data == null) {
data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。";
@@ -10,17 +10,21 @@ 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 com.yunhai.userbase.service.resolve.AccessScopeService;
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;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author chenx
* @date 2026/7/20 16:00
* @description 系统组管理(组织架构树)
* @description 系统组管理(组织架构树)——管理端四级访问控制 guard 层(S+B+C)。<br>
* 范围裁剪与越权拦截在此层完成,Service 保持与 oapi 共享不受影响;SUPER 行为与改造前完全一致。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/sysgroup/")
@@ -29,10 +33,15 @@ public class SysGroupController {
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private AccessScopeService accessScopeService;
@OperationLog("新增组")
@Operation(operationId = "sysGroupAdd")
@PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
// pid 为 null/0 表示挂根下(仅超管);其余必须落在数据范围内(学校不可跳出本校建组)
accessScopeService.requireCreatePointInScope(request.getParentId());
sysGroupService.add(request);
}
@@ -40,6 +49,9 @@ public class SysGroupController {
@Operation(operationId = "sysGroupEdit")
@PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
accessScopeService.requireGroupInScope(id);
// SCHOOL 禁止编辑学校组本身(防改名/改类型拆校);BUREAU 禁止编辑区域组本身
accessScopeService.requireGroupModifiable(id);
sysGroupService.edit(id, request);
}
@@ -47,30 +59,41 @@ public class SysGroupController {
@Operation(operationId = "sysGroupRemove")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
// 学校组/区域组本身不可删,子组可管理
accessScopeService.requireGroupModifiable(id);
sysGroupService.delete(id);
}
@Operation(operationId = "sysGroupList")
@GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) {
return sysGroupService.list(query);
List<SysGroupVo> result = sysGroupService.list(query);
// 防御性收紧:结果按数据范围过滤(当前前端未直用本接口)
Set<Integer> scope = accessScopeService.scopeGroupIds();
if (scope == null) {
return result;
}
return result.stream().filter(vo -> scope.contains(vo.getId())).collect(Collectors.toList());
}
/**
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。<br>
* 按访问级别裁剪:SUPER 全量;BUREAU 以区域组为根的子树;SCHOOL 各学校子树(多校 = 多根数组)。
*/
@GetMapping("GetTree")
public List<SysGroupVo> getTree() {
return sysGroupService.getTree();
return sysGroupService.getTree(accessScopeService.resolveCurrentScope());
}
/**
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
* 子树中无学校组的分支整体剪掉;根节点始终保留。
* 子树中无学校组的分支整体剪掉;根节点始终保留。<br>
* BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
*/
@GetMapping("GetSchoolTree")
public List<SysGroupVo> getSchoolTree() {
return sysGroupService.getSchoolTree();
return sysGroupService.getSchoolTree(accessScopeService.resolveCurrentScope());
}
/**
@@ -79,6 +102,7 @@ public class SysGroupController {
@OperationLog("批量新增子组")
@PostMapping("BatchAdd")
public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
accessScopeService.requireCreatePointInScope(request.getPid());
sysGroupService.batchAdd(request);
}
@@ -88,15 +112,19 @@ public class SysGroupController {
@OperationLog("子组排序")
@PostMapping("SortChildren")
public void sortChildren(@Validated SysGroupSortRequest request) {
accessScopeService.requireGroupInScope(request.getPid());
sysGroupService.sortChildren(request);
}
/**
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。<br>
* moveId 与目标 pid 均须落在数据范围内(SCHOOL 级天然禁止跨校移动,与 oapi move 语义一致)。
*/
@OperationLog("移动组")
@PostMapping("Move")
public void move(@Validated SysGroupMoveRequest request) {
accessScopeService.requireGroupInScope(request.getMoveId());
accessScopeService.requireCreatePointInScope(request.getPid());
sysGroupService.move(request);
}
}
@@ -0,0 +1,83 @@
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 com.yunhai.userbase.service.resolve.AccessScopeService;
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;
@Autowired
private AccessScopeService accessScopeService;
/**
* 分页查询开放接口日志列表(不含响应结果)。
*/
@Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList")
@GetMapping("/List")
public BasePageResult<SysOapiLogListVo> list(SysOapiLogQuery query, BasePageQuery pageQuery) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
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) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
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();
}
}
}
@@ -6,6 +6,7 @@ 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 com.yunhai.userbase.service.resolve.AccessScopeService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j;
@@ -37,12 +38,16 @@ public class SysOperationLogController {
@Autowired
private ISysOperationLogService sysOperationLogService;
@Autowired
private AccessScopeService accessScopeService;
/**
* 分页查询操作日志列表(不含响应结果)。
*/
@Operation(summary = "操作日志分页查询", operationId = "operationLogList")
@GetMapping("/List")
public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
return sysOperationLogService.pageQuery(query, pageQuery);
}
@@ -54,6 +59,7 @@ public class SysOperationLogController {
@Operation(summary = "下载日志响应原文", operationId = "operationLogDownload")
@GetMapping("/Download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = sysOperationLogService.getResponseData(id);
if (data == null) {
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
@@ -15,16 +15,19 @@ 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.model.sysuser.UserIdentityVo;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageImpl;
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.Collections;
import java.util.List;
/**
@@ -39,17 +42,34 @@ public class SysUserController {
@Autowired
private ISysUserService sysUserService;
@Autowired
private AccessScopeService accessScopeService;
/** 非超管必须携带 groupId(null = 全平台导出/列表),且该组须落在数据范围内 */
private void requireQueryGroupInScope(Integer groupId) {
if (accessScopeService.resolveCurrentScope().isSuper()) {
return;
}
if (groupId == null) {
throw new DException("请先选择组织节点");
}
accessScopeService.requireGroupInScope(groupId);
}
@OperationLog("新增用户")
@PostMapping("Add")
public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
List<String> data=new ArrayList<>();
accessScopeService.requireGroupInScope(request.getGroupId());
return sysUserService.add(request);
}
@OperationLog("编辑用户")
@PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
// 关键防线:目标用户本身必须与数据范围有交集。request.groupId 只是前端上下文,
// 伪造 groupId(范围内任一组)+ 任意 id 可跨校改他人资料/重置密码(账号接管)。
accessScopeService.requireUserInScope(id);
accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.edit(id, request);
}
@@ -59,6 +79,7 @@ public class SysUserController {
@OperationLog("从组中删除用户")
@PostMapping("DeleteFromGroup/{userId}/{groupId}")
public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
sysUserService.deleteFromGroup(userId, groupId);
}
@@ -68,6 +89,7 @@ public class SysUserController {
@OperationLog("批量从组中删除用户")
@PostMapping("BatchDelete")
public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId());
}
@@ -75,6 +97,8 @@ public class SysUserController {
@OperationLog("禁用用户")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
// 入参只有 userId:校验目标用户归属组与数据范围有交集,防止跨校禁用他人账号
accessScopeService.requireUserInScope(id);
sysUserService.changeStatus(id, true);
}
@@ -82,6 +106,7 @@ public class SysUserController {
@OperationLog("启用用户")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
accessScopeService.requireUserInScope(id);
sysUserService.changeStatus(id, false);
}
@@ -89,11 +114,21 @@ public class SysUserController {
@OperationLog("批量设置用户状态")
@PostMapping("BatchChangeStatus")
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
accessScopeService.requireUsersInScope(request.getUserIds());
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
}
@GetMapping("/List")
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) {
// 前端点树节点必带 groupId;非超管 null groupId(树未选中时的首载请求)返回空页而非报错,
// 越界组拒绝(null = 全平台用户,仅超管语义)
if (!accessScopeService.resolveCurrentScope().isSuper()) {
if (query.getGroupId() == null) {
return PageUtils.toResult(new PageImpl<SysUserVo>(Collections.emptyList(),
PageUtils.toPageable(pageQuery, null), 0));
}
accessScopeService.requireGroupInScope(query.getGroupId());
}
// 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制
// (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null)));
@@ -104,13 +139,18 @@ public class SysUserController {
@PostMapping("ChangeSortNo/{userId}")
public void changeSortNo(@PathVariable Integer userId,
@RequestBody @Validated ChangeSortNoRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo());
}
@OperationLog("调整组")
@PostMapping("ChangeGroups")
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) {
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds());
// 目标组全部须落在数据范围内(学校不可把用户调出本校);
// 被移动用户本身也须在范围内(防把外校用户拉进本校)
accessScopeService.requireUsersInScope(request.getUserIds());
accessScopeService.requireGroupsInScope(request.getGroupIds());
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds(), request.getMainGroupId());
}
@GetMapping("Groups/{userId}")
@@ -118,9 +158,20 @@ public class SysUserController {
return sysUserService.findGroupIdsByUserId(userId);
}
/**
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
* 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
*/
@GetMapping("UserIdentity")
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return sysUserService.getUserIdentity(userId, groupId);
}
@OperationLog("批量导入用户")
@PostMapping("BatchImport")
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
// 涉及的全部组 id ∈ scope 的校验在 Service 解析 Excel 后逐行执行(越界行按行失败上报)
return sysUserService.batchImport(file);
}
@@ -130,6 +181,7 @@ public class SysUserController {
@GetMapping("DownloadTemplate")
public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId,
HttpServletResponse response) throws IOException {
requireQueryGroupInScope(groupId);
sysUserService.downloadImportTemplate(groupId, response);
}
@@ -142,6 +194,7 @@ public class SysUserController {
@RequestParam(value = "withSub", defaultValue = "1") Integer withSub,
@RequestParam(value = "name", required = false) String name,
HttpServletResponse response) throws IOException {
requireQueryGroupInScope(groupId);
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);
}
}
@@ -4,6 +4,7 @@ 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;
@@ -78,7 +79,7 @@ public class GroupController {
}
/**
* 编辑组(不含移动,移动请走 group/move 后台接口):返回更新后的组节点(含 id)。
* 编辑组(不含移动,移动请走 group/move/{schoolId} 开放接口或后台 Move):返回更新后的组节点(含 id)。
*/
@PostMapping("edit/{id}")
public BoaGroup edit(@PathVariable Integer id,
@@ -102,4 +103,14 @@ public class GroupController {
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,93 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest;
import com.yunhai.userbase.service.impl.IdentityService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 用户身份开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/identity/...})。<br>
* <br>
* 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName),
* 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。<br>
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;<br>
* <b>切换(switch)</b>额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门),
* 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。<br>
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
*
* @author chenx
* @date 2026/8/27
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "identity/")
public class IdentityController {
@Autowired
private IdentityService identityService;
/** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */
@Value("${yh.identity.switch-allowed-app-keys:}")
private String switchAllowedAppKeys;
/**
* 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析);
* 无任何身份返回 null。
*/
@GetMapping("current")
public BoaRoleInfo current(@RequestParam("userId") Integer userId) {
return identityService.getCurrent(userId);
}
/**
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。
*/
@GetMapping("available")
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
return identityService.listAvailable(userId);
}
/**
* 切换身份:AppKey 白名单校验后,校验三元组合法并 UPSERT sys_user_current_role。
*/
@PostMapping("switch")
public BoaRoleInfo switchIdentity(@RequestBody @Validated IdentitySwitchOapiRequest request) {
checkSwitchAllowed();
return identityService.switchIdentity(request.getUserId(), request.getRoleId(), request.getSchoolId());
}
/** AppKey 级闸门:仅允许白名单内的应用替用户切换身份。 */
private void checkSwitchAllowed() {
Integer callerAppKey = CallerAppContext.getAppKey();
if (callerAppKey == null || !parseAllowedAppKeys().contains(callerAppKey)) {
throw new DException("当前应用无权调用身份切换接口");
}
}
private Set<Integer> parseAllowedAppKeys() {
if (switchAllowedAppKeys == null || switchAllowedAppKeys.trim().isEmpty()) {
return new HashSet<>();
}
return Arrays.stream(switchAllowedAppKeys.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::valueOf)
.collect(Collectors.toSet());
}
}
@@ -1,17 +1,24 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.apistd.ApiResponse;
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.annotation.OapiLog;
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.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
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.model.sysuser.SysUserTransferSchoolRequest;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.service.impl.UserCenterService;
@@ -26,6 +33,7 @@ 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 org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
@@ -58,7 +66,7 @@ public class UserController {
private IGroupAuthzService groupAuthzService;
@Autowired
private ApplicationInfoRepository applicationInfoRepository;
private IApplicationInfoService applicationInfoService;
@GetMapping("byId")
public BoaUser byId(@RequestParam("id") Integer id) {
@@ -94,7 +102,8 @@ public class UserController {
*
* @param request 用于取 AppAuthFilter 设置的 appKey
* @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;为 null 时按"主学校→主归属组"规则推断)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/
@GetMapping("modules")
@@ -112,7 +121,7 @@ public class UserController {
}
/**
* 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id。<br>
* 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。<br>
* 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。
*/
private Integer resolveCallerAppId(HttpServletRequest request) {
@@ -134,13 +143,13 @@ public class UserController {
log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
if (app == null) {
Integer appId = applicationInfoService.findIdByAppKey(appKey);
if (appId == null) {
log.warn("[oapi:user:modules] appKey={} not found in application_info, uri={}", appKey,
request.getRequestURI());
return null;
}
return app.getId();
return appId;
}
@@ -209,4 +218,96 @@ public class UserController {
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());
}
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。<br>
* <b>注意必须显式返回 {@link ApiResponse} 信封</b>:若直接返回 String,
* ApiResponseAdvice 会跳过包装(String 走 StringHttpMessageConverter 无法套壳),
* 响应变成裸文本 URL,调用方(yh-openapi-client)按 JSON 解析会报 deserialize failed。
*/
@PostMapping("profilePicture")
public ApiResponse<String> profilePicture(@RequestParam("userId") Integer userId,
@RequestParam("file") MultipartFile file) {
return ApiResponse.success(sysUserService.changeProfilePicture(userId, file));
}
/**
* 设置用户在指定组上的状态(转出/恢复)。<br>
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。
* 仅允许在同一学校(组)范围内操作——groupId 必须属于 schoolId 子树且不等于 schoolId。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("groupState")
public void groupState(@RequestBody @Validated SysUserGroupStateRequest request) {
sysUserService.changeUserGroupState(request.getUserId(), request.getGroupId(),
request.getSchoolId(), request.getState());
}
/**
* 查询用户在指定组上的状态:0=正常,1=转出。<br>
* 边界与设置接口一致(groupId 必须属于 schoolId 子树且不等于 schoolId);
* 用户不在此组时返回业务错误(code=5)。
*/
@OapiLog(value = "查询用户组状态", saveResponse = false)
@GetMapping("groupState")
public Integer groupState(@RequestParam("userId") Integer userId,
@RequestParam("groupId") Integer groupId,
@RequestParam("schoolId") Integer schoolId) {
return sysUserService.getUserGroupState(userId, groupId, schoolId);
}
/**
* 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。<br>
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);<br>
* 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。<br>
* 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留);
* 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("transferToSchool")
public BoaUser transferToSchool(@RequestBody @Validated SysUserTransferSchoolRequest request) {
sysUserService.transferUserToSchool(request.getUserId(), request.getFromSchoolId(),
request.getSchoolId(), request.getGroupId());
return userCenterService.getUserInfoWithDeptById(request.getUserId());
}
/**
* 设置单个用户状态(启用/禁用账号,语义同管理端 sysuser/Enable、sysuser/Disable 的合并版)。<br>
* disabled=true 禁用、false 启用;变更后自动推送「编辑用户」订阅。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("changeStatus")
public void changeStatus(@RequestBody @Validated SysUserChangeStatusRequest request) {
sysUserService.changeStatus(request.getUserId(), request.getDisabled());
}
/**
* 批量设置用户状态(启用/禁用,语义同管理端 sysuser/BatchChangeStatus)。<br>
* 任一 userId 无效则整体拒绝;变更后自动推送「编辑用户」订阅。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("batchChangeStatus")
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
}
}
@@ -64,8 +64,7 @@ public class ApplicationInfo extends BaseEntity {
//应用前端类型
@Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "clients", columnDefinition = "json")
@Column(name = "clients", columnDefinition = "TEXT")
@Column(name = "clients", columnDefinition = "json")
private List<Integer> clients;
/**
@@ -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,64 @@
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;
/**
* 学校应用私有覆盖表:学校管理员对「本校已开通应用」的私有别名(名称/描述/图标/排序)。<br>
* 为什么不改 application_info 加 school_id 多行:appKey 是全局身份(oapi 签名 / 推送订阅 /
* 应用全链路引用),按学校插行要么复制 appKey 破坏唯一性、要么新发 appKey 导致一个应用裂变,
* 覆盖表对主数据零侵入。override 语义即「别名」,主表改名不自动同步属预期行为;
* 删除应用时级联清理本表覆盖行。
*
* @author chenx
* @date 2026/9/4
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_school_app_override",
indexes = {
@Index(name = "idx_school_app_override_app", columnList = "app_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_school_app", columnNames = {"school_id", "app_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysSchoolAppOverride extends BaseEntity {
/** 学校组ID(逻辑外键 → SysGroup.id,且组类型为学校组) */
@NotNull
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 应用ID(逻辑外键 → ApplicationInfo.id,该应用须为本校已开通应用) */
@NotNull
@Column(name = "app_id", nullable = false)
private Integer appId;
/** 应用别名(null = 沿用主表名称) */
@Column(name = "name", length = 64)
private String name;
/** 应用描述覆盖(null = 沿用主表描述) */
@Column(name = "description", length = 255)
private String description;
/** PC 图标覆盖(null = 沿用主表 pcIcon) */
@Column(name = "icon", length = 255)
private String icon;
/** 排序号覆盖(null = 沿用主表 sortNo;学校排序快照按 1..n 写入) */
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -0,0 +1,53 @@
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;
/**
* 学校应用类型(分类)私有覆盖表:学校管理员对应用类型的私有改名 + 排序,仅对该校生效。<br>
* 与 {@link SysSchoolAppOverride} 同语义;删除应用类型时级联清理覆盖行。
*
* @author chenx
* @date 2026/9/4
*/
@NoArgsConstructor
@Data
@Entity
@Table(
name = "sys_school_category_override",
indexes = {
@Index(name = "idx_school_category_override_cat", columnList = "category_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_school_category", columnNames = {"school_id", "category_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysSchoolCategoryOverride extends BaseEntity {
/** 学校组ID(逻辑外键 → SysGroup.id,且组类型为学校组) */
@NotNull
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 应用类型ID(逻辑外键 → AppCategory.id) */
@NotNull
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 类型别名(null = 沿用主表名称) */
@Column(name = "name", length = 64)
private String name;
/** 排序号覆盖(null = 沿用主表 sortNo;学校排序快照按 1..n 写入) */
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -52,8 +52,7 @@ public class SysUser extends BaseEntity {
* 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3]
*/
@Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "roles", columnDefinition = "json")
@Column(name = "roles",columnDefinition = "TEXT")
@Column(name = "roles", columnDefinition = "json")
private List<Integer> roles;
// 一对多:关联用户分组中间表,实现多对多业务
@@ -37,7 +37,7 @@ public class SysUserCurrentRole {
private Integer userId;
/**
* 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null)
* 用户切换选中的身份归属学校(恒为真实组 id:学校组/区域组/根直属=根节点 id;原「区域/学段/根直属=0或null」约定废弃)
*/
private Integer schoolId;
/**
@@ -4,6 +4,9 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import com.yunhai.userbase.common.converter.UserGroupRelStatusEnumConverter;
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
@@ -36,6 +39,14 @@ public class SysUserGroupRel {
@Column(name = "sort_no")
private Integer sortNo;
/**
* 组内状态:0=正常 1=转出(存量数据 NULL 视为正常)。<br>
* 转出仅作标记,不影响成员查询、组织链与下游推送;
* 改组/移动经 changeGroups 覆盖式重建关系行时状态随用户继承(同组照抄旧状态,新增组在转出流程中继承转出)。
*/
@Convert(converter = UserGroupRelStatusEnumConverter.class)
private UserGroupRelStatusEnum status;
private LocalDateTime createTime;
// 关联用户,仅查询,不维护外键
@@ -51,17 +51,13 @@ public class SysUserRoleRel {
private Integer roleId;
/**
* 学校组ID(=type=SCHOOL_GROUP 的 sys_group.id);区域/学段/根直属用户填 0
* 身份归属学校(恒为真实组 id:学校组 / 区域组 / 根直属=根节点 id)。
* 由祖先链解析(见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId});
* 原注释「区域/学段/根=0」约定已废弃。
*/
@Column(nullable = false)
private Integer schoolId;
/**
* 是否主身份标记:每 userId 最多 1 条 isMain=1(应用层校验,不靠 uk)
*/
@Column(nullable = false)
private Integer isMain = 0;
/**
* 排序号:越小越靠前
*/
@@ -0,0 +1,36 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/9/4
* @description 管理端访问级别(四级访问控制,判定优先级 SUPER &gt; SCHOOL &gt; BUREAU &gt; NONE)。<br>
* key 与前端 UserRoleEnum 对齐(1/2/3/4),GetUserScope 直接下发该数值。<br>
* 判定规则权威实现见 {@code AccessScopeService}(方案《管理端权限分级改造方案》§1)。
*/
@Getter
@AllArgsConstructor
public enum AccessLevelEnum {
SUPER(1, "超级管理员"),
BUREAU(2, "局端账户"),
SCHOOL(3, "学校管理员"),
NONE(4, "普通用户");
private Integer key;
private String value;
public static AccessLevelEnum getByKey(Integer key) {
if (key == null) {
return null;
}
for (AccessLevelEnum e : values()) {
if (e.key.equals(key)) {
return e;
}
}
return null;
}
}
@@ -1,35 +0,0 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/07/13
* @description 客户端来源
*/
@Getter
@AllArgsConstructor
public enum ClientEnum implements IEnum<Integer> {
PC_BROWSER(1, "Pc浏览器"),
MOBILE_APP(2, "移动App"),
SYSTEM_MODULE(4, "系统模块"),
WECHAT_OFFICIAL(8, "微信公众号"),
WORK_WECHAT(16, "企业微信"),
DING_TALK(32, "钉钉"),
STUDENT_DEVICE(64, "学伴机");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -1,65 +0,0 @@
package com.yunhai.userbase.enums;
import com.yunhai.userbase.utils.EnumUtils;
import java.util.List;
import java.util.Map;
/**
* 统一枚举规范接口,仅定义契约getKey/getValue
* 内置静态工具方法,业务直接 IEnum.xxx(枚举类) 调用
* @param <T> key类型
*/
public interface IEnum<T> {
/** 获取编码key */
T getKey();
/** 获取中文展示value */
String getValue();
// ====================== 静态工具入口(统一对外调用) ======================
/** 获取枚举全部实例列表 */
static <E extends Enum<E> & IEnum<?>> List<E> toList(Class<E> clazz) {
return EnumUtils.toList(clazz);
}
/** 根据key获取枚举,无匹配返回null */
static <E extends Enum<E> & IEnum<?>> E getByKey(Class<E> clazz, Object key) {
return EnumUtils.getEnumByKey(clazz, key);
}
/** 根据key严格获取,不存在抛异常(对应fromKey) */
static <E extends Enum<E> & IEnum<?>> E fromKey(Class<E> clazz, Object key) {
return EnumUtils.fromKey(clazz, key);
}
/** 根据key获取中文value,无匹配返回空串 */
static <E extends Enum<E> & IEnum<?>> String getValueByKey(Class<E> clazz, Object key) {
return EnumUtils.getValue(clazz, key);
}
/** 根据中文value获取key字符串,无匹配返回空串 */
static <E extends Enum<E> & IEnum<?>> String getKeyByValue(Class<E> clazz, String value) {
return EnumUtils.getKey(clazz, value);
}
/** 判断key是否存在 */
static <E extends Enum<E> & IEnum<?>> boolean existsKey(Class<E> clazz, Object key) {
return EnumUtils.isExistKey(clazz, key);
}
/** 判断value是否存在 */
static <E extends Enum<E> & IEnum<?>> boolean existsValue(Class<E> clazz, String value) {
return EnumUtils.isExistValue(clazz, value);
}
/** 转完整Map列表(包含enum实例、name、key、value) */
static <E extends Enum<E> & IEnum<?>> List<Map<String, Object>> toFullMapList(Class<E> clazz) {
return EnumUtils.toMapList(clazz);
}
/** 转下拉简易Map列表,自定义key/value字段名 */
static <E extends Enum<E> & IEnum<?>> List<Map<String, Object>> toSimpleMapList(Class<E> clazz, String keyMapName, String valueMapName) {
return EnumUtils.toMapList(clazz, keyMapName, valueMapName);
}
}
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -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/8/26
* @description 用户-组关联状态(sys_user_group_rel.status):0=正常,1=转出。<br>
* 区别于全局启用/禁用({@link StatusEnum},挂 sys_user 上),本枚举表达用户在<b>某个组上</b>的状态;
* 转出仅作关系标记,不影响成员查询、组织链与下游推送。
*/
@Getter
@AllArgsConstructor
public enum UserGroupRelStatusEnum implements IEnum<Integer> {
NORMAL(0, "正常"),
TRANSFERRED_OUT(1, "转出");
private Integer key;
private String value;
@Override
public Integer getKey() {
return this.key;
}
@Override
public String getValue() {
return this.value;
}
}
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -0,0 +1,26 @@
package com.yunhai.userbase.event;
import org.springframework.context.ApplicationEvent;
/**
* 组域数据变更事件:sys_group 任意写操作(add/batchAdd/edit/move/delete/sortChildren)事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 驱逐 {@code groupSnapshot} 本地快照
* (下次读取 sync=true 排队重建),并整清 {@code boaUser}(BoaUser 组织链内嵌组名/组类型,
* 组改名后 5min TTL 内的旧值不可接受,组写频次极低、clear 成本可忽略)。<br>
* <b>粒度</b>:单一事件类型——对所有消费方语义均为「组数据变了,重建」,将来出现需要精确到组的缓存再细化。<br>
* 注意与 {@link DataSyncEvent}(下游推送)互不影响:推送面向外部应用,本事件面向本机缓存失效。
*/
public class GroupChangedEvent extends ApplicationEvent {
/** 触发本次变更的写口(add/batchAdd/edit/move/delete/sortChildren),仅用于日志定位。 */
private final String sourceOp;
public GroupChangedEvent(Object source, String sourceOp) {
super(source);
this.sourceOp = sourceOp;
}
public String getSourceOp() {
return sourceOp;
}
}
@@ -0,0 +1,48 @@
package com.yunhai.userbase.event;
import org.springframework.context.ApplicationEvent;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 用户域数据变更事件:sys_user / sys_user_group_rel / sys_user_role_rel / sys_user_current_role
* 任意写操作(新增/编辑/换组/身份切换/禁用/跨校转入/密码/头像/删除等)事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 按 {@code id:userId} 精确驱逐 boaUser 缓存,
* 并回查当前 account/phone 驱逐 {@code login:}/{@code phone:} 维度 key(含空值哨兵缓存)。<br>
* <b>硬约束</b>:身份切换(IdentityService.switchIdentity)后 boaUser.currentRole 必须立即失效,
* 否则用户以旧身份操作(权限事故级)。<br>
* <b>旧 key 提示</b>:改账号/改手机号场景,DB 里已查不到旧值,发布方须把旧 key(如
* {@code login:oldAccount})放入 {@link #staleKeyHints},监听器据此精确驱逐,避免旧 key 残留至 TTL。
*/
public class UserChangedEvent extends ApplicationEvent {
/** 发生变更的用户ID列表。 */
private final List<Integer> userIds;
/**
* 无法由 userId 回查的旧维度 key 提示(带前缀,如 "login:zhangsan01"、"phone:138...")。
* 典型场景:edit 改账号、删除用户(sys_user 行已删,account/phone 无处可查)。
*/
private final Set<String> staleKeyHints;
public UserChangedEvent(Object source, Collection<Integer> userIds, Collection<String> staleKeyHints) {
super(source);
this.userIds = userIds == null ? Collections.emptyList() : Collections.unmodifiableList(
new java.util.ArrayList<>(userIds));
this.staleKeyHints = staleKeyHints == null || staleKeyHints.isEmpty()
? Collections.emptySet()
: Collections.unmodifiableSet(new LinkedHashSet<>(staleKeyHints));
}
public List<Integer> getUserIds() {
return userIds;
}
public Set<String> getStaleKeyHints() {
return staleKeyHints;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
/**
* [LOADTEST-METRICS] 埋点装配:注册 oapi 指标过滤器。<br>
* <b>顺序说明(与方案 §2.1 的差异)</b>:方案原文写 +12(RequireAppSignFilter 之后),
* 但 +11 的 401 短路发生在 chain.doFilter 之前,+12 根本不会执行——
* 「401 验签失败也能统计」这一目标就落空了。故取 +10(与 AppAuthFilter 同级、
* 先于 +11 闸门):401 请求全量可统计;appKey 在 AppAuth 先执行时取权威 attr,
* 同序竞争落后时降级 header(签名场景两者取值一致,无实际偏差)。
*/
@Configuration
public class LoadTestMetricsConfig {
@Bean
public FilterRegistrationBean<OapiMetricsFilter> oapiMetricsFilter(OapiMetricsCollector collector) {
FilterRegistrationBean<OapiMetricsFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new OapiMetricsFilter(collector));
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
bean.setName("oapiMetricsFilter");
return bean;
}
}
@@ -0,0 +1,142 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* [LOADTEST-METRICS] oapi 指标内存累加器(方案 §2.2)。<br>
* 按 (uri, method, appKey, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加,
* 不走队列、不做同步 IO——压测高并发下不丢数据、不影响吞吐。<br>
* 每 60s 定时快照落库 oapi_metrics(getAndSet 原子清零);落库失败把数值加回,
* 最多顺延到下个窗口,不静默丢失。<br>
* Nacos 可下发 oapi.metrics.enabled=false 整体停用(Filter 变纯透传,定时器跳过)。
*/
@Slf4j
@Component
public class OapiMetricsCollector {
/** 聚合维度 key(uri×method×appKey×压测标记),基数 = 路由数×调用方数,预估数百级 */
private static final class Dim {
final String uri;
final String method;
final String appKey;
final boolean loadTest;
Dim(String uri, String method, String appKey, boolean loadTest) {
this.uri = uri;
this.method = method;
this.appKey = appKey;
this.loadTest = loadTest;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Dim)) {
return false;
}
Dim d = (Dim) o;
return loadTest == d.loadTest && uri.equals(d.uri) && method.equals(d.method) && appKey.equals(d.appKey);
}
@Override
public int hashCode() {
return Objects.hash(uri, method, appKey, loadTest);
}
}
/** 单维度三指标:次数 / 耗时累计 / 错误数 */
private static final class Counter {
final AtomicLong cnt = new AtomicLong();
final AtomicLong cost = new AtomicLong();
final AtomicLong err = new AtomicLong();
}
private final ConcurrentHashMap<Dim, Counter> counters = new ConcurrentHashMap<>();
@Autowired
private OapiMetricsRepository repository;
/** 埋点总开关(Nacos 可下发紧急关停) */
@Value("${oapi.metrics.enabled:true}")
private boolean enabled;
/** Filter 层调用:请求结束(含异常)后记录一笔 */
public void record(String uri, String method, String appKey, long costMs, boolean error, boolean loadTest) {
if (!enabled) {
return;
}
Counter c = counters.computeIfAbsent(new Dim(uri, method, appKey, loadTest), k -> new Counter());
c.cnt.incrementAndGet();
c.cost.addAndGet(costMs);
if (error) {
c.err.incrementAndGet();
}
}
/** 快照窗口(ms),对齐方案 60s;仅本地/紧急调整用,常量语义 */
@Scheduled(fixedDelayString = "${oapi.metrics.flush-interval-ms:60000}")
public void flush() {
if (!enabled || counters.isEmpty()) {
return;
}
LocalDateTime sliceTime = LocalDateTime.now();
List<OapiMetricsEntity> rows = new ArrayList<>();
List<Object[]> snapshot = new ArrayList<>(counters.size());
for (ConcurrentHashMap.Entry<Dim, Counter> e : counters.entrySet()) {
long cnt = e.getValue().cnt.getAndSet(0);
long cost = e.getValue().cost.getAndSet(0);
long err = e.getValue().err.getAndSet(0);
if (cnt <= 0) {
continue;
}
snapshot.add(new Object[]{e.getKey(), cnt, cost, err});
OapiMetricsEntity row = new OapiMetricsEntity();
row.setStatTime(sliceTime);
row.setUri(e.getKey().uri);
row.setMethod(e.getKey().method);
row.setAppKey(e.getKey().appKey);
row.setCnt(cnt);
row.setTotalCost(cost);
row.setErrCnt(err);
row.setIsLoadTest(e.getKey().loadTest ? 1 : 0);
rows.add(row);
}
if (rows.isEmpty()) {
return;
}
try {
repository.saveAll(rows);
} catch (Exception ex) {
// 落库失败:数值加回内存,并入下个窗口,不静默丢数
log.warn("[LOADTEST-METRICS] oapi_metrics 落库失败,{} 行并入下个窗口: {}", rows.size(), ex.getMessage());
for (Object[] s : snapshot) {
Dim dim = (Dim) s[0];
Counter c = counters.computeIfAbsent(dim, k -> new Counter());
c.cnt.addAndGet((Long) s[1]);
c.cost.addAndGet((Long) s[2]);
c.err.addAndGet((Long) s[3]);
}
}
}
}
@@ -0,0 +1,150 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一(方案 §2.3)
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import com.yunhai.userbase.config.Config;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* [LOADTEST-METRICS] oapi 热度报告查询(方案 §2.3)。<br>
* 默认只统计线上流量(is_load_test=0),压测流量单独给合计数,两者严格隔离。<br>
* 例:GET /api/oapimetrics/TopN?dimension=both&amp;days=7&amp;top=10
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/oapimetrics/")
public class OapiMetricsController {
@Autowired
private OapiMetricsRepository repository;
/**
* TopN 热度报告:Top 接口 + appKey 分层分布。
*/
@Operation(summary = "[LOADTEST-METRICS] oapi热度TopN报告", operationId = "oapiMetricsTopN")
@GetMapping("/TopN")
public ReportVo topN(
@RequestParam(value = "dimension", defaultValue = "both") String dimension,
@RequestParam(value = "days", defaultValue = "1") Integer days,
@RequestParam(value = "top", defaultValue = "10") Integer top) {
int d = clamp(days == null ? 1 : days, 1, 30);
int n = clamp(top == null ? 10 : top, 1, 100);
LocalDateTime since = LocalDateTime.now().minusDays(d);
ReportVo vo = new ReportVo();
vo.setGeneratedAt(LocalDateTime.now().toString());
vo.setDays(d);
vo.setTop(n);
Object[] total = repository.aggregateTotal(since, 0);
vo.setTotalCnt(asLong(total, 0));
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
vo.setTotalErrCnt(asLong(total, 2));
if ("uri".equals(dimension) || "both".equals(dimension)) {
List<UriRow> rows = new ArrayList<>();
for (Object[] r : repository.aggregateByUri(since, 0)) {
rows.add(toUriRow(r));
if (rows.size() >= n) {
break;
}
}
vo.setTopUris(rows);
}
if ("appkey".equals(dimension) || "both".equals(dimension)) {
List<AppKeyRow> rows = new ArrayList<>();
for (Object[] r : repository.aggregateByAppKey(since, 0)) {
AppKeyRow row = new AppKeyRow();
row.setAppKey((String) r[0]);
row.setCnt(asLong(r, 1));
row.setAvgCostMs(avg(asLong(r, 1), asLong(r, 2)));
row.setErrCnt(asLong(r, 3));
rows.add(row);
if (rows.size() >= n) {
break;
}
}
vo.setAppKeys(rows);
}
// 压测流量合计(单列口径,不与线上热度混算)
Object[] lt = repository.aggregateTotal(since, 1);
vo.setLoadTestCnt(asLong(lt, 0));
return vo;
}
private UriRow toUriRow(Object[] r) {
UriRow row = new UriRow();
row.setUri((String) r[0]);
row.setMethod((String) r[1]);
row.setCnt(asLong(r, 2));
row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3)));
row.setErrCnt(asLong(r, 4));
return row;
}
private long asLong(Object[] arr, int i) {
if (arr == null || i >= arr.length || arr[i] == null) {
return 0L;
}
return ((Number) arr[i]).longValue();
}
private double avg(long cnt, long total) {
return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0;
}
private int clamp(int v, int min, int max) {
return v < min ? min : (v > max ? max : v);
}
/** 报告 VO(嵌套声明,压测结束随包整体删除) */
@Data
public static class ReportVo {
private String generatedAt;
private Integer days;
private Integer top;
/** 线上总请求数 */
private Long totalCnt;
private Double totalAvgCostMs;
private Long totalErrCnt;
/** Top 接口热度 */
private List<UriRow> topUris;
/** appKey 分层分布 */
private List<AppKeyRow> appKeys;
/** 压测流量请求数(is_load_test=1 单列,不混入上面各项) */
private Long loadTestCnt;
}
@Data
public static class UriRow {
private String uri;
private String method;
private Long cnt;
private Double avgCostMs;
private Long errCnt;
}
@Data
public static class AppKeyRow {
private String appKey;
private Long cnt;
private Double avgCostMs;
private Long errCnt;
}
}
@@ -0,0 +1,77 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import lombok.Data;
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;
/**
* [LOADTEST-METRICS] oapi 接口访问频次指标(60s 切片聚合)。<br>
* 区别于 sys_oapi_log(逐请求审计日志):本表是方案 §2.2 的轻量统计层——
* Filter 层内存累加、定时快照,GET/401 全覆盖,不丢数据、不膨胀。<br>
* 表结构由 ddl-auto=update 自动创建;生产 DBA 手工建表脚本见
* resources/sql/oapi_metrics_loadtest.sql。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "oapi_metrics", indexes = {
@Index(name = "idx_time", columnList = "stat_time"),
@Index(name = "idx_appkey_time", columnList = "app_key, stat_time")
})
public class OapiMetricsEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 统计切片时间(该 60s 窗口的结束时刻) */
@Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME")
private LocalDateTime statTime;
/** 规范化路由(去 query、纯数字路径段归一为 {id}),最长 200 */
@Column(name = "uri", nullable = false, length = 200)
private String uri;
/** GET/POST/PUT/DELETE */
@Column(name = "method", nullable = false, length = 10)
private String method;
/** 调用方 appKey:验签成功为权威值,未验签(401)为 X-App-Key header 原值,无 header 为 "-" */
@Column(name = "app_key", nullable = false, length = 32)
private String appKey;
/** 窗口内请求次数 */
@Column(name = "cnt", nullable = false)
private Long cnt;
/** 窗口内耗时累计 ms */
@Column(name = "total_cost", nullable = false)
private Long totalCost;
/** 窗口内 HTTP 非 200 次数(Filter 层口径,不含响应体 code:5 细化) */
@Column(name = "err_cnt", nullable = false)
private Long errCnt;
/** 压测流量单列:请求头 X-Load-Test: 1 → 1,不污染线上热度 */
@Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)")
private Integer isLoadTest;
}
@@ -0,0 +1,138 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一(方案 §2.1)
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import com.yunhai.platform.appauth.AppAuthFilter;
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;
/**
* [LOADTEST-METRICS] oapi 访问频次采集过滤器。<br>
* 只在 Filter 层记 HTTP 状态(方案 §2.1),不解响应体;业务错误(HTTP 200 + code:5)
* 细化继续由 sys_oapi_log 补充分析。<br>
* 注册见 {@link LoadTestMetricsConfig}(order=HIGHEST_PRECEDENCE+10,先于 401 闸门,
* 保证 401 验签失败请求也能统计)。appKey 取值优先级:<br>
* 1. {@code AppAuthFilter.APP_AUTH_ATTR}(验签后权威值);<br>
* 2. 降级 {@code X-App-Key} header(401 请求,标记未验签的 header 原值);<br>
* 3. 均无 → "-"(无签名头,将被 RequireAppSignFilter 401)。
*/
public class OapiMetricsFilter implements Filter {
/** 压测流量标记头(方案 §2.4,k6 全部请求携带) */
public static final String HEADER_LOAD_TEST = "X-Load-Test";
/** appKey 降级取值的 header(yh-app-auth 签名三件套之一) */
public static final String HEADER_APP_KEY = "X-App-Key";
private static final int MAX_URI_LEN = 200;
private static final int MAX_APP_KEY_LEN = 32;
private final OapiMetricsCollector collector;
public OapiMetricsFilter(OapiMetricsCollector collector) {
this.collector = collector;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = normalizeUri(req);
String method = req.getMethod();
String appKey = resolveAppKey(req);
boolean loadTest = isLoadTest(req);
long start = System.currentTimeMillis();
boolean error = false;
try {
chain.doFilter(request, response);
error = res.getStatus() != 200;
} catch (IOException | ServletException e) {
error = true;
throw e;
} finally {
collector.record(uri, method, appKey, System.currentTimeMillis() - start, error, loadTest);
}
}
/**
* 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id}
* (如 /oapi/user/edit/123 → /oapi/user/edit/{id}),防止 PathVariable 撑爆维度基数。
*/
private String normalizeUri(HttpServletRequest req) {
String uri = req.getRequestURI();
String ctx = req.getContextPath();
if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) {
uri = uri.substring(ctx.length());
}
if (uri.indexOf('/') >= 0) {
String[] segs = uri.split("/");
StringBuilder sb = new StringBuilder(uri.length());
for (String seg : segs) {
if (seg.length() > 0 && isDigits(seg)) {
seg = "{id}";
}
sb.append('/').append(seg);
}
uri = sb.toString();
}
if (uri.length() > MAX_URI_LEN) {
uri = uri.substring(0, MAX_URI_LEN);
}
return uri;
}
private boolean isDigits(String s) {
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
/** appKey:验签 attr 权威值 → X-App-Key header(401 未验签)→ "-" */
private String resolveAppKey(HttpServletRequest req) {
Object attr = req.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr != null) {
String v = String.valueOf(attr).trim();
if (!v.isEmpty()) {
return truncate(v, MAX_APP_KEY_LEN);
}
}
String header = req.getHeader(HEADER_APP_KEY);
if (header != null && !header.trim().isEmpty()) {
return truncate(header.trim(), MAX_APP_KEY_LEN);
}
return "-";
}
/** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */
private boolean isLoadTest(HttpServletRequest req) {
String v = req.getHeader(HEADER_LOAD_TEST);
if (v == null || v.trim().isEmpty()) {
return false;
}
v = v.trim();
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
}
private String truncate(String s, int max) {
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -0,0 +1,37 @@
package com.yunhai.userbase.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
/** [LOADTEST-METRICS] oapi_metrics 聚合查询(TopN 报告用)。 */
public interface OapiMetricsRepository extends JpaRepository<OapiMetricsEntity, Long> {
/** 按 uri+method 聚合(热度 TopN),按总次数降序 */
@Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest "
+ "group by e.uri, e.method order by sum(e.cnt) desc")
List<Object[]> aggregateByUri(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
/** 按 appKey 聚合(调用方分层分布),按总次数降序 */
@Query("select e.appKey, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest "
+ "group by e.appKey order by sum(e.cnt) desc")
List<Object[]> aggregateByAppKey(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
/** 全量汇总(总次数/总耗时/总错误) */
@Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest")
Object[] aggregateTotal(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
}
@@ -0,0 +1,90 @@
package com.yunhai.userbase.model;
import com.yunhai.userbase.entity.SysGroup;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* sys_group 全表不可变快照({@code groupSnapshot} 缓存的值类型,单条目装整个快照)。<br><br>
*
* 形态:不可变 List + 两个派生索引(byId / childrenMap),整体驱逐 + 重建,
* <b>不做增量维护</b>(避免在缓存层重复实现树结构逻辑)。<br>
* 存放:Caffeine 仅本地(约 1 万行 ≈ JVM 4~8MB;进 Redis 则每次读取网络传输 2MB,
* 权限计算一次请求读 N 次,无意义流量),消费方拿到的是对象引用,零序列化零网络。<br>
* 失效:{@code GroupChangedEvent} AFTER_COMMIT 驱逐整条,下次读取排队重建。<br>
* <b>消费方约定:只读,严禁改动快照内的实体对象</b>(快照被全请求共享,写一个等于污染全部)。
*/
public class GroupSnapshot {
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
private static final int ROOT_PARENT_ID = 0;
/** 全表记录(构造时已定序,顺序与派生索引一致)。 */
private final List<SysGroup> all;
/** id → 组。 */
private final Map<Integer, SysGroup> byId;
/** parentId(null 归一为 0)→ 直接子节点列表(同层按 sort 升序、再按 id 升序)。 */
private final Map<Integer, List<SysGroup>> childrenMap;
/** 行级字节粗估(Caffeine 按字节限重的 weigher 用,量级正确即可)。 */
private final long estimatedBytes;
public GroupSnapshot(List<SysGroup> groups) {
List<SysGroup> list = new ArrayList<>();
Map<Integer, SysGroup> idMap = new HashMap<>();
Map<Integer, List<SysGroup>> childMap = new HashMap<>();
if (groups != null) {
for (SysGroup g : groups) {
if (g == null) {
continue;
}
list.add(g);
idMap.put(g.getId(), g);
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
}
}
Comparator<SysGroup> siblingOrder = (a, b) -> {
int sa = a.getSort() == null ? 999 : a.getSort();
int sb = b.getSort() == null ? 999 : b.getSort();
if (sa != sb) {
return Integer.compare(sa, sb);
}
return Integer.compare(a.getId(), b.getId());
};
for (List<SysGroup> siblings : childMap.values()) {
siblings.sort(siblingOrder);
}
this.all = Collections.unmodifiableList(list);
this.byId = Collections.unmodifiableMap(idMap);
this.childrenMap = Collections.unmodifiableMap(childMap);
// 行均按 512B 保守估算(名称/类型/审计字段等实际 150~250B,翻倍留余量)
this.estimatedBytes = 512L * list.size() + 65536L;
}
public List<SysGroup> getAll() {
return all;
}
public Map<Integer, SysGroup> getById() {
return byId;
}
public Map<Integer, List<SysGroup>> getChildrenMap() {
return childrenMap;
}
/** 是否存在任何组(空表时 false)。 */
public boolean isEmpty() {
return all.isEmpty();
}
/** Caffeine weigher 用的字节粗估值。 */
public long getEstimatedBytes() {
return estimatedBytes;
}
}
@@ -21,7 +21,7 @@ public class UserGroupNodeVo {
/** 组名称。 */
private String name;
/** 是否用户直接归属的组(叶子高亮用);祖先组为 false。 */
/** 是否用户主归属组(isMain=1,无主归属时取第一个直接归属组);祖先组为 false。 */
private Boolean own;
/** 子节点(仅含祖先链上的节点)。 */
@@ -20,6 +20,9 @@ public class UserPermissionUserVo {
/** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */
private Integer isMain;
/** 该行对应的组ID(同一用户多组时每个组出一行,用于按组查看权限)。 */
private Integer groupId;
/** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */
private String groupPath;
}
@@ -1,32 +0,0 @@
package com.yunhai.userbase.model.identity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 当前激活身份(来自 {@code sys_user_current_role};无切换记录时兜底主身份)。<br>
*
* @author chenx
* @date 2026/8/18
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IdentityCurrentVo {
/** 当前激活角色ID(SysRoleEnum.key) */
private Integer roleId;
/** 当前激活角色名 */
private String roleName;
/** 当前激活学校ID(=学校组 sys_group.id;无学校时为 0) */
private Integer schoolId;
/** 当前激活学校名 */
private String schoolName;
/** 是否兜底(无主动切换记录时为 true,提示前端可引导用户显式切换) */
private Boolean fallback;
}
@@ -1,33 +0,0 @@
package com.yunhai.userbase.model.identity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户可选身份(一条 = 某学校下的某平台角色)。<br>
* 数据源:{@code sys_user_role_rel}(平台身份角色权威源)。<br>
*
* @author chenx
* @date 2026/8/18
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IdentityOptionVo {
/** 角色ID(SysRoleEnum.key,1-7) */
private Integer roleId;
/** 角色名(老师/校领导/学生…,查枚举快取) */
private String roleName;
/** 学校ID(=学校组 sys_group.id;区域/学段/直属无学校时为 0) */
private Integer schoolId;
/** 学校名(冗余,免 join sys_group) */
private String schoolName;
/** 是否主身份(isMain=1) */
private Boolean isMain;
}
@@ -5,15 +5,20 @@ import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 切换身份入参:指定目标学校与该学校下的角色。<br>
* 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,<br>
* 开放接口(oapi)切换身份入参:目标用户 + 目标学校与该学校下的角色。<br>
* 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
* 不存在则拒绝(防止越权切换到非授权身份)。<br>
* 调用方应用须在 {@code yh.identity.switch-allowed-app-keys} 白名单内(AppKey 级闸门)。
*
* @author chenx
* @date 2026/8/18
* @date 2026/8/27
*/
@Data
public class IdentitySwitchRequest {
public class IdentitySwitchOapiRequest {
/** 目标用户ID(由调用方应用后端从登录会话获取,禁止前端透传) */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 目标角色ID(SysRoleEnum.key) */
@NotNull(message = "角色ID不能为空")
@@ -0,0 +1,82 @@
package com.yunhai.userbase.model.oapilog;
import com.alibaba.fastjson.annotation.JSONField;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 开放接口日志列表项视图。<br>
* 不含 response_data(响应结果),列表不展示,需通过下载接口单独获取。<br>
*
* @author chenx
* @date 2026/8/20
*/
@Data
public class SysOapiLogListVo {
/** 主键 */
private Long id;
/** 日志级别 1-重要 2-次要 3-一般 */
private Integer logLevel;
/** 调用方应用 appKey */
private Integer callerAppKey;
/** 调用方应用名 */
private String callerAppName;
/** 请求IP */
private String requestIp;
/** 请求路径 */
private String requestUri;
/** 请求方法 GET/POST/PUT/DELETE */
private String requestMethod;
/** 操作类型 DELETE/CREATE/UPDATE/QUERY */
private String operationType;
/** 类名.方法名 */
private String classMethod;
/** 操作描述 */
private String operationDesc;
/** 请求参数JSON(脱敏后) */
private String requestParams;
/** 响应结果(GZIP 解压后原文,脱敏后;随列表带出,详情弹窗直接展示) */
private String responseData;
/** HTTP 状态码 */
private Integer statusCode;
/** 耗时(ms) */
private Long costTime;
/** 调用方UA */
private String userAgent;
/** 错误信息 */
private String errorMsg;
/** 调用时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 全参构造,顺序需与 {@code SysOapiLog} 的列表投影字段一致。<br>
* 用于 JPA Criteria {@code cb.construct} 投影查询。<br>
*/
public SysOapiLogListVo(Long id, Integer logLevel, Integer callerAppKey, String callerAppName,
String requestIp, String requestUri, String requestMethod,
String operationType, String classMethod, String operationDesc,
String requestParams, Integer statusCode, Long costTime,
String userAgent, String errorMsg, LocalDateTime createTime) {
this.id = id;
this.logLevel = logLevel;
this.callerAppKey = callerAppKey;
this.callerAppName = callerAppName;
this.requestIp = requestIp;
this.requestUri = requestUri;
this.requestMethod = requestMethod;
this.operationType = operationType;
this.classMethod = classMethod;
this.operationDesc = operationDesc;
this.requestParams = requestParams;
this.statusCode = statusCode;
this.costTime = costTime;
this.userAgent = userAgent;
this.errorMsg = errorMsg;
this.createTime = createTime;
}
}
@@ -0,0 +1,59 @@
package com.yunhai.userbase.model.oapilog;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 开放接口日志查询条件。<br>
* 覆盖调用方应用、URI、级别、IP、时间等高频查询场景,与表索引前缀对齐。<br>
*
* @author chenx
* @date 2026/8/20
*/
@Data
public class SysOapiLogQuery {
/** 日志级别(1-重要 2-次要 3-一般) */
private Integer logLevel;
/** 调用方应用 appKey(等值) */
private Integer callerAppKey;
/** 调用方应用名(模糊) */
private String callerAppName;
/** 请求路径(模糊) */
private String requestUri;
/** 请求IP(模糊) */
private String requestIp;
/** 操作类型 DELETE/CREATE/UPDATE/QUERY */
private String operationType;
/** HTTP 状态码 */
private Integer statusCode;
/**
* 成功/失败筛选。<br>
* true=仅 HTTP 200(成功),false=非 200(失败)。
*/
private Boolean success;
/**
* 关键字,同时匹配请求路径(requestUri)与应用名(callerAppName)。<br>
* 前端单输入框搜索这两个字段。
*/
private String keyword;
/** 调用起始时间(含) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
/** 调用结束时间(含) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
@@ -1,25 +0,0 @@
package com.yunhai.userbase.model.other;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @program: UserProfile
* @description: 用户当前角色信息
* @author: chenx
* @create: 2024-07-09 17:30
**/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class RoleInfoModel {
private Integer roleId;
private String roleName;
private Integer schId;
private String schName;
private Integer childId;
private String childName;
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.other;
import lombok.Data;
import java.util.List;
/**
* GetUserScope 返回体:当前登录用户的管理端访问范围(后端单点计算,前端不重复实现判定逻辑)。<br>
* level 取 {@link com.yunhai.userbase.enums.AccessLevelEnum} 的 key(1=SUPER/2=BUREAU/3=SCHOOL/4=NONE),
* 与前端 UserRoleEnum 数值对齐。
*
* @author chenx
* @date 2026/9/4
*/
@Data
public class UserScopeVo {
/** 访问级别(1=超管 2=局端 3=学校管理员 4=普通用户) */
private Integer level;
/** BUREAU 级:区域组 id;其余级别为 null */
private Integer bureauGroupId;
/** SCHOOL 级:可管理学校组 id 列表(多校管理员多元素);其余级别为 null/空 */
private List<Integer> schoolGroupIds;
}
@@ -20,4 +20,7 @@ public class ChangeGroupsRequest {
/** 目标组ID列表(覆盖式) */
@NotEmpty(message = "请至少保留一个组")
private List<Integer> groupIds;
/** 主归属组ID(可选):不传时后端按「旧主组保留」策略降级(旧主组勾掉才落到 groupIds 首项) */
private Integer mainGroupId;
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @description 开放接口:设置用户状态请求(启用/禁用账号)。
*/
@Data
public class SysUserChangeStatusRequest {
/** 用户ID。 */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 是否禁用:true=禁用,false=启用。 */
@NotNull(message = "状态不能为空")
private Boolean disabled;
}
@@ -0,0 +1,28 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @description 开放接口:设置用户组状态请求(转出/恢复,按 (userId, groupId) 维度,学校范围限定)。
*/
@Data
public class SysUserGroupStateRequest {
/** 用户ID。 */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 组ID(用户所在的具体组)。 */
@NotNull(message = "组ID不能为空")
private Integer groupId;
/** 学校组ID:限定操作范围,groupId 必须属于该校子树。 */
@NotNull(message = "学校ID不能为空")
private Integer schoolId;
/** 目标状态:0=恢复正常,1=转出。 */
@NotNull(message = "状态不能为空")
private Integer state;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 修改用户密码请求(oapi 开放接口专用)。
*
* @author chenx
*/
@Data
public class SysUserPasswordRequest {
/**
* 用户ID(必填)。
*/
@NotNull
private Integer userId;
/**
* 旧密码(必填,须与库中 BCrypt 密文匹配)。
*/
@NotBlank
private String oldPassword;
/**
* 新密码(必填,走弱密码规则校验后 BCrypt 加密写入)。
*/
@NotBlank
private String newPassword;
}
@@ -38,7 +38,16 @@ public class SysUserRequest {
private String email;
private Integer gender;
private String profilePicture;
private List<Integer> roles;
/**
* 身份归属学校(schoolId 解析后的真实组 id:学校组/区域组/根节点)。<br>
* 编辑时必传(随 Select 当前选中组提交原值);新增可不传,后端按 groupId 解析。
*/
private Integer schoolId;
/**
* 用户身份角色(SysRoleEnum.key 1-7),按 schoolId 全量覆盖保存;空数组=清空该校身份。<br>
* 替代旧 roles 字段:sys_user.roles 列已停写(仅历史兼容读取)。
*/
private List<Integer> roleIds;
/** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */
private Integer disabled;
private Boolean expired;
@@ -0,0 +1,28 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @description 开放接口:跨校转入请求(用户在源学校标记转出后,迁移到目标学校的指定组)。
*/
@Data
public class SysUserTransferSchoolRequest {
/** 待迁移用户ID。 */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 原学校组ID(源学校,须真实存在且为学校组):要求用户在该学校存在至少一条「转出」状态的组关系。 */
@NotNull(message = "原学校ID不能为空")
private Integer fromSchoolId;
/** 目标学校组ID(转入后的学校)。 */
@NotNull(message = "目标学校ID不能为空")
private Integer schoolId;
/** 目标组ID(目标学校内挂载的具体组,迁移后的主归属组)。 */
@NotNull(message = "目标组ID不能为空")
private Integer groupId;
}
@@ -29,6 +29,13 @@ public class SysUserVo {
private Integer type;
/** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */
private Integer sortNo;
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
private Integer groupStatus;
/**
* 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。<br>
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
*/
private String identityNames;
private Boolean expired;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import java.util.List;
/**
* 用户身份回显 VO(GET /sysuser/UserIdentity)。<br>
* 后端按所在组解析 schoolId 后返回;同一学校多组返回同一 schoolId 与角色并集。<br>
* 编辑请求提交时原样携带 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
*
* @author chenx
* @date 2026/8/27
*/
@Data
public class UserIdentityVo {
/** 身份归属学校(解析后的真实组 id:学校组/区域组/根节点) */
private Integer schoolId;
/** 该校用户身份角色(SysRoleEnum.key 1-7,并集去重) */
private List<Integer> roleIds;
}
@@ -0,0 +1,27 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysOapiLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.time.LocalDateTime;
/**
* 开放接口调用日志仓储。<br>
*
* @author chenx
* @date 2026/8/20
*/
public interface SysOapiLogRepository extends JpaRepository<SysOapiLog, Long>,
JpaSpecificationExecutor<SysOapiLog> {
/**
* 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。<br>
* 用于定时清理:一般日志保留 12 个月,重要/次要日志保留 36 个月。<br>
*
* @param logLevel 日志级别编码
* @param createTime 截止时间(早于该时间的记录被删除)
* @return 删除条数
*/
long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime);
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysSchoolAppOverride;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
/**
* 学校应用私有覆盖仓储。
*
* @author chenx
* @date 2026/9/4
*/
public interface SysSchoolAppOverrideRepository extends JpaRepository<SysSchoolAppOverride, Integer> {
/** 指定学校集合的全部应用覆盖行(AppList/AppSortList 合并用) */
List<SysSchoolAppOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
SysSchoolAppOverride findBySchoolIdAndAppId(Integer schoolId, Integer appId);
/** 删除应用时级联清理该应用在所有学校的覆盖行 */
@Modifying
@Query("DELETE FROM SysSchoolAppOverride o WHERE o.appId = :appId")
void deleteByAppIdBulk(@Param("appId") Integer appId);
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysSchoolCategoryOverride;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
/**
* 学校应用类型私有覆盖仓储。
*
* @author chenx
* @date 2026/9/4
*/
public interface SysSchoolCategoryOverrideRepository extends JpaRepository<SysSchoolCategoryOverride, Integer> {
/** 指定学校集合的全部类型覆盖行(AppTypeList 合并用) */
List<SysSchoolCategoryOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
SysSchoolCategoryOverride findBySchoolIdAndCategoryId(Integer schoolId, Integer categoryId);
/** 删除应用类型时级联清理该类型在所有学校的覆盖行 */
@Modifying
@Query("DELETE FROM SysSchoolCategoryOverride o WHERE o.categoryId = :categoryId")
void deleteByCategoryIdBulk(@Param("categoryId") Integer categoryId);
}
@@ -3,6 +3,9 @@ package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
@@ -14,4 +17,9 @@ import java.util.Optional;
public interface SysUserCurrentRoleRepository extends JpaRepository<SysUserCurrentRole, Integer>, JpaSpecificationExecutor<SysUserCurrentRole> {
Optional<SysUserCurrentRole> findByUserId(Integer userId);
/** 删除用户当前激活身份记录(级联删用户时清理残留切换记录) */
@Modifying
@Query("DELETE FROM SysUserCurrentRole r WHERE r.userId = :userId")
void deleteByUserIdBulk(@Param("userId") Integer userId);
}
@@ -35,7 +35,7 @@ public interface SysUserRepository extends JpaRepository<SysUser, Integer>, JpaS
* 入参 name / disabled 为可空:为空(NULL)时该过滤条件整体跳过。
*/
@Query(value =
"SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo " +
"SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo, MAX(COALESCE(r.status, 0)) AS groupStatus " +
"FROM sys_user u " +
"INNER JOIN sys_user_group_rel r ON r.user_id = u.id " +
"WHERE r.group_id IN (:groupIds) " +
@@ -4,6 +4,7 @@ import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.StatusEnum;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -23,13 +24,28 @@ public interface SysUserRoleRelRepository extends JpaRepository<SysUserRoleRel,
/** 用户某状态下的全部可选身份 */
List<SysUserRoleRel> findByUserIdAndStatus(Integer userId, StatusEnum status);
/** 用户主身份(isMain=1),最多 1 条 */
Optional<SysUserRoleRel> findByUserIdAndIsMain(Integer userId, Integer isMain);
/** 批量取用户在指定状态下的全部身份行(列表页/导出批量主身份解析用,替代已删除的 findByUserIdInAndIsMain) */
List<SysUserRoleRel> findByUserIdInAndStatus(List<Integer> userIds, StatusEnum status);
/** 用户在某学校下、某状态下的全部角色关联 */
List<SysUserRoleRel> findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
/** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds) */
/**
* 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用)。<br>
* 必须 bulk 立即发 SQL:派生 delete 只把删除动作入队,同一事务内随后 saveAll 的 INSERT
* 在刷新时会先于 DELETE 执行(Hibernate ActionQueue 顺序),与 uk(user_id, role_id, school_id)
* 撞唯一键(编辑提交原身份集即触发 Duplicate entry)。
*/
@Modifying
@Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId AND r.schoolId = :schoolId")
void deleteByUserIdAndSchoolId(@Param("userId") Integer userId, @Param("schoolId") Integer schoolId);
/** 删除用户全部身份行(级联删用户时清理,bulk delete 直接发 SQL) */
@Modifying
@Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId")
void deleteByUserIdBulk(@Param("userId") Integer userId);
/** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds / resolveUserType) */
@Query("SELECT DISTINCT r.roleId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status")
List<Integer> findDistinctRoleIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status);
@@ -1,5 +1,7 @@
package com.yunhai.userbase.service;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
@@ -33,4 +35,18 @@ public interface IAppCategoryService {
*/
List<AppAuthzCategoryVo> listWithApps();
/**
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。<br>
* 规则:<br>
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br>
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
* ③ 不透出 appSecret。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。
*
* @param userId 用户ID
* @param clientType 客户端类型(null=不按端过滤)
* @return 应用类型 + 有权应用列表
*/
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType);
}
@@ -23,6 +23,11 @@ public interface IApplicationInfoService {
*/
ApplicationInfo getById(Integer id);
/**
* 根据 appKey 查询应用主键 id(appKeyToId 本地缓存,消除开放接口每请求一次 findByAppKey)。
*/
Integer findIdByAppKey(Integer appKey);
/**
* 保存/更新应用信息(写后同步更新缓存)。
*/
@@ -84,6 +84,12 @@ public interface IGroupAuthzService {
*/
List<UserPermissionUserVo> searchUsers(String keyword);
/**
* 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回归属组与范围有交集的行;
* null 表示不过滤(超管/内部调用)。
*/
List<UserPermissionUserVo> searchUsers(String keyword, java.util.Set<Integer> scopeGroupIds);
/**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回<b>有授权模块</b>的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
@@ -176,4 +182,15 @@ public interface IGroupAuthzService {
* @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll)
*/
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
/**
* 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。<br>
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
* - 超级管理员:全部应用;<br>
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
*
* @param userId 用户ID
* @return 有权应用ID列表(无则空列表)
*/
List<Integer> findUserAuthorizedAppIds(Integer userId);
}
@@ -6,9 +6,13 @@ import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
import com.yunhai.userbase.service.resolve.AccessScope;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* @author chenx
@@ -35,11 +39,59 @@ public interface ISysGroupService {
*/
void delete(Integer id, Integer schoolId);
/**
* 开放接口通用:校验学校锚点 schoolId 合法——非空、存在、且必须为学校组(SCHOOL_GROUP)。<br>
* 供 delete/move/用户组关系等带 schoolId 边界的开放接口统一调用。
*
* @param schoolId 学校组ID(限定范围)
*/
void assertSchoolId(Integer schoolId);
/**
* 解析唯一真实根节点(parentId=0 中 id 最小者,与 {@link #getGroups()} 约定一致)。<br>
* 供「parentId/pid 为 0 或 null 表示挂根下」场景与身份归属解析({@link #resolveSchoolId})共用。
*
* @return 根节点实体
* @throws com.yunhai.platform.utils.exception.DException 当不存在任何根节点时
*/
SysGroup resolveRootGroup();
/**
* 解析组归属的 schoolId(身份归属):沿该组的祖先链(含自身)——<br>
* ① 最近的学校组(SCHOOL_GROUP)→ 该校组 id;<br>
* ② 无学校组,则最近的区域组(AREA_GROUP)→ 该区域组 id;<br>
* ③ 学校组、区域组都没有 → 根节点 id。<br>
* 同校多组解析到同一 schoolId;schoolId 恒为真实组 id(原表注释「区域/学段/根=0」约定废弃)。<br>
* 该规则属后端职责,前端只按接口返回的 schoolId 加载/保存,不感知解析逻辑。
*
* @param groupId 组ID
* @return 归属 schoolId
*/
Integer resolveSchoolId(Integer groupId);
/**
* 批量解析组归属 schoolId(身份归属),语义与 {@link #resolveSchoolId} 完全一致:
* 一次全量加载 sys_group 建内存表后沿祖先链解析(O(树深)),避免 N 次逐级 findById。<br>
* 供列表页/导出的批量主身份解析(IdentityService.resolveMainIdentityByUser)使用;
* 组表量级 = 学校数×层级(千级),与 searchUsers buildAllGroupMap 全表加载先例成本同量级。<br>
* 组不存在(悬挂关系)按 {@link #resolveSchoolId} 同规则回落根节点 id。
*
* @param groupIds 待解析组ID集合
* @return groupId → schoolId 映射(入参全部有值)
*/
Map<Integer, Integer> resolveSchoolIds(Collection<Integer> groupIds);
List<SysGroupVo> list(SysGroupQuery query);
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
List<SysGroupVo> getTree();
/**
* 获取组织架构树(按访问范围裁剪):SUPER 原样全量;BUREAU 以区域组为根的子树;
* SCHOOL 各学校子树(多校 = 多根数组)。scope 为 null 或 SUPER 级时行为与 {@link #getTree()} 完全一致。
*/
List<SysGroupVo> getTree(AccessScope scope);
/**
* 获取「学校组」剪枝树:末端叶子一定是学校组。<br>
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);<br>
@@ -47,6 +99,12 @@ public interface ISysGroupService {
*/
List<SysGroupVo> getSchoolTree();
/**
* 获取「学校组」剪枝树(按访问范围裁剪):BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
* scope 为 null 或 SUPER 级时行为与 {@link #getSchoolTree()} 完全一致。
*/
List<SysGroupVo> getSchoolTree(AccessScope scope);
/** 在指定父组下按规则批量生成子组。 */
List<BoaGroup> batchAdd(SysGroupBatchRequest request);
@@ -56,6 +114,18 @@ public interface ISysGroupService {
/** 将 moveId 移动到 pid 下(环检测)。 */
void move(SysGroupMoveRequest request);
/**
* 开放接口专用:在学校(组)范围内移动组。<br>
* 与 {@link #move(SysGroupMoveRequest)} 移动逻辑一致,但额外强制边界:<br>
* ① moveId 不能等于 schoolId(禁止移动学校根组);<br>
* ② pid 不能是平台根(0)(禁止移出学校范围);<br>
* ③ moveId 与 pid 都必须属于 schoolId 子树(禁止跨校移动)。<br>
*
* @param request 待移动节点 moveId + 目标父节点 pid
* @param schoolId 学校组ID(限定移动范围)
*/
void move(SysGroupMoveRequest request, Integer schoolId);
/**
* 收集指定组自身及其所有子孙组的ID(沿 parentId 向下遍历)。<br>
* 用于「查询该节点及其子组下所有用户」等子树聚合场景。<br>
@@ -0,0 +1,50 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.entity.SysOapiLog;
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;
/**
* 开放接口调用日志业务接口。<br>
*
* @author chenx
* @date 2026/8/20
*/
public interface ISysOapiLogService {
/**
* 异步保存开放接口日志。<br>
* 由 OapiLogAspect 在 finally 中调用,失败仅记录日志,不影响主业务。<br>
*
* @param entity 日志实体
*/
void saveAsync(SysOapiLog entity);
/**
* 分页查询开放接口日志列表。<br>
* 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。<br>
*
* @param query 查询条件
* @param pageQuery 分页参数(pageIndex 从 1 起)
* @return 分页结果
*/
BasePageResult<SysOapiLogListVo> pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery);
/**
* 获取某条日志的响应结果(解压后原文)。<br>
*
* @param id 日志ID
* @return 解压后的响应字符串,无记录或无响应数据返回 null
*/
String getResponseData(Long id);
/**
* 清理过期日志。<br>
* 一般日志(level=3)保留 12 个月,重要(level=1)/次要(level=2)保留 36 个月。<br>
*
* @return 删除条数
*/
long cleanupExpired();
}
@@ -1,10 +1,12 @@
package com.yunhai.userbase.service;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
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.model.sysuser.UserIdentityVo;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -102,13 +104,29 @@ public interface ISysUserService {
List<Integer> findGroupIdsByUserId(Integer userId);
/**
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
* 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。
* 查询用户身份(管理端「用户身份」回显):后端按所在组解析 schoolId 后返回 {schoolId, roleIds};<br>
* 同一学校多组返回同一 schoolId 与角色并集。前端回显唯一来源(roles 列已停写,不再作为真源)。<br>
* 编辑请求提交时原样携带返回的 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
*
* @param userIds 待调整用户ID列表
* @param groupIds 目标组ID列表
* @param userId 用户ID
* @param groupId 所在组ID(Select 当前选中项;schoolId 解析规则见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId})
* @return {schoolId, roleIds}
*/
void changeGroups(List<Integer> userIds, List<Integer> groupIds);
UserIdentityVo getUserIdentity(Integer userId, Integer groupId);
/**
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
* 主归属组按三级降级确定:<br>
* ① mainGroupId 显式传入(须 ∈ groupIds,否则 400)→ 所有用户统一置主;<br>
* ② 未传 → 逐用户沿用旧主组(旧主组 ∈ 新 groupIds);<br>
* ③ 旧主组不在新集合 → 取 groupIds 首项(现状行为兜底)。<br>
* ② 级为行为增强:只要用户没把主组勾掉,调整组后主组不动(旧实现按数组顺序随机重指)。
*
* @param userIds 待调整用户ID列表
* @param groupIds 目标组ID列表
* @param mainGroupId 主归属组ID(可选;null 走 ②③ 级降级)
*/
void changeGroups(List<Integer> userIds, List<Integer> groupIds, Integer mainGroupId);
/**
* 开放接口专用:在学校(组)范围内移动用户。<br>
@@ -160,4 +178,70 @@ public interface ISysUserService {
* @param response 直接写入 Excel 流
*/
void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException;
/**
* 查询用户的业务角色身份列表(oapi 开放接口专用)。
*
* @param userId 用户ID
* @return 角色身份列表(roleId/roleName + 主归属学校组上下文)
*/
List<BoaRoleInfo> findUserRoles(Integer userId);
/**
* 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
* oapi 开放接口专用;不加操作日志(避免明文密码入日志)。
*
* @param userId 用户ID
* @param oldPassword 旧密码(须与库中 BCrypt 密文匹配)
* @param newPassword 新密码(弱密码规则校验后加密写入)
*/
void changePassword(Integer userId, String oldPassword, String newPassword);
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
*
* @param userId 用户ID
* @param file 头像图片文件(multipart)
* @return 新头像完整可访问 URL
*/
String changeProfilePicture(Integer userId, MultipartFile file);
/**
* 开放接口专用:设置用户在指定组上的状态(转出/恢复)。<br>
* 仅作关系标记,不影响成员查询、组织链与下游推送;同值重复调用幂等。<br>
* 边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/学校根组操作)。
*
* @param userId 用户ID
* @param groupId 组ID
* @param schoolId 学校组ID(限定范围)
* @param state 目标状态:0=恢复正常,1=转出
*/
void changeUserGroupState(Integer userId, Integer groupId, Integer schoolId, Integer state);
/**
* 开放接口专用:查询用户在指定组上的状态(0=正常,1=转出)。<br>
* 存量数据 status 为 NULL 视为正常返回 0。
*
* @param userId 用户ID
* @param groupId 组ID
* @param schoolId 学校组ID(限定范围)
* @return 状态值 0/1
*/
Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId);
/**
* 开放接口专用:跨校转入(转出后迁移到其他学校)。<br>
* 前置:用户已在源学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系;
* 操作成功后:删除源学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系
* (主归属、状态恢复正常,转出流程结束),并推送编辑用户订阅(UPDATE_USER)。<br>
* 边界:fromSchoolId 与目标 schoolId 均须为学校组且不相同;目标组须属于目标学校子树且不等于学校根组;
* 用户已在目标学校存在组关系时拒绝(转入语义为进入全新学校)。
*
* @param userId 待迁移用户ID
* @param fromSchoolId 原学校组ID(源学校)
* @param toSchoolId 目标学校组ID
* @param groupId 目标组ID(目标学校内的挂载组,迁移后的主归属)
*/
void transferUserToSchool(Integer userId, Integer fromSchoolId, Integer toSchoolId, Integer groupId);
}
@@ -1,8 +1,12 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.appauthz.AppAuthzAppRow;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
@@ -11,6 +15,7 @@ import com.yunhai.userbase.model.appcategory.AppCategoryVo;
import com.yunhai.userbase.repository.AppCategoryRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IGroupAuthzService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,9 +23,14 @@ import org.springframework.util.StringUtils;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -36,6 +46,9 @@ public class AppCategoryService implements IAppCategoryService {
@Autowired
private ApplicationInfoRepository applicationInfoRepository;
@Autowired
private IGroupAuthzService groupAuthzService;
@Override
@Transactional
@@ -133,6 +146,97 @@ public class AppCategoryService implements IAppCategoryService {
return vo;
}
@Override
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType) {
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo)));
// categoryId -> 该类型下应用列表(插入顺序即排序)
Map<Integer, List<ApplicationInfo>> appMap = new LinkedHashMap<>();
for (ApplicationInfo app : apps) {
if (app.getCategoryId() == null) {
continue;
}
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
}
List<BoaAppType> result = new ArrayList<>();
for (AppCategory category : categories) {
BoaAppType type = new BoaAppType();
type.setId(category.getId());
type.setName(category.getName());
List<BoaApp> boaApps = new ArrayList<>();
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
// 用户权限过滤
if (!authorizedAppIds.contains(app.getId())) {
continue;
}
// 客户端类型位掩码过滤:clients 为空视为不限端
if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) {
continue;
}
boaApps.add(convertToBoaApp(app));
}
type.setApps(boaApps);
result.add(type);
}
return result;
}
/**
* ApplicationInfo -> BoaApp 映射。不透出 appSecret;模块列表留空(由调用方按需填充)。
*/
private BoaApp convertToBoaApp(ApplicationInfo app) {
BoaApp boa = new BoaApp();
boa.setId(app.getId());
boa.setName(app.getName());
boa.setIcon(app.getPcIcon());
boa.setType(app.getCategoryId());
boa.setWebAddr(app.getPcUrl());
boa.setWebAddress(app.getPcUrl());
boa.setAppKey(app.getAppKey());
boa.setOrderNumber(app.getSortNo());
boa.setCreateUser(app.getCreateUser());
boa.setCreateTime(app.getCreateTime());
boa.setMobiIcon(app.getAppIcon());
boa.setClients(toClientMask(app.getClients()));
boa.setWxgzhAddress(app.getOfficialAccountUrl());
boa.setDingAddress(app.getDingtalkUrl());
return boa;
}
/**
* clients 列表([1,2,4])折叠为位掩码叠加值(7)。
*/
private Integer toClientMask(List<Integer> clients) {
if (clients == null || clients.isEmpty()) {
return null;
}
int mask = 0;
for (Integer c : clients) {
if (c != null) {
mask |= c;
}
}
return mask;
}
/**
* 判断客户端类型位掩码是否与 app.clients 有交集;clients 为空视为不限端。
*/
private boolean containsClient(List<Integer> clients, Integer clientKey) {
if (clients == null || clients.isEmpty()) {
return true;
}
for (Integer c : clients) {
if (c != null && (c & clientKey) != 0) {
return true;
}
}
return false;
}
}
@@ -12,9 +12,8 @@ import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.config.CacheNames;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@@ -72,19 +71,37 @@ public class ApplicationInfoService implements IApplicationInfoService {
private ApplicationEventPublisher eventPublisher;
/**
* 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。
* 查询缓存:底层优先 Redis,Redis 故障时自动降级 Caffeine。<br>
* 不存在或不存在的 id 返回 null 不入缓存(unless 拦截)——不能用 sync=true:SD Redis 2.7 的
* sync 路径对 null 返回值会无条件 put 并在禁 null 缓存下抛 IllegalArgumentException(查不存在=500)。<br>
* 失效走 ApplicationInfoCacheChangedEvent(AFTER_COMMIT 清整缓存,见 CacheInvalidationListener),
* 不再用 @CachePut/@CacheEvict——事务方法上注解驱逐发生在 commit 前,存在回源旧值竞态(方案 5.3 禁令)。
*/
@Override
@Cacheable(value = "appInfo", key = "#id", sync = true)
@Cacheable(value = CacheNames.APP_INFO, key = "#id", unless = "#result == null")
public ApplicationInfo getById(Integer id) {
return applicationInfoRepository.findById(id).orElse(null);
}
/**
* 更新缓存:方法返回即写入缓存(与 @Cacheable 同一 cacheName/key,保证一致)。
* appKey → appId 映射(appKeyToId 本地缓存,TTL 2h):消除 /oapi/user/modules 等
* 每请求一次 findByAppKey 查库;应用增删改经 ApplicationInfoCacheChangedEvent 搭车整清。
* 查无此 appKey 返回 null 不入缓存(无效 appKey 已被验签拦截,穿透可忽略)。
*/
@Override
@Cacheable(value = CacheNames.APP_KEY_TO_ID, key = "#appKey", unless = "#result == null")
public Integer findIdByAppKey(Integer appKey) {
if (appKey == null) {
return null;
}
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
return app == null ? null : app.getId();
}
/**
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
*/
@Override
@CachePut(value = "appInfo", key = "#info.id")
public ApplicationInfo save(ApplicationInfo info) {
ApplicationInfo saved = applicationInfoRepository.save(info);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
@@ -92,10 +109,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
}
/**
* 删除缓存:删除数据时同步清除对应缓存项。
* 按主键删除:删除成功后发布变更事件(含 appKey),监听器清缓存 + 摘除签名密钥。
*/
@Override
@CacheEvict(value = "appInfo", key = "#id")
public void deleteById(Integer id) {
ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null);
applicationInfoRepository.deleteById(id);
@@ -143,8 +159,8 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override
@Transactional
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理),并清除 appInfo 缓存
@CacheEvict(value = "appInfo", key = "#id")
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
// appInfo 缓存失效统一走 AFTER_COMMIT 事件(原 @CacheEvict 在事务方法上驱逐时机早于 commit,存在竞态,已移除)
public void delete(Integer id) {
Objects.requireNonNull(id, "应用ID不能为空");
ApplicationInfo existing = applicationInfoRepository.findById(id)
@@ -226,8 +242,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override
@Transactional
// AI-FIX: 重置密钥后必须清除 appInfo 缓存,否则控制器随后 getById 命中旧缓存、返回旧密钥
@CacheEvict(value = "appInfo", key = "#id")
// AI-FIX: 重置密钥后 appInfo 缓存由 AFTER_COMMIT 事件统一清空(原 @CacheEvict 驱逐早于 commit,存在回源旧密钥竞态,已移除)
public void resetAppSecret(Integer id) {
Objects.requireNonNull(id, "应用ID不能为空");
ApplicationInfo entity = applicationInfoRepository.findById(id)
@@ -256,6 +271,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
}
applicationInfoRepository.updateSortNoById(id, i + 1);
}
// 排序结果缓存于 appInfo(实体含 sortNo),提交后发布变更事件统一失效
ApplicationInfo first = applicationInfoRepository.findById(ids.get(0)).orElse(null);
if (first != null) {
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, first));
}
}
private ApplicationInfoVo convertToVo(ApplicationInfo entity) {
@@ -271,7 +291,6 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override
@Transactional
@CachePut(value = "appInfo", key = "#id")
public ApplicationInfo savePushSetting(Integer id, PushSettingRequest request) {
Objects.requireNonNull(id, "应用ID不能为空");
Objects.requireNonNull(request, "推送设置入参不能为空");
@@ -296,7 +315,10 @@ public class ApplicationInfoService implements IApplicationInfoService {
if (request.getPushSecret() != null) {
entity.setPushSecret(request.getPushSecret());
}
return applicationInfoRepository.save(entity);
ApplicationInfo saved = applicationInfoRepository.save(entity);
// 推送配置写入实体字段,提交后发布变更事件统一失效 appInfo/appKeyToId(原 @CachePut 驱逐早于 commit,已移除)
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
return saved;
}
@Override
@@ -0,0 +1,255 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* BoaUser 缓存加载器({@code boaUser} 缓存的唯一回源入口,注册表见 {@link CacheNames#BOA_USER})。<br><br>
*
* 三个查询维度各自成 key({@code login:}/{@code phone:}/{@code id:}),查无此人返回 null 不入缓存
* ({@code unless} 拦截;穿透防护由 UserCenterService 的 30s 空值哨兵缓存承担)。<br>
* <b>注解不使用 sync=true 的原因</b>:spring-data-redis 2.7 的 sync 路径对 loader 返回 null 会无条件
* put(key, null),在 {@code disableCachingNullValues} 下直接抛 IllegalArgumentException(查无此人=500),
* 无法与空值保护共存;单 JVM 排队回源(防击穿)由 UserCenterService 门面的条带锁承担,语义等价。<br>
* BoaUser 组装逻辑(组织链/身份推导)自 UserCenterService 平移至此;
* UserCenterService 作为对外门面保留原方法签名,内部委托本类(调用必须经代理,禁止同类自调用)。<br>
* <b>失效责任</b>:UserChangedEvent AFTER_COMMIT 精确驱逐(身份切换必须立即失效 currentRole)。
*/
@Service
public class BoaUserLoader {
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
private static final int ROOT_PARENT_ID = 0;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserRoleRelRepository sysUserRoleRelRepository;
@Autowired
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Autowired
private MainGroupResolver mainGroupResolver;
@Autowired
private IdentityService identityService;
/** 按账号加载(缓存 key = login:{account})。 */
@Cacheable(value = CacheNames.BOA_USER, key = "'login:' + #account", unless = "#result == null")
public BoaUser byAccount(String account) {
return resolve(sysUserRepository.findByAccount(account));
}
/** 按手机号加载(缓存 key = phone:{phone})。 */
@Cacheable(value = CacheNames.BOA_USER, key = "'phone:' + #phone", unless = "#result == null")
public BoaUser byPhone(String phone) {
return resolve(sysUserRepository.findByPhone(phone));
}
/** 按用户ID加载(缓存 key = id:{userId})。 */
@Cacheable(value = CacheNames.BOA_USER, key = "'id:' + #id", unless = "#result == null")
public BoaUser byId(Integer id) {
return resolve(sysUserRepository.findById(id).orElse(null));
}
private BoaUser resolve(SysUser user) {
if (user == null) {
return null;
}
BoaUser boa = new BoaUser();
boa.setId(user.getId());
boa.setAccount(user.getAccount());
boa.setName(user.getRealName());
boa.setIdcard(user.getIdcard());
boa.setPhone(user.getPhone());
boa.setProfilePicture(user.getProfilePicture());
// 用户表无 email / gender 列,置 null
boa.setEmail(null);
boa.setGender(null);
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
// 角色列表:从 sys_user_role_rel(平台身份角色权威源)取正常态 roleId 去重,不再读 sys_user.roles
List<Integer> roleIds = sysUserRoleRelRepository
.findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL);
boa.setRoleIds(roleIds);
List<BoaGroup> groups = buildGroups(user.getId());
boa.setGroups(groups);
// 部门/教育局:仍从组织链推导(与主组/区域组语义绑定)
deriveOrg(groups, boa);
// 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校)
deriveIdentity(user.getId(), boa);
return boa;
}
/**
* 组装用户组织链:取主归属组(isMain=1 取 id 最小,无主兜底 id 最小),沿 parentId 向上回溯到根,
* 顺序为「主组 -> ... -> 根方向」(近 -> 远)。
*/
private List<BoaGroup> buildGroups(Integer userId) {
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
if (rels == null || rels.isEmpty()) {
return new ArrayList<>();
}
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
Integer groupId = mainRel != null ? mainRel.getGroupId() : null;
if (groupId == null) {
return new ArrayList<>();
}
List<BoaGroup> chain = new ArrayList<>();
Integer cursor = groupId;
Set<Integer> seen = new HashSet<>();
while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) {
SysGroup g = sysGroupRepository.findById(cursor).orElse(null);
if (g == null) {
break;
}
BoaGroup bg = new BoaGroup();
bg.setId(g.getId());
bg.setName(g.getName());
bg.setParentId(g.getParentId());
bg.setType(g.getType() != null ? g.getType().getKey() : null);
chain.add(bg);
cursor = g.getParentId();
}
return chain;
}
/** 从组织链推导 school / bureau / dept。 */
private void deriveOrg(List<BoaGroup> groups, BoaUser boa) {
if (groups == null || groups.isEmpty()) {
return;
}
BoaGroup school = null;
BoaGroup bureau = null;
for (BoaGroup g : groups) {
if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) {
school = g;
}
if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) {
bureau = g;
}
}
boa.setSchool(school);
boa.setBureau(bureau);
// 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校
boa.setDept(groups.get(0));
}
/**
* 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:<br>
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);<br>
* ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行,
* 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
* ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);<br>
* ④ schools:用户在全部学校下的可选身份去重 schoolId(&gt;0)集合组装 BoaGroup 列表(本期补码)。<br>
* school 类型守卫:仅当身份 schoolId 对应组类型=SCHOOL_GROUP 时回填 boa.school,
* 区域组/根直属用户保持 null——等价旧约定「填 0 不回填」的行为,避免子应用读到 school=区域组 对象。
*/
private void deriveIdentity(Integer userId, BoaUser boa) {
Integer currentRoleId = null;
Integer currentSchoolId = null;
String currentSchoolName = null;
SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null);
if (current != null && current.getRoleId() != null) {
currentRoleId = current.getRoleId();
currentSchoolId = current.getSchoolId();
currentSchoolName = current.getSchoolName();
} else {
// 兜底:主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行),
// 而不是「主组+主角色」组合(避免组合出 B 学校老师的非法身份)
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
if (mainRel != null) {
currentRoleId = mainRel.getRoleId();
currentSchoolId = mainRel.getSchoolId();
if (currentSchoolId != null && currentSchoolId > 0) {
SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null);
currentSchoolName = sch != null ? sch.getName() : null;
}
}
}
// 当前激活学校:仅当对应组为学校组时回填(区域组/根直属保持 null)
if (currentSchoolId != null && currentSchoolId > 0 && isSchoolGroup(currentSchoolId)) {
boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName));
}
// 当前激活身份(优先切换记录,兜底主身份)——组装为 BoaRoleInfo 富对象(childId/childName 本期不做,恒空)
BoaRoleInfo currentRole = new BoaRoleInfo();
currentRole.setRoleId(currentRoleId);
currentRole.setRoleName(resolveRoleName(currentRoleId));
currentRole.setSchId(currentSchoolId);
currentRole.setSchName(currentSchoolName);
boa.setCurrentRole(currentRole);
// 可选学校集合:全部正常态身份行的 schoolId 去重(>0 过滤,恒为真实组 id)
List<Integer> schoolIds = sysUserRoleRelRepository
.findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL);
if (schoolIds != null && !schoolIds.isEmpty()) {
List<BoaGroup> schools = schoolIds.stream()
.filter(sid -> sid != null && sid > 0)
.map(sid -> toBoaGroup(sid, null))
.collect(Collectors.toList());
boa.setSchools(schools);
}
}
/** 该组是否为学校组(SCHOOL_GROUP);组不存在视为 false。 */
private boolean isSchoolGroup(Integer groupId) {
SysGroup g = sysGroupRepository.findById(groupId).orElse(null);
return g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP;
}
/** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */
private String resolveRoleName(Integer roleId) {
if (roleId == null) {
return null;
}
SysRoleEnum role = IEnum.getByKey(SysRoleEnum.class, roleId);
return role != null ? role.getValue() : null;
}
/** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */
private BoaGroup toBoaGroup(Integer groupId, String fallbackName) {
BoaGroup bg = new BoaGroup();
bg.setId(groupId);
if (fallbackName != null) {
bg.setName(fallbackName);
} else {
SysGroup g = sysGroupRepository.findById(groupId).orElse(null);
bg.setName(g != null ? g.getName() : null);
}
return bg;
}
}
@@ -0,0 +1,138 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
import com.yunhai.userbase.event.GroupChangedEvent;
import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.repository.SysUserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.List;
/**
* 缓存失效统一监听器(写失效唯一通道:AFTER_COMMIT 事件驱动)。<br><br>
*
* 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:<br>
* <ul>
* <li>{@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;</li>
* <li>{@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+
* 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);</li>
* <li>{@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐
* login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时
* 整清兜底(无法反查 account/phone,删除是罕见操作)。</li>
* </ul>
*
* <b>护栏</b>(方案 5.2):所有监听器 {@code fallbackExecution=true}——发布方不在事务中时事件
* 不被静默吞掉;每次驱逐自带 try-catch + error 日志(含 key 与「TTL 后自愈」说明)——
* CacheErrorHandler 只覆盖注解驱动的缓存操作,手动驱逐路径必须自兜底;该 error 日志的
* 出现频率是事件链路健康度的观测点,频繁出现必须排查。
*/
@Service
public class CacheInvalidationListener {
private static final Logger log = LoggerFactory.getLogger(CacheInvalidationListener.class);
/** groupSnapshot 的固定单 key(与 GroupSnapshotService 的 @Cacheable key 一致)。 */
private static final String SNAPSHOT_KEY = "all";
@Autowired
private CacheManager cacheManager;
@Autowired
private SysUserRepository sysUserRepository;
// ===================== 应用域 =====================
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onApplicationInfoChanged(ApplicationInfoCacheChangedEvent event) {
clearQuietly(CacheNames.APP_INFO);
clearQuietly(CacheNames.APP_KEY_TO_ID);
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId",
event.getAppKey(), event.getType());
}
// ===================== 组域 =====================
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onGroupChanged(GroupChangedEvent event) {
evictQuietly(CacheNames.GROUP_SNAPSHOT, SNAPSHOT_KEY);
clearQuietly(CacheNames.BOA_USER);
clearQuietly(CacheNames.BOA_USER_NULL);
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser", event.getSourceOp());
}
// ===================== 用户域 =====================
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onUserChanged(UserChangedEvent event) {
List<Integer> userIds = event.getUserIds();
if (userIds != null) {
for (Integer userId : userIds) {
if (userId == null) {
continue;
}
evictQuietly(CacheNames.BOA_USER, "id:" + userId);
evictQuietly(CacheNames.BOA_USER_NULL, "id:" + userId);
// 回查当前 account/phone:绝大多数写口(换组/禁用/身份切换等)不改变账号手机号,
// 按当前值驱逐即可精确覆盖 login:/phone: 维度
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user != null) {
if (user.getAccount() != null) {
evictQuietly(CacheNames.BOA_USER, "login:" + user.getAccount());
evictQuietly(CacheNames.BOA_USER_NULL, "login:" + user.getAccount());
}
if (user.getPhone() != null && !user.getPhone().isEmpty()) {
evictQuietly(CacheNames.BOA_USER, "phone:" + user.getPhone());
evictQuietly(CacheNames.BOA_USER_NULL, "phone:" + user.getPhone());
}
} else {
// 用户行已不存在(物理删除):无法反查 account/phone,整清兜底(罕见操作)
clearQuietly(CacheNames.BOA_USER);
clearQuietly(CacheNames.BOA_USER_NULL);
log.warn("[Cache失效] userId={} 已不存在(物理删除) → 整清 boaUser 兜底", userId);
}
}
}
// 改账号/改手机号场景的旧 key(DB 已查不到),由发布方通过 staleKeyHints 精确指示
for (String hint : event.getStaleKeyHints()) {
evictQuietly(CacheNames.BOA_USER, hint);
evictQuietly(CacheNames.BOA_USER_NULL, hint);
}
log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser 精确驱逐完成",
userIds, event.getStaleKeyHints());
}
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================
private void evictQuietly(String cacheName, Object key) {
try {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.evict(key);
}
} catch (Exception e) {
log.error("[Cache失效] 驱逐失败 cache={} key={} 脏数据将存活至TTL后自愈,若频繁出现请排查事件失效链路",
cacheName, key, e);
}
}
private void clearQuietly(String cacheName) {
try {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
} catch (Exception e) {
log.error("[Cache失效] 整清失败 cache={} 脏数据将存活至TTL后自愈,若频繁出现请排查事件失效链路",
cacheName, e);
}
}
}
@@ -1,12 +1,10 @@
package com.yunhai.userbase.service.impl;
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.BoaUser;
import com.yunhai.userbase.entity.*;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.userbase.enums.SysModeEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
@@ -34,8 +32,10 @@ import com.yunhai.userbase.repository.SysAppRoleRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.impl.UserCenterService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.domain.Specification;
@@ -54,6 +54,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author chenx
@@ -83,6 +84,10 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired
private ApplicationInfoRepository applicationInfoRepository;
/** 开放接口读路径经缓存 service 取应用(appInfo 缓存 1h):单条存在性校验与图标直读。 */
@Autowired
private IApplicationInfoService applicationInfoService;
@Autowired
private AppCategoryRepository appCategoryRepository;
@@ -92,6 +97,9 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private MainGroupResolver mainGroupResolver;
@Autowired
private SysUserRepository sysUserRepository;
@@ -105,7 +113,7 @@ public class GroupAuthzService implements IGroupAuthzService {
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
@Autowired
private UserCenterService userCenterService;
private GroupSnapshotService groupSnapshotService;
// ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId)
// =====================
@@ -506,8 +514,9 @@ public class GroupAuthzService implements IGroupAuthzService {
public List<BoaModule> findUserModulesForApp(Integer appId, Integer schId, Integer userId) {
Objects.requireNonNull(appId, "应用ID不能为空");
Objects.requireNonNull(userId, "用户ID不能为空");
// 1. 调用方应用本身存在性校验(数据库过滤第一层:appId 必须真实存在,否则直接空)
ApplicationInfo app = applicationInfoRepository.findById(appId).orElse(null);
// 1. 调用方应用本身存在性校验(数据库过滤第一层:appId 必须真实存在,否则直接空);
// 经 appInfo 缓存 service 读取(命中零 SQL,应用信息属低频变更、事件失效保即时性)
ApplicationInfo app = applicationInfoService.getById(appId);
if (app == null) {
return Collections.emptyList();
}
@@ -523,23 +532,43 @@ public class GroupAuthzService implements IGroupAuthzService {
}
// 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)
Set<Integer> roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId);
// 4. 上下文组解析(schId 非空=只认这一条链路;schId 空=按"主学校→主归属组"规则推断)
Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId);
// 5. 若上下文组有效=构建其祖先链,并判定:该祖先链开通池里是否包含目标 appId(应用开通闸门)
// 上下文组无效(用户无学校/无组)时,仍可能因角色授权而返回模块,不能直接返回空
Set<Integer> selfGroupIds = new LinkedHashSet<>();
// 4. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
// 不把 schId 冒充本组,否则会绕过子组继承闸门、丢本组数据);
// - schId 空:取用户全部真实归属组(与权限一览「用户全局模式」一致)。
List<SysUserGroupRel> userRels = sysUserGroupRelRepository.findByUserId(userId);
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
Set<Integer> selfGroupIds = new LinkedHashSet<>();
if (schId != null) {
for (SysUserGroupRel rel : userRels) {
Integer gid = rel.getGroupId();
if (gid == null) {
continue;
}
if (gid.equals(schId) || ancestorChain(gid, allGroupMap).contains(schId)) {
selfGroupIds.add(gid);
}
}
} else {
for (SysUserGroupRel rel : userRels) {
if (rel.getGroupId() != null) {
selfGroupIds.add(rel.getGroupId());
}
}
}
// 5. 构建各归属组的祖先链与开通池,并判定:任意归属组开通池里是否包含目标 appId(应用开通闸门)。
// 归属组为空(该学校子树内无组 / 无学校无组)时仍可能因角色授权而返回模块,不能直接返回空。
Map<Integer, List<Integer>> chainMap = new HashMap<>();
Set<Integer> allChainIds = new LinkedHashSet<>();
Map<Integer, Set<Integer>> groupToApps = new HashMap<>();
boolean appOpened = false;
if (effectiveGroupId != null) {
selfGroupIds.add(effectiveGroupId);
List<Integer> chain = ancestorChain(effectiveGroupId, allGroupMap);
chainMap.put(effectiveGroupId, chain);
for (Integer gid : selfGroupIds) {
List<Integer> chain = ancestorChain(gid, allGroupMap);
chainMap.put(gid, chain);
allChainIds.addAll(chain);
Set<Integer> openedApps = collectAppIdsForAuthzScope(effectiveGroupId);
groupToApps.put(effectiveGroupId, openedApps);
Set<Integer> openedApps = collectAppIdsForAuthzScope(gid);
groupToApps.put(gid, openedApps);
if (openedApps.contains(appId)) {
appOpened = true;
}
@@ -612,33 +641,6 @@ public class GroupAuthzService implements IGroupAuthzService {
return result;
}
/**
* 上下文组解析(与开放接口 modules 语义严格一致):
* - schId 非空:直接用其作为唯一 selfGroupId;
* - schId 为空:取用户主学校组(BoaUser.school.id);
* - 仍空:取用户主归属组 id(groups 第 0 个);
* - 全空:返回 null(此时只能靠角色授权拿模块)。
*/
private Integer resolveEffectiveGroupId(Integer userId, Integer schId) {
if (schId != null) {
return schId;
}
BoaUser boa = userCenterService.getUserInfoWithDeptById(userId);
if (boa == null) {
return null;
}
BoaGroup school = boa.getSchool();
if (school != null && school.getId() != null) {
return school.getId();
}
List<BoaGroup> groups = boa.getGroups();
if (groups != null && !groups.isEmpty()) {
BoaGroup main = groups.get(0);
return main == null ? null : main.getId();
}
return null;
}
private static Map<Integer, AppModule> buildAppModuleMap(List<AppModule> appModules) {
Map<Integer, AppModule> map = new HashMap<>();
for (AppModule m : appModules) {
@@ -683,17 +685,49 @@ public class GroupAuthzService implements IGroupAuthzService {
return bm;
}
/**
* GetUserMod/oapi 模块列表排序:按 customId(配置编码,沿用旧系统 modId 语义)升序。<br>
* 数字编码按数值比较("2" < "10"),非数字编码按字符串比较,解析并列时回退 id 升序。<br>
* 不再使用 sortNo(空视为999沉底)+id 的重排——sortNo 基本未配置,会把后补建模块(id 较大)
* 擅自沉底,破坏配置编码顺序(如通知公告 1005 应用设置被 1006 通知统计反超)。
*/
private static Comparator<AppModule> moduleOrder() {
return (a, b) -> {
int sa = a.getSortNo() == null ? 999 : a.getSortNo();
int sb = b.getSortNo() == null ? 999 : b.getSortNo();
if (sa != sb) {
return Integer.compare(sa, sb);
int c = compareCustomId(a.getCustomId(), b.getCustomId());
if (c != 0) {
return c;
}
return Integer.compare(a.getId() == null ? 0 : a.getId(), b.getId() == null ? 0 : b.getId());
};
}
/** customId 比较:null 排最后;双方均可解析为数字时按数值比较,否则按字符串比较。 */
private static int compareCustomId(String x, String y) {
if (x == null && y == null) {
return 0;
}
if (x == null) {
return 1;
}
if (y == null) {
return -1;
}
Long nx = parseLongOrNull(x.trim());
Long ny = parseLongOrNull(y.trim());
if (nx != null && ny != null) {
return Long.compare(nx, ny);
}
return x.compareTo(y);
}
private static Long parseLongOrNull(String s) {
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
return null;
}
}
// ===================== 权限一览 =====================
@Override
@@ -729,6 +763,7 @@ public class GroupAuthzService implements IGroupAuthzService {
vo.setAccount(user.getAccount());
vo.setRealName(user.getRealName());
vo.setIsMain(rel.getIsMain());
vo.setGroupId(rel.getGroupId());
vo.setGroupPath(groupPath);
result.add(vo);
}
@@ -737,6 +772,15 @@ public class GroupAuthzService implements IGroupAuthzService {
@Override
public List<UserPermissionUserVo> searchUsers(String keyword) {
return searchUsers(keyword, null);
}
/**
* 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回「归属组与范围有交集」的行,
* 无组关系的用户行一并滤除;scopeGroupIds 为 null(超管)行为与 {@link #searchUsers(String)} 一致。
*/
@Override
public List<UserPermissionUserVo> searchUsers(String keyword, Set<Integer> scopeGroupIds) {
if (keyword == null) {
keyword = "";
}
@@ -750,40 +794,51 @@ public class GroupAuthzService implements IGroupAuthzService {
return Collections.emptyList();
}
// 加载所有组,用于构建主归属路径
// 加载所有组,用于构建各组的组织路径
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
// 加载用户与组关联,找到每个用户的主归属组
Set<Integer> userIds = new LinkedHashSet<>();
for (SysUser user : users) {
userIds.add(user.getId());
}
Map<Integer, SysUserGroupRel> mainRelMap = new HashMap<>();
for (SysUserGroupRel rel : sysUserGroupRelRepository.findAll()) {
if (rel.getUserId() == null || !userIds.contains(rel.getUserId())) {
continue;
}
SysUserGroupRel existing = mainRelMap.get(rel.getUserId());
// 优先取主归属(isMain=1),否则保留第一个
if (existing == null || (existing.getIsMain() == null || existing.getIsMain() != 1)
&& rel.getIsMain() != null && rel.getIsMain() == 1) {
mainRelMap.put(rel.getUserId(), rel);
}
}
// 同一用户在多个组时每个组出一行(各自组路径/主兼职标记),
// 不再只解析主归属组——否则多组用户只能看到主组一条数据
Map<Integer, List<SysUserGroupRel>> relsByUser = sysUserGroupRelRepository
.findByUserIdIn(new ArrayList<>(userIds))
.stream()
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
List<UserPermissionUserVo> result = new ArrayList<>();
for (SysUser user : users) {
SysUserGroupRel mainRel = mainRelMap.get(user.getId());
String path = "";
if (mainRel != null && mainRel.getGroupId() != null) {
path = buildGroupPath(mainRel.getGroupId(), allGroupMap);
List<SysUserGroupRel> userRels = relsByUser.get(user.getId());
if (userRels == null || userRels.isEmpty()) {
// 无组关系的用户仍出一行(组路径空),保持可选中查看权限;
// 范围过滤模式下无交集则整行丢弃
if (scopeGroupIds != null) {
continue;
}
UserPermissionUserVo vo = new UserPermissionUserVo();
vo.setId(user.getId());
vo.setAccount(user.getAccount());
vo.setRealName(user.getRealName());
vo.setGroupPath("");
result.add(vo);
continue;
}
for (SysUserGroupRel rel : userRels) {
if (scopeGroupIds != null
&& (rel.getGroupId() == null || !scopeGroupIds.contains(rel.getGroupId()))) {
continue;
}
UserPermissionUserVo vo = new UserPermissionUserVo();
vo.setId(user.getId());
vo.setAccount(user.getAccount());
vo.setRealName(user.getRealName());
vo.setIsMain(rel.getIsMain());
vo.setGroupId(rel.getGroupId());
vo.setGroupPath(rel.getGroupId() != null
? buildGroupPath(rel.getGroupId(), allGroupMap) : "");
result.add(vo);
}
UserPermissionUserVo vo = new UserPermissionUserVo();
vo.setId(user.getId());
vo.setAccount(user.getAccount());
vo.setRealName(user.getRealName());
vo.setIsMain(mainRel != null ? mainRel.getIsMain() : null);
vo.setGroupPath(path);
result.add(vo);
}
return result;
}
@@ -1038,13 +1093,16 @@ public class GroupAuthzService implements IGroupAuthzService {
if (!sysUserRepository.existsById(userId)) {
throw new DException("用户不存在");
}
// 1. 用户直接归属的组
// 1. 用户直接归属的组;主归属组(isMain=1,无主兜底 id 最小)用于「本组」标记
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
Set<Integer> selfGroupIds = new LinkedHashSet<>();
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
Integer mainGroupId = mainRel != null ? mainRel.getGroupId() : null;
for (SysUserGroupRel rel : rels) {
if (rel.getGroupId() != null) {
selfGroupIds.add(rel.getGroupId());
if (rel.getGroupId() == null) {
continue;
}
selfGroupIds.add(rel.getGroupId());
}
if (selfGroupIds.isEmpty()) {
return Collections.emptyList();
@@ -1065,7 +1123,7 @@ public class GroupAuthzService implements IGroupAuthzService {
UserGroupNodeVo vo = new UserGroupNodeVo();
vo.setId(id);
vo.setName(g == null || g.getName() == null ? String.valueOf(id) : g.getName());
vo.setOwn(selfGroupIds.contains(id));
vo.setOwn(mainGroupId != null && id.equals(mainGroupId));
voMap.put(id, vo);
}
List<UserGroupNodeVo> roots = new ArrayList<>();
@@ -1348,12 +1406,13 @@ public class GroupAuthzService implements IGroupAuthzService {
};
}
/**
* 全表组索引(id → 组):取自 groupSnapshot 本地快照(30min TTL + 组写事件驱逐),
* 消除权限链路(collectAppIdsForAuthzScope / findUserModulesForApp / 权限一览等)中
* 按用户归属组循环重复执行的全表 findAll。仅限只读路径调用。
*/
private Map<Integer, SysGroup> buildAllGroupMap() {
Map<Integer, SysGroup> map = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
map.put(g.getId(), g);
}
return map;
return groupSnapshotService.snapshot().getById();
}
private String buildGroupPath(Integer groupId, Map<Integer, SysGroup> allGroupMap) {
@@ -1417,11 +1476,8 @@ public class GroupAuthzService implements IGroupAuthzService {
if (userRels.isEmpty()) {
return false;
}
// 2. 全部组载入内存(树深度有限),供祖先链回溯
Map<Integer, SysGroup> allGroupMap = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
allGroupMap.put(g.getId(), g);
}
// 2. 全部组载入内存(树深度有限,取自快照),供祖先链回溯
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
// 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 就近来源模块权限 ∩ 已开通应用);
// 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询
Map<Integer, List<Integer>> chainMap = new HashMap<>();
@@ -1599,4 +1655,40 @@ public class GroupAuthzService implements IGroupAuthzService {
}
return moduleIds;
}
@Override
public List<Integer> findUserAuthorizedAppIds(Integer userId) {
Objects.requireNonNull(userId, "用户ID不能为空");
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
throw new DException("用户不存在");
}
// 超级管理员:全部应用
if (isSuperAdmin(user.getType(), userId)) {
List<Integer> allIds = new ArrayList<>();
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
allIds.add(app.getId());
}
return allIds;
}
Set<Integer> appIds = new LinkedHashSet<>();
// 1. 组开通应用(用户所属组沿祖先链的开通池并集)
List<SysUserGroupRel> userRels = sysUserGroupRelRepository.findByUserId(userId);
for (SysUserGroupRel rel : userRels) {
if (rel.getGroupId() == null) {
continue;
}
appIds.addAll(collectAppIdsForAuthzScope(rel.getGroupId()));
}
// 2. 启用角色模块所属应用(角色授权不要求组先开通应用)
Set<Integer> roleModuleIds = collectEnabledRoleModuleIds(userId);
if (!roleModuleIds.isEmpty()) {
for (AppModule m : appModuleRepository.findAllById(roleModuleIds)) {
if (m.getAppId() != null) {
appIds.add(m.getAppId());
}
}
}
return new ArrayList<>(appIds);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.model.GroupSnapshot;
import com.yunhai.userbase.repository.SysGroupRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* sys_group 全表快照读取服务({@code groupSnapshot} 缓存的唯一入口)。<br><br>
*
* 消除权限链路中按用户归属组循环重复执行的全表 {@code findAll()}:
* 一次 findAll 建索引(几十毫秒)后,同一快照内所有祖先链判定都是内存 Map 操作。<br>
* TTL 30min + GroupChangedEvent 事务提交后驱逐;{@code sync=true} 保证驱逐后并发读只重建一次。<br>
* <b>仅限只读路径使用</b>:组域写操作的事务内校验(环检测/边界校验)必须直查库,不能吃旧快照。
*/
@Service
public class GroupSnapshotService {
@Autowired
private SysGroupRepository sysGroupRepository;
/**
* 取全表快照(整体驱逐+重建,固定单 key)。
* 调用方通过本方法(经代理)访问才会命中缓存,禁止同类内部自调用。
*/
@Cacheable(value = CacheNames.GROUP_SNAPSHOT, key = "'all'", sync = true)
public GroupSnapshot snapshot() {
return new GroupSnapshot(sysGroupRepository.findAll());
}
}
@@ -1,26 +1,35 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
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.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 用户身份服务:管理「平台身份角色 × 学校」维度的当前激活状态与可选清单。<br><br>
@@ -40,63 +49,59 @@ public class IdentityService {
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private MainGroupResolver mainGroupResolver;
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private ApplicationEventPublisher eventPublisher;
/**
* 用户全部可选身份(正常态),用于「切换身份」页渲染。
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。
*/
public List<IdentityOptionVo> listAvailable(Integer userId) {
List<SysUserRoleRel> rels = sysUserRoleRelRepository
.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
List<IdentityOptionVo> result = new ArrayList<>(rels.size());
for (SysUserRoleRel rel : rels) {
IdentityOptionVo vo = new IdentityOptionVo();
vo.setRoleId(rel.getRoleId());
vo.setRoleName(roleName(rel.getRoleId()));
vo.setSchoolId(rel.getSchoolId());
vo.setSchoolName(schoolName(rel.getSchoolId()));
vo.setIsMain(rel.getIsMain() != null && rel.getIsMain() == 1);
result.add(vo);
public List<BoaRoleInfo> listAvailable(Integer userId) {
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
SysUserRoleRel main = resolveMainIdentity(userId);
List<SysUserRoleRel> sorted = rels.stream()
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
main != null && Objects.equals(r.getId(), main.getId()) ? 0 : 1)
.thenComparingInt(SysUserRoleRel::getSortNo)
.thenComparingInt(SysUserRoleRel::getId))
.collect(Collectors.toList());
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
for (SysUserRoleRel rel : sorted) {
result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId()));
}
return result;
}
/**
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(isMain=1)。
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
* 连主身份都没有时返回 null(前端引导创建身份)。
*/
public IdentityCurrentVo getCurrent(Integer userId) {
public BoaRoleInfo getCurrent(Integer userId) {
Optional<SysUserCurrentRole> currentOpt = sysUserCurrentRoleRepository.findByUserId(userId);
IdentityCurrentVo vo = new IdentityCurrentVo();
if (currentOpt.isPresent()) {
SysUserCurrentRole current = currentOpt.get();
vo.setRoleId(current.getRoleId());
vo.setRoleName(roleName(current.getRoleId()));
vo.setSchoolId(current.getSchoolId());
vo.setSchoolName(current.getSchoolName());
vo.setFallback(false);
return vo;
return toBoaRoleInfo(current.getRoleId(), current.getSchoolId(), current.getSchoolName());
}
// 兜底:主身份整条记录(isMain=1)
SysUserRoleRel mainRel = sysUserRoleRelRepository.findByUserIdAndIsMain(userId, 1).orElse(null);
// 兜底:主身份整条记录(主组 → 主校 → 该校 sortNo/id 最小行)
SysUserRoleRel mainRel = resolveMainIdentity(userId);
if (mainRel != null) {
vo.setRoleId(mainRel.getRoleId());
vo.setRoleName(roleName(mainRel.getRoleId()));
vo.setSchoolId(mainRel.getSchoolId());
vo.setSchoolName(schoolName(mainRel.getSchoolId()));
vo.setFallback(true);
return vo;
return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId());
}
// 连主身份都没有(测试期初数据尚未建立):返回空对象,前端引导创建身份
vo.setFallback(true);
return vo;
// 连主身份都没有(测试期初数据尚未建立):返回 null
return null;
}
/**
* 切换身份:校验 (userId, roleId, schoolId) 在 sys_user_role_rel 正常态中存在,
* 再 UPSERT sys_user_current_role(按 userId 唯一)。
*/
public IdentityCurrentVo switchIdentity(Integer userId, IdentitySwitchRequest request) {
Integer roleId = request.getRoleId();
Integer schoolId = request.getSchoolId();
public BoaRoleInfo switchIdentity(Integer userId, Integer roleId, Integer schoolId) {
// 校验目标身份确属该用户且正常态,防越权切换
SysUserRoleRel rel = sysUserRoleRelRepository
.findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL)
@@ -119,13 +124,82 @@ public class IdentityService {
current.setSwitchTime(LocalDateTime.now());
sysUserCurrentRoleRepository.save(current);
IdentityCurrentVo vo = new IdentityCurrentVo();
vo.setRoleId(roleId);
vo.setRoleName(roleName(roleId));
vo.setSchoolId(schoolId);
vo.setSchoolName(current.getSchoolName());
vo.setFallback(false);
return vo;
// 身份切换后 boaUser.currentRole 必须立即失效(缓存失效硬约束:脏 currentRole = 用户以错误身份操作)。
// 本方法无 @Transactional(单条 save 自动提交),监听器 fallbackExecution=true 保证事件不被吞。
eventPublisher.publishEvent(new UserChangedEvent(this,
Collections.singletonList(userId), null));
return toBoaRoleInfo(roleId, schoolId, current.getSchoolName());
}
/**
* 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):<br>
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
* 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。<br>
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。
*/
public SysUserRoleRel resolveMainIdentity(Integer userId) {
SysUserGroupRel mainGroup = mainGroupResolver.resolve(userId).orElse(null);
if (mainGroup == null) {
return null;
}
Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId());
return pickMainIdentity(mainSchoolId, sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL));
}
/**
* 批量主身份解析:列表页身份名列 + 导出用,全程 2 次组查询 + 1 次身份行 IN 查询(无 N+1)。<br>
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
*/
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
Map<Integer, SysUserRoleRel> result = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return result;
}
Map<Integer, SysUserGroupRel> mainGroupMap = mainGroupResolver.resolveByUserIds(userIds);
if (mainGroupMap.isEmpty()) {
return result;
}
Map<Integer, Integer> schoolByGroup = sysGroupService.resolveSchoolIds(
mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList()));
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
for (Map.Entry<Integer, SysUserGroupRel> e : mainGroupMap.entrySet()) {
Integer mainSchoolId = schoolByGroup.get(e.getValue().getGroupId());
if (mainSchoolId == null) {
continue;
}
SysUserRoleRel main = pickMainIdentity(mainSchoolId,
roleRelsByUser.getOrDefault(e.getKey(), Collections.emptyList()));
if (main != null) {
result.put(e.getKey(), main);
}
}
return result;
}
/** 在身份行列表中取主校行:schoolId 匹配主校,sortNo 最小、并列取 id 最小;无匹配返回 null */
private SysUserRoleRel pickMainIdentity(Integer mainSchoolId, List<SysUserRoleRel> rels) {
return rels.stream()
.filter(r -> Objects.equals(r.getSchoolId(), mainSchoolId))
.min(Comparator.comparingInt(SysUserRoleRel::getSortNo)
.thenComparingInt(SysUserRoleRel::getId))
.orElse(null);
}
/** 组装 BoaRoleInfo(childId/childName 家长-孩子维度本期不做,恒空) */
private BoaRoleInfo toBoaRoleInfo(Integer roleId, Integer schoolId) {
return toBoaRoleInfo(roleId, schoolId, schoolName(schoolId));
}
private BoaRoleInfo toBoaRoleInfo(Integer roleId, Integer schoolId, String schoolName) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setRoleName(roleName(roleId));
info.setSchId(schoolId);
info.setSchName(schoolName);
return info;
}
private String roleName(Integer roleId) {
@@ -0,0 +1,30 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.envconfig.EnvConfigSource;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
/**
* env-config 数据源实现:整站已停用 nacos 配置中心,改为从 Spring Environment 读取静态属性。
* 键 = {@code yh.env-config.source.<target>},三处可配(优先级由 Environment 决定):
* 1) 启动参数 --yh.env-config.source.front='{...}'
* 2) 环境变量 YH_ENV_CONFIG_SOURCE_FRONT(projman .env / Gitea Secrets 注入,宽松绑定)
* 3) application.yml 内 yh.env-config.source.front 键
* 未配置返回 null,平台 EnvConfigService 会归一为 "{}" 返回(前端走同源推断兜底,接口保持 200)。
*/
@Service
public class PropertyEnvConfigSource implements EnvConfigSource {
private static final String KEY_PREFIX = "yh.env-config.source.";
private final Environment environment;
public PropertyEnvConfigSource(Environment environment) {
this.environment = environment;
}
@Override
public String get(String target) {
return environment.getProperty(KEY_PREFIX + target);
}
}

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