Compare commits

..
52 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
chenxing d472902957 推送订阅添加批量删除功能
ci-userbase-service / build-and-push (push) Successful in 1m56s
2026-08-19 20:38:12 +08:00
chenxing f3656057d4 推送订阅添加批量删除功能 2026-08-19 20:38:00 +08:00
chenxing a91dacd442 推送订阅更改
ci-userbase-service / build-and-push (push) Successful in 2m9s
2026-08-19 17:35:55 +08:00
chenxing 4c67ec0ed0 用户删除订阅功能更新
ci-userbase-service / build-and-push (push) Successful in 45s
2026-08-19 17:22:38 +08:00
chenxing b521f9634b spring doc文档更新
ci-userbase-service / build-and-push (push) Successful in 43s
2026-08-19 12:01:52 +08:00
chenxing 2ca67730f7 修改文档传递
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-19 11:28:47 +08:00
chenxing 44f966c33b 其他接口
ci-userbase-service / build-and-push (push) Successful in 4m7s
2026-08-18 21:51:18 +08:00
chenxing 52b29c5082 编写开放接口,组/用户,移动组等功能
ci-userbase-service / build-and-push (push) Successful in 2m17s
2026-08-18 21:46:06 +08:00
chenxing 7a1dc29ff7 推送日志列表去除payload字段并新增下载接口
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-18 00:04:07 +08:00
chenxing 60360bfb6b 操作日志修改
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-17 20:13:17 +08:00
chenxing ac95b2463b jack配置文件上传
ci-userbase-service / build-and-push (push) Successful in 43s
2026-08-17 19:13:25 +08:00
chenxing 6543203bc6 修改角色错乱问题
ci-userbase-service / build-and-push (push) Successful in 42s
2026-08-17 18:54:44 +08:00
chenxing 05c7741954 修改部分功能
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-17 16:39:00 +08:00
chenxing 165d46b156 ci
ci-userbase-service / build-and-push (push) Successful in 43s
2026-08-14 16:14:15 +08:00
chenxing 87163ba944 修改modules的bug
ci-userbase-service / build-and-push (push) Successful in 1m31s
2026-08-14 15:20:21 +08:00
chenxing 236c9545f9 修复bug
ci-userbase-service / build-and-push (push) Successful in 44s
2026-08-14 09:21:53 +08:00
chenxing 8ff6e7ac0c 推送订阅功能
ci-userbase-service / build-and-push (push) Successful in 49s
2026-08-14 09:05:34 +08:00
chenxing e55c2cb209 推送订阅功能 2026-08-14 09:03:17 +08:00
chenxing 0233ecb525 测试ci日志
ci-userbase-service / build-and-push (push) Successful in 2m46s
2026-08-13 22:17:35 +08:00
chenxing 767ad3a7dd 推送ci
ci-userbase-service / build-and-push (push) Successful in 1m49s
2026-08-13 21:39:42 +08:00
chenxing 00278d997a 推送ci
ci-userbase-service / build-and-push (push) Successful in 2m46s
2026-08-13 21:30:19 +08:00
chenxing b5f6679eb5 缺失的文件补传
ci-userbase-service / build-and-push (push) Successful in 1m18s
2026-08-13 19:19:58 +08:00
chenxing 5cfed17956 开放接口新增获取组数据相关接口 2026-08-13 19:15:47 +08:00
chenxing 8113045471 修复排序bug
ci-userbase-service / build-and-push (push) Successful in 41s
2026-08-13 08:15:03 +08:00
chenxing 3855ea2eca 修复排序bug
ci-userbase-service / build-and-push (push) Successful in 2m22s
2026-08-12 21:33:08 +08:00
chenxing cfee8012a6 修复排序bug
ci-userbase-service / build-and-push (push) Successful in 1m59s
2026-08-12 21:21:12 +08:00
chenxing ec407970e0 修复排序bug
ci-userbase-service / build-and-push (push) Successful in 2m5s
2026-08-12 20:57:18 +08:00
chenxing c35a9fa47e 修复排序bug 2026-08-12 20:55:02 +08:00
168 changed files with 11844 additions and 920 deletions
+45 -99
View File
@@ -1,21 +1,14 @@
name: ci-userbase-service name: ci-userbase-service
# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted # 后端标准化 CI(projman 发布链路版):
# 触发:push 到 main(本仓库为 UserBaseService 独立仓,代码在根目录)/ 手动 Run workflow # maven 构建 jar -> docker build -> 推 Nexus docker-hosted -> webhook 通知 projman 建档
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号,仅 docker-hosted 推送权) # 部署由 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: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch: 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: jobs:
build-and-push: build-and-push:
@@ -27,25 +20,28 @@ jobs:
container: container:
image: docker:24 image: docker:24
steps: steps:
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库 # docker:24 基于 alpine,默认无 git/curl;先装再手动 clone 本仓库
- name: Install git (alpine) - name: Install git/curl (alpine)
run: apk add --no-cache git 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 本仓库 # 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea) - name: Checkout (manual, internal Gitea)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | 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/. . cp -a /tmp/src/. .
rm -rf /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 - name: Compute image tag
id: meta id: meta
run: | run: echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
if [ "${{ inputs.tag }}" = "auto" ] || [ -z "${{ inputs.tag }}" ]; then
echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
else
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
fi
- name: Build jar with Maven - name: Build jar with Maven
run: | run: |
@@ -55,28 +51,26 @@ jobs:
# 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。 # 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。
# 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。 # 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。
# mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。 # mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。
# 之前用 --add-host=nexus.yunhaixingtong.com:host-gateway 经 Caddy:80 反代,但该路径在 # settings.xml profile 把 pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/。
# CI 容器内不可达,maven 回退到 /opt/docker/ubs-m2 里 08-05 的旧 yh-auth 缓存(缺 # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# CasSsoAutoConfiguration),镜像启动报 serverName 崩溃。现改用 settings.xml profile 把 # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/(已验证 -7 可达)。 # 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# 先清理上次失败残留的同名容器(set -e 下末尾的 rm 不会执行) # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
docker rm -f mavenbuild 2>/dev/null || true BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
docker run -d --name mavenbuild \ echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
-v /opt/docker/ubs-m2:/root/.m2 \ -v /opt/docker/ubs-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/ docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
# 写 settings.xml:新增 profile 把 nexus:8081 maven-public 作为额外仓库(infra-infra-net 内容器名直连,已验证 -7 可达)。 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"
# 不用 mirror(external:http:*) 是因为它对 BOM import 解析未生效;profile 方式更可靠。 # 仅清旧 yh-auth 缓存(缺 CasSsoAutoConfiguration 的 08-05 本地安装版),强制从 Nexus 拉 -7
# pom 原有的 nexus.yunhaixingtong.com(Caddy:80) 在 CI 容器内不可解析 -> 快速失败,maven 转而用本仓库。 docker exec "$BUILD_CONTAINER" rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
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" docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -U -B -DskipTests clean package'
# 仅清旧 yh-auth 缓存(含 08-05 local install 的 maven-metadata-local.xml,避免本地安装优先于远端 -7);
# BOM 等其它 yh.platform 模块保留缓存(Nexus 上它们仍是 08-05 的 -1,缓存与远端一致)。
docker exec mavenbuild rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
docker exec mavenbuild sh -c 'cd /build && mvn -U -B -DskipTests clean package'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/userbase.jar "${PWD}/target/userbase.jar" docker cp "$BUILD_CONTAINER":/build/target/userbase.jar "${PWD}/target/userbase.jar"
docker rm -f mavenbuild docker rm -f "$BUILD_CONTAINER"
- name: Login to Nexus docker registry - name: Login to Nexus docker registry
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
@@ -87,64 +81,16 @@ jobs:
- name: Push image - name: Push image
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} 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 - name: Report build
run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}" 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 \
-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 \
-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
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
</settings>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>nexus-releases</id>
<username>admin</username>
<password>@4Jo8iDj10uSHvg</password>
</server>
<server>
<id>nexus-snapshots</id>
<username>admin</username>
<password>@4Jo8iDj10uSHvg</password>
</server>
</servers>
<mirrors>
<mirror>
<id>nexus-public</id>
<name>Nexus Public Group</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<mirrorOf>*</mirrorOf>
<allowInsecureProtocol>true</allowInsecureProtocol>
</mirror>
</mirrors>
</settings>
+40
View File
@@ -0,0 +1,40 @@
-- ============================================================
-- 清理孤儿业务角色(指向不存在的应用)
-- 场景:角色授权页 RoleModuleTree?roleId=2 报 "角色所属应用不存在: 3"
-- 根因:sys_app_role 中存在 all_app_flag=0 的普通角色,其 app_id 指向
-- application_info 中已不存在的应用(应用被删,当时无级联清理)。
-- 说明:本脚本只读诊断 + 备份 + 清理,请先执行第 1 步确认,再执行第 2/3 步。
-- ============================================================
-- 1) 诊断:列出全部"指向不存在应用"的普通角色(孤儿角色)
SELECT r.id AS role_id,
r.name AS role_name,
r.app_id AS missing_app_id
FROM sys_app_role r
WHERE r.all_app_flag = 0
AND r.app_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM application_info a WHERE a.id = r.app_id
);
-- 2) 备份(执行清理前先保留,便于回滚)
CREATE TABLE IF NOT EXISTS _bak_orphan_role LIKE sys_app_role;
INSERT INTO _bak_orphan_role
SELECT * FROM sys_app_role r
WHERE r.all_app_flag = 0
AND r.app_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM application_info a WHERE a.id = r.app_id
);
CREATE TABLE IF NOT EXISTS _bak_orphan_role_rel LIKE sys_app_role_module_rel;
INSERT INTO _bak_orphan_role_rel
SELECT * FROM sys_app_role_module_rel
WHERE role_id IN (SELECT id FROM _bak_orphan_role);
-- 3) 清理:先删模块授权关系,再删孤儿角色本身
DELETE FROM sys_app_role_module_rel
WHERE role_id IN (SELECT id FROM _bak_orphan_role);
DELETE FROM sys_app_role
WHERE id IN (SELECT id FROM _bak_orphan_role);
+27 -12
View File
@@ -1,4 +1,4 @@
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底) # UserBase 生产配置(镜像内自足:配置中心已停用,运行时密码由 Gitea Secrets / projman .env 环境变量注入)
server: server:
port: 8060 port: 8060
servlet: servlet:
@@ -20,8 +20,7 @@ spring:
host: redis-server host: redis-server
port: 6379 port: 6379
database: 0 database: 0
# 生产由 nacos application-infra.yaml 注入;此处仅作 nacos 不可达时的兜底, # 兜底密码运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets / projman .env,不入镜像)
# 密码不写死,运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets,不入镜像)
password: ${REDIS_PASSWORD} password: ${REDIS_PASSWORD}
timeout: 2000ms timeout: 2000ms
data: data:
@@ -29,14 +28,14 @@ spring:
repositories: repositories:
enabled: false enabled: false
# datasource: # 镜像内兜底:db 密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets / projman .env,不入镜像)
# url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true datasource:
# username: root url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
# # 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets) username: root
# password: ${DB_PASSWORD} password: ${DB_PASSWORD}
# driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
# min-idle: 10 min-idle: 10
# max-active: 30 max-active: 30
jpa: jpa:
database: MySQL database: MySQL
@@ -54,7 +53,7 @@ spring:
host: rabbitmq host: rabbitmq
port: 5672 port: 5672
username: admin username: admin
# 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(来自 Gitea Secrets) # 兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(值来自 Gitea Secrets / projman .env,不入镜像)
password: ${RABBITMQ_PASSWORD} password: ${RABBITMQ_PASSWORD}
listener: listener:
direct: direct:
@@ -62,6 +61,20 @@ spring:
simple: simple:
auto-startup: false 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-auth / yh-app-auth)
yh: yh:
sso: sso:
@@ -84,3 +97,5 @@ UPLOAD_WEB_PATH_HTTP: http://base.yunhaixingtong.com/userbase/upload/file/
logging: logging:
level: level:
root: INFO root: INFO
file:
path: /app/logs
+3 -4
View File
@@ -1,10 +1,9 @@
#!/bin/sh #!/bin/sh
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) # UserBase 启动入口:配置中心(nacos)已停用,生产配置=镜像内烤好的 application-prod.yml
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 # + 运行时环境变量注入(Gitea Secrets / projman .env:DB_PASSWORD/REDIS_PASSWORD/RABBITMQ_PASSWORD)。
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e 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 \ exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
--spring.profiles.active=prod --spring.profiles.active=prod
+8
View File
File diff suppressed because one or more lines are too long
+22 -15
View File
@@ -31,14 +31,6 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </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> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -76,23 +68,31 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 --> <!-- 健康检查端点 /actuator/health:供 projman 健康总览探活(容器间直连,不走 Caddy/鉴权) -->
<dependency> <dependency>
<groupId>com.alibaba.cloud</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点:暴露 /actuator/prometheus 供压测观测(方案 §2.5)。
压测结束后整段删除,并同步回退 application.yml 的 management 段。见《埋点移除清单.md》。 -->
<dependency> <dependency>
<groupId>org.springframework.cloud</groupId> <groupId>io.micrometer</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId> <artifactId>micrometer-registry-prometheus</artifactId>
<version>3.1.8</version>
</dependency> </dependency>
<!-- [LOADTEST-METRICS-END] -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> </dependency>
<!-- RabbitMQ:推送订阅消息队列(串行消费 + 死信重试)。版本由 Boot 2.7 父 POM 统一管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) --> <!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -188,6 +188,13 @@
<groupId>com.yunhai.platform</groupId> <groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</artifactId> <artifactId>yh-api-std</artifactId>
</dependency> </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 过滤器注册与 <!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 --> SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/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). // that act_runner already mounts into the job container (/var/run/docker.sock).
// //
// Why this script exists: // Why this script exists:
@@ -153,8 +153,8 @@ async function flushDebug() {
function resolveDistPath() { function resolveDistPath() {
const candidates = [ const candidates = [
path.join(process.cwd(), 'front', 'dist', 'prod'), path.join(process.cwd(), 'front', 'dist'),
'/workspace/admin/userbase/front/dist/prod', '/workspace/admin/userbase/front/dist',
]; ];
for (const c of candidates) { for (const c of candidates) {
if (fs.existsSync(c)) return c; if (fs.existsSync(c)) return c;
@@ -201,13 +201,13 @@ async function main() {
} catch (e) { } catch (e) {
dbg('ls err: ' + e.message); 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 dir=' + distDir);
dbg('dist listing: ' + fs.readdirSync(distDir).join(' ')); dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
// 3) tar of the built assets, fetched from THIS container's own filesystem. // 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 // folder; we then mv it into place (deterministic, no reliance on the
// trailing-slash "contents only" quirk which this moby build ignores). // trailing-slash "contents only" quirk which this moby build ignores).
const tar = await getArchive(jobId, distDir); const tar = await getArchive(jobId, distDir);
@@ -226,10 +226,10 @@ async function main() {
throw new Error('put archive failed: ' + put.status + ' ' + put.body); 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( const mv = await runExec(
HELPER_ID, 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, ' | ')); dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
@@ -237,12 +237,12 @@ async function main() {
const verify = await runExec( const verify = await runExec(
HELPER_ID, HELPER_ID,
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` + `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` `ls -la ${HOST_STATIC}/front | head -20`
); );
dbg('verify output: ' + verify.body.replace(/\n/g, ' | ')); 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() 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 @Autowired
private ISysOperationLogService sysOperationLogService; 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 { public Object around(ProceedingJoinPoint point) throws Throwable {
// 排除日志查看接口自身 // 排除日志查看接口自身
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) { 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.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException; 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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException; import javax.validation.ConstraintViolationException;
@@ -52,6 +54,18 @@ public class GlobalExceptionHandler {
return paramError(msg); 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) { private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
if (fieldErrors == null || fieldErrors.isEmpty()) { if (fieldErrors == null || fieldErrors.isEmpty()) {
return "参数校验失败"; 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;
}
@@ -0,0 +1,27 @@
package com.yunhai.userbase.common.context;
/**
* 开放接口(oapi)调用方来源上下文:在 oapi 请求链路中保存"触发本次数据变更的订阅方 appKey",
* 供业务层发布 {@code DataSyncEvent} 时写入事件来源,推送分发器据此回推时排除来源应用自身
* (避免应用 A 自己调 oapi 写接口后又收到自己的回声)。<br><br>
* <b>生命周期:</b>由 {@code OapiCallerAppInterceptor} 在 {@code preHandle} 写入、
* {@code afterCompletion} 强制清除(防线程池复用泄漏)。管理后台 /api/* 写接口不走 oapi 拦截器,
* 此处为 null,表示"来源非订阅方",推送不对其做来源排除(全量投递)。<br><br>
* 设计与 {@link UserContext} 同套路(ThreadLocal + remove)。
*/
public class CallerAppContext {
private static final ThreadLocal<Integer> CALLER_APP_KEY = new ThreadLocal<>();
public static void setAppKey(Integer appKey) {
CALLER_APP_KEY.set(appKey);
}
public static Integer getAppKey() {
return CALLER_APP_KEY.get();
}
public static void clear() {
CALLER_APP_KEY.remove();
}
}
@@ -1,6 +1,6 @@
package com.yunhai.userbase.common.converter; package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.ClientEnum; import com.yunhai.platform.utils.enums.ClientEnum;
import javax.persistence.AttributeConverter; import javax.persistence.AttributeConverter;
@@ -1,6 +1,6 @@
package com.yunhai.userbase.common.converter; package com.yunhai.userbase.common.converter;
import com.yunhai.userbase.enums.IEnum; import com.yunhai.platform.utils.enums.IEnum;
import javax.persistence.AttributeConverter; 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.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.context.UserContext; import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.impl.UserCenterService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -44,5 +45,7 @@ public class BoaUserContextInterceptor implements HandlerInterceptor {
HttpServletResponse response, HttpServletResponse response,
Object handler, Exception ex) { Object handler, Exception ex) {
UserContext.clear(); 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;
}
}
@@ -4,11 +4,20 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; 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.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 com.yunhai.userbase.utils.ConstantUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache; import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -16,62 +25,123 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager; 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.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; 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 org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration; import java.time.Duration;
import java.util.Collection; 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.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit; 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 @EnableCaching
@Configuration @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 @Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) { public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper(); jacksonSerializer.setObjectMapper(newCacheObjectMapper());
// 允许任意字段可见,保证反序列化不依赖 getter/setter
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失)
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jacksonSerializer.setObjectMapper(mapper);
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() RedisCacheConfiguration baseConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期 .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeKeysWith(RedisSerializationContext.SerializationPair .serializeValuesWith(SerializationPair.fromSerializer(jacksonSerializer))
.fromSerializer(new StringRedisSerializer())) // key 用 String .disableCachingNullValues();
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jacksonSerializer)) // value 用 Jackson
.disableCachingNullValues(); // 禁止缓存 null(防穿透)
return RedisCacheManager.builder(factory) Map<String, RedisCacheConfiguration> perCache = new HashMap<>();
.cacheDefaults(cacheConfig) 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(); .build();
} }
/** /**
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。 * 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。<br>
* 按字节限重(原 maximumSize(10000) 条数统计会被巨型条目欺骗),权重为保守启发式估算。
*/ */
@Bean @Bean
public CaffeineCacheManager caffeineCacheManager() { public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager(); CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder() manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致 .expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 未注册名默认 TTL 保持一致
.maximumSize(10000) // 限制本地容量,防止内存溢出 .maximumWeight(CAFFEINE_MAX_WEIGHT_BYTES)
.weigher((Weigher<Object, Object>) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4)))
.recordStats()); .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; return manager;
} }
@@ -93,24 +163,63 @@ public class CacheConfig {
} }
/** /**
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。 * 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。<br>
* 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。 * 注册表 LOCAL 存放的缓存恒路由本地实例;其余优先 Redis,
* Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
*/ */
@Primary @Primary
@Bean @Bean
public CacheManager cacheManager(RedisCacheManager redisCacheManager, public CacheManager cacheManager(RedisCacheManager redisCacheManager,
CaffeineCacheManager caffeineCacheManager, CaffeineCacheManager caffeineCacheManager,
CaffeineCacheManager localCacheManager,
RedisConnectionFactory redisConnectionFactory) { 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,无业务报错。 * 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。
*/ */
static class FallbackCacheManager implements CacheManager { public static class FallbackCacheManager implements CacheManager {
private final RedisCacheManager redisManager; private final RedisCacheManager redisManager;
private final CaffeineCacheManager caffeineManager; private final CaffeineCacheManager caffeineManager;
private final CaffeineCacheManager localManager;
private final RedisConnectionFactory connectionFactory; private final RedisConnectionFactory connectionFactory;
private final ScheduledExecutorService healthScheduler = private final ScheduledExecutorService healthScheduler =
Executors.newSingleThreadScheduledExecutor(r -> { Executors.newSingleThreadScheduledExecutor(r -> {
@@ -122,9 +231,11 @@ public class CacheConfig {
FallbackCacheManager(RedisCacheManager redisManager, FallbackCacheManager(RedisCacheManager redisManager,
CaffeineCacheManager caffeineManager, CaffeineCacheManager caffeineManager,
CaffeineCacheManager localManager,
RedisConnectionFactory connectionFactory) { RedisConnectionFactory connectionFactory) {
this.redisManager = redisManager; this.redisManager = redisManager;
this.caffeineManager = caffeineManager; this.caffeineManager = caffeineManager;
this.localManager = localManager;
this.connectionFactory = connectionFactory; this.connectionFactory = connectionFactory;
if (connectionFactory != null) { if (connectionFactory != null) {
// 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回) // 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回)
@@ -156,8 +267,17 @@ public class CacheConfig {
} }
} }
/** Redis 是否健康(对账接口上报降级状态用)。 */
public boolean isRedisUp() {
return redisUp;
}
@Override @Override
public Cache getCache(String name) { public Cache getCache(String name) {
// 本地专用缓存(groupSnapshot/appKeyToId)恒走本地实例,与 Redis 状态无关
if (CacheNames.isLocalOnly(name)) {
return localManager.getCache(name);
}
// AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。 // AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。
// 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s), // 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s),
// resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。 // resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。
@@ -166,18 +286,168 @@ public class CacheConfig {
@Override @Override
public Collection<String> getCacheNames() { 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 复用。 */ /** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() { private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class); Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper(); jackson.setObjectMapper(newCacheObjectMapper());
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson.setObjectMapper(mapper);
return jackson; return jackson;
} }
/** 构建缓存专用 ObjectMapper:类型信息 + 可见性 + Java 8 时间类型支持。 */
private ObjectMapper newCacheObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
// 允许任意字段可见,保证反序列化不依赖 getter/setter
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失)
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
// 支持 Java 8 时间类型(LocalDateTime 等),否则缓存含时间字段的实体(如 ApplicationInfo.createTime)会序列化失败
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
} }
@@ -0,0 +1,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; package com.yunhai.userbase.config;
import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor; import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor;
import com.yunhai.userbase.common.interceptor.ManageAccessInterceptor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -12,6 +13,10 @@ public class DWebMvcConfig implements WebMvcConfigurer
{ {
@Autowired @Autowired
private BoaUserContextInterceptor boaUserContextInterceptor; private BoaUserContextInterceptor boaUserContextInterceptor;
@Autowired
private OapiCallerAppInterceptor oapiCallerAppInterceptor;
@Autowired
private ManageAccessInterceptor manageAccessInterceptor;
@Override @Override
public void addCorsMappings(CorsRegistry registry) public void addCorsMappings(CorsRegistry registry)
@@ -25,8 +30,21 @@ public class DWebMvcConfig implements WebMvcConfigurer
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
// oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用
registry.addInterceptor(oapiCallerAppInterceptor)
.addPathPatterns("/oapi/**");
// 用户上下文必须先于访问范围拦截器注册(后者依赖 UserContext 判定 NONE)
registry.addInterceptor(boaUserContextInterceptor) registry.addInterceptor(boaUserContextInterceptor)
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); .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/**");
} }
} }
@@ -0,0 +1,44 @@
package com.yunhai.userbase.config;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 全局 Jackson 日期格式统一。
*
* <p>Spring Boot 的 {@code spring.jackson.date-format} 只作用于 {@link java.util.Date},
* 对 {@code LocalDateTime}/{@code LocalDate} 无效;且 {@code spring.jackson.local-date-time-format}
* 并非 {@code JacksonProperties} 的合法绑定项(会被静默忽略)。本配置把 Nacos 中已配置的
* {@code spring.jackson.date-format} 同时应用到 JSR-310 时间类型,做到全局统一。
*
* <p>仅用 {@code serializerByType}/{@code deserializerByType} 覆盖 JavaTimeModule 默认序列化器,
* 不 new 整个 ObjectMapper,因此不会禁用 Spring Boot 的 Jackson 自动配置;
* 字段上的 {@code @JsonFormat} 优先级更高,不受影响。
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer(
@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") String dateFormat) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return (Jackson2ObjectMapperBuilder builder) -> {
builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter));
builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter));
builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter));
};
}
}
@@ -18,11 +18,13 @@ public class MyWebAppConfigurer implements WebMvcConfigurer {
String ICON_FILE_WEB_PATH = "/upload/icon/"; String ICON_FILE_WEB_PATH = "/upload/icon/";
String WORD_FILE_WEB_PATH = "/upload/word/"; String WORD_FILE_WEB_PATH = "/upload/word/";
String HTML_FILE_WEB_PATH = "/upload/html/"; 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(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/"); registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/"); registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/"); registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/"); registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
registry.addResourceHandler(AVATAR_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/avatar/");
} }
@@ -0,0 +1,49 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.userbase.common.context.CallerAppContext;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* oapi 调用方来源注入拦截器:在 oapi 请求处理前,从 yh-app-auth 验签过滤器写入的
* {@link AppAuthFilter#APP_AUTH_ATTR}(调用方 appKey)解析出整型 appKey,
* 存入 {@link CallerAppContext};请求结束(无论成功/异常)在 {@code afterCompletion} 强制清除,
* 防止线程池复用导致 appKey 串号、进而误把其他订阅方排除。<br><br>
* 配合 {@code PushDispatcher} 的来源排除逻辑,实现"应用自己触发的变更不回推给自己"。
* 管理后台 /api/* 不走此拦截器 → CallerAppContext 为 null → 推送对该来源不做排除(全量投递)。
*/
@Component
public class OapiCallerAppInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
return true;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (StringUtils.isBlank(appKeyStr)) {
return true;
}
try {
CallerAppContext.setAppKey(Integer.valueOf(appKeyStr.trim()));
} catch (NumberFormatException ignored) {
// 非法 appKey 格式:不设置来源,等同"非订阅方来源",推送全量投递
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
CallerAppContext.clear();
}
}
@@ -3,6 +3,7 @@ package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthFilter; import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.platform.auth.UserDetailsProvider; import com.yunhai.platform.auth.UserDetailsProvider;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.impl.UserCenterService;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -0,0 +1,102 @@
package com.yunhai.userbase.config;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.yunhai.userbase.model.push.PushTaskMessage;
import java.util.HashMap;
import java.util.Map;
/**
* 推送订阅 MQ 配置。<br><br>
* 拓扑:
* <pre>
* push.topic (topic, durable)
* └─ push.main (durable, concurrency=1, x-dead-letter-exchange=push.dlx) ← 串行消费,可观测
* push.dlx (fanout, durable)
* └─ push.dlq (durable) ← 死信归档(重试耗尽)
* </pre>
* 连接信息(host/port/user/pwd/vhost)由 Nacos {@code application-infra.yaml} 的 {@code spring.rabbitmq.*}
* 公共配置注入,本类不写死(也不改 bootstrap.yml / application.yml)。<br>
* 可靠性:消费端手动 ack,失败由 {@code PushConsumer} 应用层自管重试(共 3 次),耗尽进死信队列。
*
* @author chenx
* @date 2026/8/14
*/
@Configuration
public class PushMqConfig {
public static final String EXCHANGE_TOPIC = "push.topic";
public static final String EXCHANGE_DLX = "push.dlx";
public static final String QUEUE_MAIN = "push.main";
public static final String QUEUE_DLQ = "push.dlq";
/** 统一 JSON 消息转换器(固定反序列化为 PushTaskMessage,绕过 trusted-packages 版本差异)。 */
@Bean
public MessageConverter pushMessageConverter() {
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
converter.setClassMapper(new PushTaskClassMapper(PushTaskMessage.class));
return converter;
}
@Bean
public TopicExchange pushTopicExchange() {
return new TopicExchange(EXCHANGE_TOPIC, true, false);
}
@Bean
public FanoutExchange pushDlxExchange() {
return new FanoutExchange(EXCHANGE_DLX, true, false);
}
/** 主队列:单消费者串行 + 死信路由到 push.dlx。 */
@Bean
public Queue pushMainQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", EXCHANGE_DLX);
return new Queue(QUEUE_MAIN, true, false, false, args);
}
/** 死信队列:归档重试耗尽的任务。 */
@Bean
public Queue pushDlqQueue() {
return new Queue(QUEUE_DLQ, true, false, false);
}
@Bean
public Binding pushMainBinding() {
return BindingBuilder.bind(pushMainQueue()).to(pushTopicExchange()).with("#");
}
@Bean
public Binding pushDlqBinding() {
return BindingBuilder.bind(pushDlqQueue()).to(pushDlxExchange());
}
@Bean
public RabbitTemplate pushRabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(pushMessageConverter());
return template;
}
/** 消费者容器:手动 ack + 单并发 + prefetch=1,保证串行可观测。 */
@Bean(name = "pushListenerContainerFactory")
public SimpleRabbitListenerContainerFactory pushListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(pushMessageConverter());
factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.MANUAL);
factory.setConcurrentConsumers(1);
factory.setMaxConcurrentConsumers(1);
factory.setPrefetchCount(1);
return factory;
}
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.config;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.support.converter.ClassMapper;
/**
* 推送消息固定反序列化为 {@link PushTaskMessage},绕过 Jackson2JsonMessageConverter 的
* trusted-packages 安全限制(不同 spring-amqp 版本该方法签名/默认值不一致,编译期不可靠)。<br>
* 生产者端序列化仍由 Jackson2JsonMessageConverter 完成(写入 __TypeId__),消费者端忽略 __TypeId__
* 直接按本映射器返回 PushTaskMessage,类型单一且可控。
*
* @author chenx
* @date 2026/8/14
*/
public class PushTaskClassMapper implements ClassMapper {
private final Class<?> payloadClass;
public PushTaskClassMapper(Class<?> payloadClass) {
this.payloadClass = payloadClass;
}
@Override
public Class<?> toClass(MessageProperties properties) {
return payloadClass;
}
@Override
public void fromClass(Class<?> clazz, MessageProperties properties) {
properties.setHeader("__TypeId__", clazz.getName());
}
}
@@ -2,6 +2,7 @@ package com.yunhai.userbase.config;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.GroupedOpenApi;
import org.springdoc.core.customizers.GlobalOperationCustomizer; import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -10,7 +11,14 @@ import org.springframework.web.method.HandlerMethod;
/** /**
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br> * 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数, * 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@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 @Configuration
public class SwaggerConfig { public class SwaggerConfig {
@@ -24,6 +32,28 @@ public class SwaggerConfig {
.version("0.0.1")); .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 @Bean
public GlobalOperationCustomizer hideAuthUserParam() { public GlobalOperationCustomizer hideAuthUserParam() {
@@ -3,6 +3,7 @@ package com.yunhai.userbase.config;
import com.yunhai.platform.auth.UserDetailsProvider; import com.yunhai.platform.auth.UserDetailsProvider;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.impl.UserCenterService;
import com.yunhai.userbase.service.impl.UserCenterService;
/** /**
* 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后, * 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后,
@@ -14,6 +14,7 @@ import com.yunhai.userbase.model.approle.SysAppRoleRequest;
import com.yunhai.userbase.model.approle.SysAppRoleVo; import com.yunhai.userbase.model.approle.SysAppRoleVo;
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
import com.yunhai.userbase.service.IAppRoleService; import com.yunhai.userbase.service.IAppRoleService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils; import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -41,12 +42,16 @@ public class AppRoleController {
@Autowired @Autowired
private IAppRoleService appRoleService; private IAppRoleService appRoleService;
@Autowired
private AccessScopeService accessScopeService;
/** /**
* 角色分页列表:name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。 * 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称
*/ */
@GetMapping("/RoleList") @GetMapping("/RoleList")
public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery, public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) { @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))); return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort)));
} }
@@ -57,6 +62,7 @@ public class AppRoleController {
@OperationLog("新增角色") @OperationLog("新增角色")
@PostMapping("RoleAdd") @PostMapping("RoleAdd")
public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) { public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.add(request); appRoleService.add(request);
} }
@@ -67,6 +73,7 @@ public class AppRoleController {
@PostMapping("RoleEdit/{id}") @PostMapping("RoleEdit/{id}")
public void roleEdit(@PathVariable Integer id, public void roleEdit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) { @RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.edit(id, request); appRoleService.edit(id, request);
} }
@@ -76,6 +83,7 @@ public class AppRoleController {
@OperationLog("删除角色") @OperationLog("删除角色")
@PostMapping("RoleDelete/{id}") @PostMapping("RoleDelete/{id}")
public void roleDelete(@PathVariable Integer id) { public void roleDelete(@PathVariable Integer id) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.delete(id); appRoleService.delete(id);
} }
@@ -85,6 +93,7 @@ public class AppRoleController {
*/ */
@GetMapping("/RoleModuleTree") @GetMapping("/RoleModuleTree")
public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) { public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return appRoleService.findRoleModuleTree(roleId); return appRoleService.findRoleModuleTree(roleId);
} }
@@ -94,6 +103,7 @@ public class AppRoleController {
@OperationLog("保存角色模块授权") @OperationLog("保存角色模块授权")
@PostMapping("SaveRoleModules") @PostMapping("SaveRoleModules")
public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) { public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveRoleModules(request); appRoleService.saveRoleModules(request);
} }
@@ -102,6 +112,7 @@ public class AppRoleController {
*/ */
@GetMapping("/RoleUsers") @GetMapping("/RoleUsers")
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) { public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return appRoleService.findRoleUsers(roleId); return appRoleService.findRoleUsers(roleId);
} }
@@ -111,6 +122,7 @@ public class AppRoleController {
@OperationLog("保存角色用户绑定") @OperationLog("保存角色用户绑定")
@PostMapping("SaveRoleUsers") @PostMapping("SaveRoleUsers")
public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) { public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveRoleUsers(request); appRoleService.saveRoleUsers(request);
} }
@@ -120,6 +132,7 @@ public class AppRoleController {
@OperationLog("保存用户角色绑定") @OperationLog("保存用户角色绑定")
@PostMapping("SaveUserRoles") @PostMapping("SaveUserRoles")
public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) { public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.saveUserRoles(request); appRoleService.saveUserRoles(request);
} }
@@ -130,6 +143,7 @@ public class AppRoleController {
@OperationLog("移除角色用户绑定") @OperationLog("移除角色用户绑定")
@PostMapping("RemoveRoleUser") @PostMapping("RemoveRoleUser")
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) { public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.removeRoleUser(roleId, userId); 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.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config; import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.enums.AccessLevelEnum;
import com.yunhai.userbase.model.BasePageQuery; import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult; import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
@@ -14,13 +15,18 @@ import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest; import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo; import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo; import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.model.application.PushSettingRequest;
import com.yunhai.userbase.model.application.PushSettingVo;
import com.yunhai.userbase.model.appmodule.AppModuleQuery; import com.yunhai.userbase.model.appmodule.AppModuleQuery;
import com.yunhai.userbase.model.appmodule.AppModuleRequest; import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.model.appmodule.AppModuleVo; import com.yunhai.userbase.model.appmodule.AppModuleVo;
import com.yunhai.userbase.service.IAppCategoryService; import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IAppModuleService; 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.impl.SignKeyCacheService;
import com.yunhai.userbase.service.resolve.AccessScope;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils; import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -28,10 +34,20 @@ import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 应用管理 + 学校私有覆盖(管理端权限分级改造 §3.4)。<br>
* 访问矩阵:<br>
* - 仅超管:新增/删除应用、密钥、推送设置、模块管理、新增/删除类型;<br>
* - 超管+局端:AppAuthzList(应用开通页数据源);<br>
* - 学校管理员:AppList/AppTypeList/AppSortList/EditApp/AppSort/EditType/AppTypeSort 走覆盖表
* (仅本校已开通应用,名称/描述/图标/排序,均只对该校生效);<br>
* - 局端不开放应用维护(局端的应用动作是「开通」,在应用开通页),落到写接口一律 requireSuper。
*/
@RestController @RestController
@RequestMapping(value = Config.API_PATH + "/app/") @RequestMapping(value = Config.API_PATH + "/app/")
public class AppSettingsController { public class AppSettingsController {
@@ -48,67 +64,120 @@ public class AppSettingsController {
@Autowired @Autowired
private SignKeyCacheService signKeyCacheService; private SignKeyCacheService signKeyCacheService;
@Autowired
private AccessScopeService accessScopeService;
@Autowired
private SchoolAppOverrideService schoolAppOverrideService;
/** 当前 scope 是否学校管理员(覆盖表通道) */
private boolean isSchoolLevel() {
return accessScopeService.resolveCurrentScope().getLevel() == AccessLevelEnum.SCHOOL;
}
@OperationLog("新增应用类型") @OperationLog("新增应用类型")
@PostMapping("AddType") @PostMapping("AddType")
public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) { public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) {
accessScopeService.requireSuper();
appCategoryService.add(request); appCategoryService.add(request);
} }
@OperationLog("编辑应用类型") @OperationLog("编辑应用类型")
@PostMapping("EditType/{id}") @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); appCategoryService.edit(id, request);
} }
@OperationLog("删除应用类型") @OperationLog("删除应用类型")
@PostMapping("DeleteType/{id}") @PostMapping("DeleteType/{id}")
public void deleteType(@PathVariable Integer id) { public void deleteType(@PathVariable Integer id) {
accessScopeService.requireSuper();
// 级联清理该类型在所有学校的覆盖行
schoolAppOverrideService.deleteCategoryOverrides(id);
appCategoryService.delete(id); appCategoryService.delete(id);
} }
@GetMapping("/AppTypeList") @GetMapping("/AppTypeList")
public List<AppCategoryVo> appTypeList(AppCategoryQuery query) { public List<AppCategoryVo> appTypeList(AppCategoryQuery query,
return appCategoryService.list(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("应用类型排序") @OperationLog("应用类型排序")
@PostMapping("/AppTypeSort") @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); appCategoryService.sort(ids);
} }
/** /**
* 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br> * 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。 * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。<br>
* 应用开通页数据源,仅超管+局端。
*/ */
@GetMapping("/AppAuthzList") @GetMapping("/AppAuthzList")
public List<AppAuthzCategoryVo> appAuthzList() { public List<AppAuthzCategoryVo> appAuthzList() {
accessScopeService.requireSuperOrBureau();
return appCategoryService.listWithApps(); return appCategoryService.listWithApps();
} }
@OperationLog("新增应用") @OperationLog("新增应用")
@PostMapping("AddApp") @PostMapping("AddApp")
public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) { public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) {
accessScopeService.requireSuper();
applicationInfoService.add(request); applicationInfoService.add(request);
} }
@OperationLog("编辑应用") @OperationLog("编辑应用")
@PostMapping("EditApp/{id}") @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); applicationInfoService.edit(id, request);
} }
@OperationLog("删除应用") @OperationLog("删除应用")
@PostMapping("DeleteApp/{id}") @PostMapping("DeleteApp/{id}")
public void deleteApp(@PathVariable Integer id) { public void deleteApp(@PathVariable Integer id) {
accessScopeService.requireSuper();
// 级联清理该应用在所有学校的覆盖行
schoolAppOverrideService.deleteAppOverrides(id);
applicationInfoService.delete(id); applicationInfoService.delete(id);
} }
@OperationLog("重置应用密钥") @OperationLog("重置应用密钥")
@PostMapping("ResetAppSecret/{id}") @PostMapping("ResetAppSecret/{id}")
public String resetAppSecret(@PathVariable Integer id) { public String resetAppSecret(@PathVariable Integer id) {
accessScopeService.requireSuper();
applicationInfoService.resetAppSecret(id); applicationInfoService.resetAppSecret(id);
ApplicationInfo info= applicationInfoService.getById(id); ApplicationInfo info= applicationInfoService.getById(id);
return info.getAppSecret(); return info.getAppSecret();
@@ -117,6 +186,7 @@ public class AppSettingsController {
@OperationLog("全量刷新应用签名密钥到Redis") @OperationLog("全量刷新应用签名密钥到Redis")
@PostMapping("RefreshSignKeys") @PostMapping("RefreshSignKeys")
public Map<String, Object> refreshSignKeys() { public Map<String, Object> refreshSignKeys() {
accessScopeService.requireSuper();
int count = signKeyCacheService.refreshAll(); int count = signKeyCacheService.refreshAll();
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("refreshed", count); result.put("refreshed", count);
@@ -125,41 +195,93 @@ public class AppSettingsController {
@GetMapping("/AppList") @GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery, 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))); return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort)));
} }
@OperationLog("应用排序") @OperationLog("应用排序")
@PostMapping("/AppSort") @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); applicationInfoService.sort(ids);
} }
@GetMapping("/AppSortList") @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); 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("新增模块") @OperationLog("新增模块")
@PostMapping("AddModule") @PostMapping("AddModule")
public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) { public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) {
accessScopeService.requireSuper();
appModuleService.add(request); appModuleService.add(request);
} }
@OperationLog("编辑模块") @OperationLog("编辑模块")
@PostMapping("EditModule/{id}") @PostMapping("EditModule/{id}")
public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) { public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) {
accessScopeService.requireSuper();
appModuleService.edit(id, request); appModuleService.edit(id, request);
} }
@OperationLog("删除模块") @OperationLog("删除模块")
@PostMapping("DeleteModule/{id}") @PostMapping("DeleteModule/{id}")
public void deleteModule(@PathVariable Integer id) { public void deleteModule(@PathVariable Integer id) {
accessScopeService.requireSuper();
appModuleService.delete(id); appModuleService.delete(id);
} }
@GetMapping("/ModuleList") @GetMapping("/ModuleList")
public List<AppModuleVo> moduleList(AppModuleQuery query) { public List<AppModuleVo> moduleList(AppModuleQuery query) {
accessScopeService.requireSuper();
return appModuleService.list(query); 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; package com.yunhai.userbase.controller.api;
import com.yunhai.platform.utils.model.BoaUser; 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.config.Config;
import com.yunhai.userbase.enums.*; 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.userbase.utils.*;
import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.exception.DException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -22,6 +28,9 @@ public class BaseController {
@Autowired @Autowired
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Autowired
private AccessScopeService accessScopeService;
/** /**
* 一般hashMap初始化大小 * 一般hashMap初始化大小
*/ */
@@ -38,6 +47,22 @@ public class BaseController {
return authUserWithDept; 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 //返回系统枚举2
@GetMapping("GetSysDict") @GetMapping("GetSysDict")
public Map getSysDict(BoaUser authUserWithDept){ public Map getSysDict(BoaUser authUserWithDept){
@@ -53,11 +78,13 @@ public class BaseController {
} }
/** /**
* 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。<br> * 手动管理 Redis Hash 的示例(@Cacheable 注解无法满足的场景)。<br>
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br> * 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br> * 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<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 本次演示各步骤的结果快照 * @return 本次演示各步骤的结果快照
*/ */
@@ -102,19 +129,22 @@ public class BaseController {
* 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br> * 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br>
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br> * 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<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 file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置)
* @param type 存储子目录类型,默认 image * @param type 存储子目录类型,默认 image
* @return 文件对外可访问的 Web URL * @return 文件对外可访问的 Web URL
*/ */
@PostMapping("UploadFile") @PostMapping("UploadFile")
public String uploadFile(@RequestParam("file") MultipartFile file, public ApiResponse<String> uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam(value = "type", defaultValue = "image") String type) { @RequestParam(value = "type", defaultValue = "image") String type) {
if (!ALLOWED_UPLOAD_TYPES.contains(type)) { if (!ALLOWED_UPLOAD_TYPES.contains(type)) {
throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word"); throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word");
} }
// FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接 // 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.UserPermissionUserVo;
import com.yunhai.userbase.model.groupauthz.UserRoleVo; import com.yunhai.userbase.model.groupauthz.UserRoleVo;
import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import javax.validation.Valid; 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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* @author chenx * @author chenx
* @date 2026/7/23 * @date 2026/7/23
* @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br> * @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br> * 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
* 访问控制:应用开通类 = 超管+局端;组授权/权限一览 = 管理员(数据范围内组);
* HasModulePerm 保留登录即可(判定接口,自身已有超管直通)。
*/ */
@RestController @RestController
@RequestMapping(value = Config.API_PATH + "/groupauthz/") @RequestMapping(value = Config.API_PATH + "/groupauthz/")
@@ -39,11 +44,17 @@ public class GroupAuthzController {
@Autowired @Autowired
private IGroupAuthzService groupAuthzService; private IGroupAuthzService groupAuthzService;
@Autowired
private AccessScopeService accessScopeService;
/** /**
* 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。 * 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。
*/ */
@GetMapping("/GroupAppList") @GetMapping("/GroupAppList")
public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) { public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) {
// 应用开通动作仅超管+局端(学校无此菜单);局端目标组须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.listGroupApps(groupId); return groupAuthzService.listGroupApps(groupId);
} }
@@ -53,6 +64,9 @@ public class GroupAuthzController {
@OperationLog("保存组应用开通") @OperationLog("保存组应用开通")
@PostMapping("AssignApps") @PostMapping("AssignApps")
public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) { public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) {
// 局端目标组须在本区域范围内(验收矩阵:局端仅可开通本区域学校)
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(request.getGroupId());
groupAuthzService.assignAppsToGroup(request); groupAuthzService.assignAppsToGroup(request);
} }
@@ -61,6 +75,7 @@ public class GroupAuthzController {
*/ */
@GetMapping("/GroupModuleTree") @GetMapping("/GroupModuleTree")
public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) { public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupAppsWithModules(groupId); return groupAuthzService.findGroupAppsWithModules(groupId);
} }
@@ -70,6 +85,7 @@ public class GroupAuthzController {
*/ */
@GetMapping("/GroupModuleTreeByCategory") @GetMapping("/GroupModuleTreeByCategory")
public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) { public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupAppsWithModulesByCategory(groupId); return groupAuthzService.findGroupAppsWithModulesByCategory(groupId);
} }
@@ -79,6 +95,7 @@ public class GroupAuthzController {
@OperationLog("保存组模块权限") @OperationLog("保存组模块权限")
@PostMapping("SaveModulePerms") @PostMapping("SaveModulePerms")
public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) { public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
groupAuthzService.saveModulePermissions(request); groupAuthzService.saveModulePermissions(request);
} }
@@ -95,26 +112,37 @@ public class GroupAuthzController {
*/ */
@GetMapping("/GroupUsers") @GetMapping("/GroupUsers")
public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) { public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupUsers(groupId); return groupAuthzService.findGroupUsers(groupId);
} }
/** /**
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br> * 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。 * 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。<br>
* 非超管结果按数据范围过滤:仅返回归属组与范围有交集的用户。
*/ */
@GetMapping("/SearchUsers") @GetMapping("/SearchUsers")
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) { public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
return groupAuthzService.searchUsers(keyword); return groupAuthzService.searchUsers(keyword, accessScopeService.scopeGroupIds());
} }
/** /**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br> * 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br> * 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。 * 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合(只看得到本校/本区域用户)。
*/ */
@GetMapping("/UserPermissionTree") @GetMapping("/UserPermissionTree")
public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId, public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId,
@RequestParam(required = false) Integer groupId) { @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); return groupAuthzService.findUserPermissionTree(groupId, userId);
} }
@@ -124,15 +152,24 @@ public class GroupAuthzController {
*/ */
@GetMapping("/UserRoles") @GetMapping("/UserRoles")
public List<UserRoleVo> userRoles(@RequestParam Integer userId) { public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserRoles(userId); return groupAuthzService.findUserRoles(userId);
} }
/** /**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br> * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。 * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
*/ */
@GetMapping("/UserGroupTree") @GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) { public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserGroupTree(userId); return groupAuthzService.findUserGroupTree(userId);
} }
@@ -146,6 +183,9 @@ public class GroupAuthzController {
*/ */
@GetMapping("/GetSchoolAppId") @GetMapping("/GetSchoolAppId")
public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) { public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) {
// 应用开通动作仅超管+局端;局端目标节点须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(nodeId);
return groupAuthzService.findAuthorizedAppIds(nodeId); return groupAuthzService.findAuthorizedAppIds(nodeId);
} }
@@ -159,6 +199,9 @@ public class GroupAuthzController {
@OperationLog("设置组应用开通") @OperationLog("设置组应用开通")
@PostMapping("SetSchoolApp") @PostMapping("SetSchoolApp")
public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) { public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) {
// 应用开通动作仅超管+局端;局端目标学校须在本区域范围内
accessScopeService.requireSuperOrBureau();
accessScopeService.requireGroupInScope(request.getSchid());
groupAuthzService.setSchoolApps(request); groupAuthzService.setSchoolApps(request);
} }
@@ -174,6 +217,10 @@ public class GroupAuthzController {
@OperationLog("设置组模块权限") @OperationLog("设置组模块权限")
@PostMapping("SetGroupModule") @PostMapping("SetGroupModule")
public void setGroupModule(@RequestBody @Valid List<GroupModuleAuthzItem> items) { 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); groupAuthzService.setGroupModulePerms(items);
} }
} }
@@ -0,0 +1,105 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.push.PushRecordListVo;
import com.yunhai.userbase.model.push.PushRecordQuery;
import com.yunhai.userbase.model.push.PushTestRequest;
import com.yunhai.userbase.service.push.PushDispatcher;
import com.yunhai.userbase.service.push.PushRecordService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 推送订阅控制器:测试推送 + 推送日志记录 / 重试。
*
* @author chenx
* @date 2026/8/14
*/
@RestController
@Slf4j
@RequestMapping(value = Config.API_PATH + "/push/")
public class PushSubscriptionController {
@Autowired
private PushDispatcher pushDispatcher;
@Autowired
private PushRecordService pushRecordService;
@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());
Map<String, Object> result = new LinkedHashMap<>();
result.put("sent", true);
result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时");
result.put("preview", preview);
return result;
}
/** 推送日志列表(不含 payload,避免全量推送数据量过大;payload 经 Download 接口单独获取)。 */
@GetMapping("RecordList")
public BasePageResult<PushRecordListVo> recordList(PushRecordQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
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);
}
/**
* 下载某条推送记录的请求体原文(payload 文本文件)。<br>
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
* 若该记录未记录 payload,返回一段说明文本文件而非 404,便于排查。
*/
@GetMapping("/Download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = pushRecordService.getPayload(id);
if (data == null) {
data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。";
}
try {
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + new String(("payload-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers)
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(bis));
} catch (Exception e) {
log.error("推送日志请求体下载失败, id={}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -10,16 +10,21 @@ import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupVo; import com.yunhai.userbase.model.sysgroup.SysGroupVo;
import com.yunhai.userbase.service.ISysGroupService; 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.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* @author chenx * @author chenx
* @date 2026/7/20 16:00 * @date 2026/7/20 16:00
* @description 系统组管理(组织架构树) * @description 系统组管理(组织架构树)——管理端四级访问控制 guard 层(S+B+C)。<br>
* 范围裁剪与越权拦截在此层完成,Service 保持与 oapi 共享不受影响;SUPER 行为与改造前完全一致。
*/ */
@RestController @RestController
@RequestMapping(value = Config.API_PATH + "/sysgroup/") @RequestMapping(value = Config.API_PATH + "/sysgroup/")
@@ -28,44 +33,67 @@ public class SysGroupController {
@Autowired @Autowired
private ISysGroupService sysGroupService; private ISysGroupService sysGroupService;
@Autowired
private AccessScopeService accessScopeService;
@OperationLog("新增组") @OperationLog("新增组")
@Operation(operationId = "sysGroupAdd")
@PostMapping("Add") @PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
// pid 为 null/0 表示挂根下(仅超管);其余必须落在数据范围内(学校不可跳出本校建组)
accessScopeService.requireCreatePointInScope(request.getParentId());
sysGroupService.add(request); sysGroupService.add(request);
} }
@OperationLog("编辑组") @OperationLog("编辑组")
@Operation(operationId = "sysGroupEdit")
@PostMapping("Edit/{id}") @PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { 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); sysGroupService.edit(id, request);
} }
@OperationLog("删除组") @OperationLog("删除组")
@Operation(operationId = "sysGroupRemove")
@PostMapping("Delete/{id}") @PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) { public void delete(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
// 学校组/区域组本身不可删,子组可管理
accessScopeService.requireGroupModifiable(id);
sysGroupService.delete(id); sysGroupService.delete(id);
} }
@Operation(operationId = "sysGroupList")
@GetMapping("/List") @GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) { 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") @GetMapping("GetTree")
public List<SysGroupVo> getTree() { public List<SysGroupVo> getTree() {
return sysGroupService.getTree(); return sysGroupService.getTree(accessScopeService.resolveCurrentScope());
} }
/** /**
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br> * 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
* 子树中无学校组的分支整体剪掉;根节点始终保留。 * 子树中无学校组的分支整体剪掉;根节点始终保留。<br>
* BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
*/ */
@GetMapping("GetSchoolTree") @GetMapping("GetSchoolTree")
public List<SysGroupVo> getSchoolTree() { public List<SysGroupVo> getSchoolTree() {
return sysGroupService.getSchoolTree(); return sysGroupService.getSchoolTree(accessScopeService.resolveCurrentScope());
} }
/** /**
@@ -74,6 +102,7 @@ public class SysGroupController {
@OperationLog("批量新增子组") @OperationLog("批量新增子组")
@PostMapping("BatchAdd") @PostMapping("BatchAdd")
public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) { public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
accessScopeService.requireCreatePointInScope(request.getPid());
sysGroupService.batchAdd(request); sysGroupService.batchAdd(request);
} }
@@ -83,15 +112,19 @@ public class SysGroupController {
@OperationLog("子组排序") @OperationLog("子组排序")
@PostMapping("SortChildren") @PostMapping("SortChildren")
public void sortChildren(@Validated SysGroupSortRequest request) { public void sortChildren(@Validated SysGroupSortRequest request) {
accessScopeService.requireGroupInScope(request.getPid());
sysGroupService.sortChildren(request); sysGroupService.sortChildren(request);
} }
/** /**
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。 * 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。<br>
* moveId 与目标 pid 均须落在数据范围内(SCHOOL 级天然禁止跨校移动,与 oapi move 语义一致)。
*/ */
@OperationLog("移动组") @OperationLog("移动组")
@PostMapping("Move") @PostMapping("Move")
public void move(@Validated SysGroupMoveRequest request) { public void move(@Validated SysGroupMoveRequest request) {
accessScopeService.requireGroupInScope(request.getMoveId());
accessScopeService.requireCreatePointInScope(request.getPid());
sysGroupService.move(request); 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,8 @@ import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.oplog.SysOperationLogListVo; import com.yunhai.userbase.model.oplog.SysOperationLogListVo;
import com.yunhai.userbase.model.oplog.SysOperationLogQuery; import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
import com.yunhai.userbase.service.ISysOperationLogService; 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; import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -36,21 +38,28 @@ public class SysOperationLogController {
@Autowired @Autowired
private ISysOperationLogService sysOperationLogService; private ISysOperationLogService sysOperationLogService;
@Autowired
private AccessScopeService accessScopeService;
/** /**
* 分页查询操作日志列表(不含响应结果)。 * 分页查询操作日志列表(不含响应结果)。
*/ */
@Operation(summary = "操作日志分页查询", operationId = "operationLogList")
@GetMapping("/List") @GetMapping("/List")
public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) { public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
return sysOperationLogService.pageQuery(query, pageQuery); return sysOperationLogService.pageQuery(query, pageQuery);
} }
/** /**
* 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。<br> * 下载某条日志的响应结果(GZIP 解压后原文, 文本文件)。<br>
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br> * 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。 * 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
*/ */
@Operation(summary = "下载日志响应原文", operationId = "operationLogDownload")
@GetMapping("/Download/{id}") @GetMapping("/Download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) { public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = sysOperationLogService.getResponseData(id); String data = sysOperationLogService.getResponseData(id);
if (data == null) { if (data == null) {
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。"; 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.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo; 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.ISysUserService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils; import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageImpl;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
@@ -39,31 +42,44 @@ public class SysUserController {
@Autowired @Autowired
private ISysUserService sysUserService; 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("新增用户") @OperationLog("新增用户")
@PostMapping("Add") @PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
List<String> data=new ArrayList<>(); accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.add(request); return sysUserService.add(request);
} }
@OperationLog("编辑用户") @OperationLog("编辑用户")
@PostMapping("Edit/{id}") @PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { 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); sysUserService.edit(id, request);
} }
@OperationLog("删除用户")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
sysUserService.delete(id);
}
/** /**
* 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。 * 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。
*/ */
@OperationLog("从组中删除用户") @OperationLog("从组中删除用户")
@PostMapping("DeleteFromGroup/{userId}/{groupId}") @PostMapping("DeleteFromGroup/{userId}/{groupId}")
public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) { public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
sysUserService.deleteFromGroup(userId, groupId); sysUserService.deleteFromGroup(userId, groupId);
} }
@@ -73,6 +89,7 @@ public class SysUserController {
@OperationLog("批量从组中删除用户") @OperationLog("批量从组中删除用户")
@PostMapping("BatchDelete") @PostMapping("BatchDelete")
public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) { public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId()); sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId());
} }
@@ -80,6 +97,8 @@ public class SysUserController {
@OperationLog("禁用用户") @OperationLog("禁用用户")
@PostMapping("Disable/{id}") @PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) { public void disable(@PathVariable Integer id) {
// 入参只有 userId:校验目标用户归属组与数据范围有交集,防止跨校禁用他人账号
accessScopeService.requireUserInScope(id);
sysUserService.changeStatus(id, true); sysUserService.changeStatus(id, true);
} }
@@ -87,6 +106,7 @@ public class SysUserController {
@OperationLog("启用用户") @OperationLog("启用用户")
@PostMapping("Enable/{id}") @PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) { public void enable(@PathVariable Integer id) {
accessScopeService.requireUserInScope(id);
sysUserService.changeStatus(id, false); sysUserService.changeStatus(id, false);
} }
@@ -94,11 +114,21 @@ public class SysUserController {
@OperationLog("批量设置用户状态") @OperationLog("批量设置用户状态")
@PostMapping("BatchChangeStatus") @PostMapping("BatchChangeStatus")
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) { public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
accessScopeService.requireUsersInScope(request.getUserIds());
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled()); sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
} }
@GetMapping("/List") @GetMapping("/List")
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) { 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 控制 // 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制
// (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。 // (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null))); return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null)));
@@ -109,13 +139,18 @@ public class SysUserController {
@PostMapping("ChangeSortNo/{userId}") @PostMapping("ChangeSortNo/{userId}")
public void changeSortNo(@PathVariable Integer userId, public void changeSortNo(@PathVariable Integer userId,
@RequestBody @Validated ChangeSortNoRequest request) { @RequestBody @Validated ChangeSortNoRequest request) {
accessScopeService.requireGroupInScope(request.getGroupId());
sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo()); sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo());
} }
@OperationLog("调整组") @OperationLog("调整组")
@PostMapping("ChangeGroups") @PostMapping("ChangeGroups")
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) { 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}") @GetMapping("Groups/{userId}")
@@ -123,9 +158,20 @@ public class SysUserController {
return sysUserService.findGroupIdsByUserId(userId); 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("批量导入用户") @OperationLog("批量导入用户")
@PostMapping("BatchImport") @PostMapping("BatchImport")
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) { public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
// 涉及的全部组 id ∈ scope 的校验在 Service 解析 Excel 后逐行执行(越界行按行失败上报)
return sysUserService.batchImport(file); return sysUserService.batchImport(file);
} }
@@ -135,6 +181,7 @@ public class SysUserController {
@GetMapping("DownloadTemplate") @GetMapping("DownloadTemplate")
public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId, public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
requireQueryGroupInScope(groupId);
sysUserService.downloadImportTemplate(groupId, response); sysUserService.downloadImportTemplate(groupId, response);
} }
@@ -147,6 +194,7 @@ public class SysUserController {
@RequestParam(value = "withSub", defaultValue = "1") Integer withSub, @RequestParam(value = "withSub", defaultValue = "1") Integer withSub,
@RequestParam(value = "name", required = false) String name, @RequestParam(value = "name", required = false) String name,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
requireQueryGroupInScope(groupId);
sysUserService.exportUsers(groupId, withSub, name, response); sysUserService.exportUsers(groupId, withSub, name, response);
} }
} }
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 应用域开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/app/...})。<br>
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求
* (URL pattern {@code /oapi/*} 已覆盖本控制器),未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "app/")
public class AppController {
@Autowired
private IAppCategoryService appCategoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
/**
* 按主键 ID 查询应用信息(带缓存)。<br>
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
* 返回 {@link ApplicationInfoVo} 并清空 appSecret,与 list 接口一致不透出签名密钥。
*
* @param appId 应用主键 ID(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("{appId}")
public ApplicationInfoVo byId(@PathVariable Integer appId) {
ApplicationInfo info = applicationInfoService.getById(appId);
if (info == null) {
return null;
}
ApplicationInfoVo vo = new ApplicationInfoVo();
BeanUtils.copyProperties(info, vo);
// 不透出签名密钥(与 list 契约一致)
vo.setAppSecret(null);
return vo;
}
/**
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
* 超级管理员返回全部;不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
*
* @param userId 用户ID(必填)
* @param clientType 客户端类型 key(可选)
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
*/
@GetMapping("list")
public List<BoaAppType> list(@RequestParam("userId") Integer userId,
@RequestParam(value = "clientType", required = false) Integer clientType) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppType(userId, clientEnum);
}
}
@@ -0,0 +1,116 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
import com.yunhai.userbase.service.ISysGroupService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 组织架构开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/group/...})。<br>
* <br>
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient}
* 硬编码的契约严格一致:
* {@code GET /oapi/group/tree}、{@code /byId?groupId=&withSub=}、{@code /list?ids=&withSub=}。<br>
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "group/")
public class GroupController {
@Autowired
private ISysGroupService sysGroupService;
/**
* 获取整个平台的组树(从根节点开始,children 递归嵌套全部后代)。
*/
@GetMapping("tree")
public BoaGroup tree() {
return sysGroupService.getGroups();
}
/**
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。
*
* @param groupId 组ID(必填)
* @param withSub 是否展开子节点,缺省 false
* @return 组节点;不存在返回 null
*/
@GetMapping("byId")
public BoaGroup byId(@RequestParam("groupId") Integer groupId,
@RequestParam(value = "withSub", required = false) Boolean withSub) {
return sysGroupService.getGroupById(groupId, withSub);
}
/**
* 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的跳过。
*
* @param ids 组ID列表(逗号/空格分隔)
* @param withSub 是否展开子节点,缺省 false
* @return 组节点列表(按入参顺序;不存在的跳过)
*/
@GetMapping("list")
public List<BoaGroup> list(@RequestParam("ids") String ids,
@RequestParam(value = "withSub", required = false) Boolean withSub) {
return sysGroupService.getGroupList(ids, withSub);
}
/**
* 新增组:返回创建后的组节点(含 id)。<br>
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
*/
@PostMapping("add")
public BoaGroup add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
return sysGroupService.add(request);
}
/**
* 编辑组(不含移动,移动请走 group/move/{schoolId} 开放接口或后台 Move):返回更新后的组节点(含 id)。
*/
@PostMapping("edit/{id}")
public BoaGroup edit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
return sysGroupService.edit(id, request);
}
/**
* 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。<br>
* 新增 {@code schoolId} 边界:服务端强制 id 属于该校子树且不等于 schoolId,避免删学校根组/跨校删组。
*/
@PostMapping("delete/{id}/{schoolId}")
public void delete(@PathVariable Integer id, @PathVariable Integer schoolId) {
sysGroupService.delete(id, schoolId);
}
/**
* 批量新增子组:请求体 { pid, type, items:[{name},...] },返回创建后的组节点列表(均含 id)。
*/
@PostMapping("batchAdd")
public List<BoaGroup> batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
return sysGroupService.batchAdd(request);
}
/**
* 移动组(学校限定):仅允许在同一学校(组)子树内移动——moveId、pid 都必须落在 schoolId 子树内。<br>
* 语义复用后台 Move(环检测/类型约束/重排),额外叠加学校边界。鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
*/
@PostMapping("move/{schoolId}")
public void move(@PathVariable Integer schoolId,
@RequestBody @Validated SysGroupMoveRequest request) {
sysGroupService.move(request, schoolId);
}
}
@@ -0,0 +1,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,21 +1,41 @@
package com.yunhai.userbase.controller.oapi; 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.BoaGroup;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser; 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.config.Config;
import com.yunhai.userbase.model.groupauthz.UserPermissionAppVo; import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; 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.model.sysuser.SysUserTransferSchoolRequest;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.impl.UserCenterService;
import org.springframework.beans.BeanUtils; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -34,12 +54,20 @@ import java.util.List;
@RequestMapping(Config.OPEN_API_PATH + "user/") @RequestMapping(Config.OPEN_API_PATH + "user/")
public class UserController { public class UserController {
private static final Logger log = LoggerFactory.getLogger(UserController.class);
@Autowired @Autowired
private UserCenterService userCenterService; private UserCenterService userCenterService;
@Autowired
private ISysUserService sysUserService;
@Autowired @Autowired
private IGroupAuthzService groupAuthzService; private IGroupAuthzService groupAuthzService;
@Autowired
private IApplicationInfoService applicationInfoService;
@GetMapping("byId") @GetMapping("byId")
public BoaUser byId(@RequestParam("id") Integer id) { public BoaUser byId(@RequestParam("id") Integer id) {
return userCenterService.getUserInfoWithDeptById(id); return userCenterService.getUserInfoWithDeptById(id);
@@ -62,77 +90,224 @@ public class UserController {
} }
/** /**
* 查询用户在指定学校下的已授权模块列表。<br> * 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* 若 {@code schId} 为 null 时,自动取用户的主学校;若仍无主学校信息,退化为取用户主归属组(groups 第 0 个)。 * <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
* 已保证非空);<br>
* ② 查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
* *
* @param userId 用户ID(必填) * @param request 用于取 AppAuthFilter 设置的 appKey
* @param schId 学校ID/组ID(可选;为 null 时按上述规则推断) * @param userId 用户ID(必填)
* @return 扁平化的已授权模块列表(含基础平台统一 BoaModule 模型);无权限时返回空列表 * @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/ */
@GetMapping("modules") @GetMapping("modules")
public List<BoaModule> modules(@RequestParam("userId") Integer userId, public List<BoaModule> modules(HttpServletRequest request,
@RequestParam("userId") Integer userId,
@RequestParam(value = "schId", required = false) Integer schId) { @RequestParam(value = "schId", required = false) Integer schId) {
if (userId == null) { if (userId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); Integer appId = resolveCallerAppId(request);
// 注意:超管即使没有学校/归属组,service.findUserPermissionTree 内部也会走超管短路返回全部权限, if (appId == null) {
// 因此这里不能因 effectiveGroupId 为 null 直接返回空,否则超管在 oapi/user/modules 永远拿不到权限;
// 普通用户无组/无学校时,findUserPermissionTree 同样会返回空列表,行为一致。
List<UserPermissionCategoryVo> tree = groupAuthzService.findUserPermissionTree(effectiveGroupId, userId);
if (tree == null || tree.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
List<BoaModule> result = new ArrayList<>(); return groupAuthzService.findUserModulesForApp(appId, schId, userId);
for (UserPermissionCategoryVo category : tree) { }
if (category.getApps() == null)
continue; /**
for (UserPermissionAppVo appVo : category.getApps()) { * 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。<br>
String icon = appVo.getPcIcon(); * 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。
collectModules(appVo.getModules(), icon, result); */
} private Integer resolveCallerAppId(HttpServletRequest request) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
log.warn("[oapi:user:modules] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
request.getRequestURI());
return null;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (appKeyStr.trim().isEmpty()) {
log.warn("[oapi:user:modules] AppAuth attr is blank, uri={}", request.getRequestURI());
return null;
}
Integer appKey;
try {
appKey = Integer.parseInt(appKeyStr.trim());
} catch (NumberFormatException e) {
log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
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 appId;
}
/**
* 新增用户:走完整校验(账号唯一/手机号/身份证/密码强度),BCrypt 加密并写入组关联;
* 返回创建后的用户(含 id 与组织链)。<br>
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
*/
@PostMapping("add")
public BoaUser add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
Integer id = sysUserService.add(request);
return userCenterService.getUserInfoWithDeptById(id);
}
/**
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
*/
@PostMapping("edit/{id}")
public BoaUser edit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
sysUserService.edit(id, request);
return userCenterService.getUserInfoWithDeptById(id);
}
/**
* 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;<br>
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。<br>
* 这样多校用户只会被退出指定学校,而不会被整体误删。<br>
* 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。
*/
@PostMapping("delete/{id}/{groupId}/{schoolId}")
public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) {
sysUserService.deleteFromGroup(id, groupId, schoolId);
}
/**
* 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);<br>
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。
*/
@PostMapping("deleteUsers")
public void deleteUsers(@RequestBody List<SysUserGroupDeleteItem> items) {
sysUserService.deleteUsers(items);
}
/**
* 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword);
* 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。
*/
@PostMapping("batchAdd")
public List<BoaUser> batchAdd(@RequestBody List<SysUserRequest> requests) {
List<Integer> ids = sysUserService.batchAdd(requests);
List<BoaUser> result = new ArrayList<>();
for (Integer id : ids) {
result.add(userCenterService.getUserInfoWithDeptById(id));
} }
return result; return result;
} }
/** /**
* 递归收集已授权模块树到扁平列表,并把应用 PC 图标作为模块的 icon 兜底(BoaModule.icon)。 * 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内,
* 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。<br>
* 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。
*/ */
private void collectModules(List<UserPermissionModuleVo> modules, @PostMapping("moveToGroup")
String appIcon, public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) {
List<BoaModule> collector) { sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId());
if (modules == null || modules.isEmpty()) { return userCenterService.getUserInfoWithDeptById(request.getUserId());
return;
}
for (UserPermissionModuleVo m : modules) {
BoaModule bm = new BoaModule();
BeanUtils.copyProperties(m, bm);
bm.setIcon(appIcon);
collector.add(bm);
collectModules(m.getChildren(), appIcon, collector);
}
} }
/** /**
* 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。 * 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。<br>
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
*/ */
private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { @GetMapping("roles")
if (schId != null) { public List<BoaRoleInfo> roles(@RequestParam("userId") Integer userId) {
return schId; return sysUserService.findUserRoles(userId);
} }
BoaUser boa = userCenterService.getUserInfoWithDeptById(userId);
if (boa == null) { /**
return null; * 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
} * 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
BoaGroup school = boa.getSchool(); */
if (school != null && school.getId() != null) { @PostMapping("password")
return school.getId(); public void password(@RequestBody @Validated SysUserPasswordRequest request) {
} sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
List<BoaGroup> groups = boa.getGroups(); }
if (groups != null && !groups.isEmpty()) {
BoaGroup main = groups.get(0); /**
return main == null ? null : main.getId(); * 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
} * 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
return null; * 与修改密码一致,仅由过滤器层强制应用签名;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) @Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "clients", columnDefinition = "json") @Column(name = "clients", columnDefinition = "json")
@Column(name = "clients", columnDefinition = "TEXT")
private List<Integer> clients; private List<Integer> clients;
/** /**
@@ -104,4 +103,43 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "dingtalk_url") @Column(name = "dingtalk_url")
private String dingtalkUrl; private String dingtalkUrl;
// ===================== 推送订阅配置(文档《弘育基础平台数据同步接口 2》) =====================
/**
* 推送外部接口地址(不含固定后缀),如 http://host/notice/api/base/ 。
* 完整调用地址 = pushBaseUrl + 接口路径(如 group/AddGroup)。
*/
@Column(name = "push_base_url")
private String pushBaseUrl;
/**
* 推送总开关:0关 1开(默认 0,自动推送默认不触发)。
*/
@Column(name = "push_enabled", nullable = false)
private Integer pushEnabled = 0;
/**
* 启用的接口路径,逗号分隔(如 group/AddGroup,user/AddUser);空=不推送任何接口。
*/
@Column(name = "push_event_types")
private String pushEventTypes;
/**
* 关联学校组 id,逗号分隔;空=全局(监听所有学校变化)。
*/
@Column(name = "push_school_ids")
private String pushSchoolIds;
/**
* 推送角色 key,逗号分隔;空=全部角色。
*/
@Column(name = "push_role_ids")
private String pushRoleIds;
/**
* 推送签名密钥;空则复用 appSecret。
*/
@Column(name = "push_secret")
private String pushSecret;
} }
@@ -0,0 +1,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] * 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3]
*/ */
@Convert(converter = ListIntegerJsonConverter.class) @Convert(converter = ListIntegerJsonConverter.class)
// @Column(name = "roles", columnDefinition = "json") @Column(name = "roles", columnDefinition = "json")
@Column(name = "roles",columnDefinition = "TEXT")
private List<Integer> roles; private List<Integer> roles;
// 一对多:关联用户分组中间表,实现多对多业务 // 一对多:关联用户分组中间表,实现多对多业务
@@ -37,12 +37,24 @@ public class SysUserCurrentRole {
private Integer userId; private Integer userId;
/** /**
* 用户切换选中的学校ID(根级group) * 用户切换选中的身份归属学校(恒为真实组 id:学校组/区域组/根直属=根节点 id;原「区域/学段/根直属=0或null」约定废弃)
*/ */
private Integer schoolId; private Integer schoolId;
/** /**
* 用户切换后生效角色ID * 用户切换后生效角色ID(SysRoleEnum.key)
*/ */
private Integer roleId; private Integer roleId;
/**
* 最近一次切换时间(服务端 now() 写入)
*/
@Column(name = "switch_time")
private LocalDateTime switchTime;
/**
* 学校名冗余(避免每次 join sys_group 取 name)
*/
@Column(name = "school_name", length = 100)
private String schoolName;
} }
@@ -4,6 +4,9 @@ import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import com.yunhai.userbase.common.converter.UserGroupRelStatusEnumConverter;
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
import javax.persistence.*; import javax.persistence.*;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -36,6 +39,14 @@ public class SysUserGroupRel {
@Column(name = "sort_no") @Column(name = "sort_no")
private Integer sortNo; private Integer sortNo;
/**
* 组内状态:0=正常 1=转出(存量数据 NULL 视为正常)。<br>
* 转出仅作标记,不影响成员查询、组织链与下游推送;
* 改组/移动经 changeGroups 覆盖式重建关系行时状态随用户继承(同组照抄旧状态,新增组在转出流程中继承转出)。
*/
@Convert(converter = UserGroupRelStatusEnumConverter.class)
private UserGroupRelStatusEnum status;
private LocalDateTime createTime; private LocalDateTime createTime;
// 关联用户,仅查询,不维护外键 // 关联用户,仅查询,不维护外键
@@ -0,0 +1,82 @@
package com.yunhai.userbase.entity;
import com.yunhai.userbase.common.converter.StatusEnumConverter;
import com.yunhai.userbase.enums.StatusEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/8/18
* @description 用户-SysRoleEnum角色-学校 三元关联(平台身份角色的权威源)。
* <p>区别于 {@link SysUserAppRoleRel}(业务权限角色):本表表达"用户在哪个学校下激活哪个平台身份角色"
* (如"刘文娟在 A 学校是老师"),角色取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key。</p>
* <p>不继承 BaseEntity:高频读写关系表,自定义字段更轻量,与 {@link SysUserGroupRel} 风格一致。</p>
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_role_rel",
uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "roleId", "schoolId"}),
indexes = {
@Index(columnList = "userId"),
@Index(columnList = "schoolId"),
@Index(columnList = "roleId")
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserRoleRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = Update.class)
private Integer id;
/**
* 用户ID
*/
@Column(nullable = false)
private Integer userId;
/**
* 角色ID(SysRoleEnum.key,1-7)
*/
@Column(nullable = false)
private Integer roleId;
/**
* 身份归属学校(恒为真实组 id:学校组 / 区域组 / 根直属=根节点 id)。
* 由祖先链解析(见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId});
* 原注释「区域/学段/根=0」约定已废弃。
*/
@Column(nullable = false)
private Integer schoolId;
/**
* 排序号:越小越靠前
*/
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/**
* 状态:0=禁用 1=正常(StatusEnum)
*/
@Convert(converter = StatusEnumConverter.class)
@Column(nullable = false)
private StatusEnum status = StatusEnum.NORMAL;
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
public @interface Update {
}
}
@@ -0,0 +1,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; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; 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; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@@ -1,5 +1,7 @@
package com.yunhai.userbase.enums; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; 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; package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@@ -0,0 +1,30 @@
package com.yunhai.userbase.event;
import com.yunhai.userbase.model.push.PushInterfaceType;
import lombok.Data;
import java.util.List;
/**
* 数据同步事件:组 / 用户变更后由 {@code SysGroupService}/{@code SysUserService} 在事务内 publish,
* {@code PushDispatcher} 用 {@code @TransactionalEventListener(AFTER_COMMIT)} 消费(事务提交后才推送,
* 推送失败不影响主业务)。
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class DataSyncEvent {
/** 接口类型,如 group/AddGroup */
private PushInterfaceType type;
/** 组事件携带的受影响组 id 列表 */
private List<Integer> groupIds;
/** 用户事件携带的受影响用户 id 列表 */
private List<Integer> userIds;
/** 组维度的关联事件(如删除用户=解除组关系)携带的组 id */
private Integer groupId;
/** 触发本次变更的 oapi 调用方 appKey;非 oapi 来源(如管理后台)为 null。
* 推送分发器据此回推时排除来源应用自身,避免收到自己的回声。 */
private Integer sourceAppKey;
}
@@ -0,0 +1,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;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 保存应用推送设置请求(应用列表「推送设置」按钮提交)。<br>
* 所有字段均为可选更新:传 null 表示不修改该项。
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushSettingRequest {
/** 推送外部接口地址(不含固定后缀) */
private String pushBaseUrl;
/** 推送总开关:0关 1开 */
private Integer pushEnabled;
/** 启用的接口路径,逗号分隔 */
private String pushEventTypes;
/** 关联学校组 id,逗号分隔;空=全局 */
private String pushSchoolIds;
/** 推送角色 key,逗号分隔;空=全部角色 */
private String pushRoleIds;
/**
* 推送签名密钥;传 null 表示不修改(保留现有值),
* 传空串 "" 表示清空(后续复用 appSecret)。
*/
private String pushSecret;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 应用推送设置回显 VO(GetPushSetting 返回,仅含推送相关字段,不含 appSecret 等无关大字段)。
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushSettingVo {
private Integer id;
private Integer appKey;
private String name;
private String pushBaseUrl;
private Integer pushEnabled;
private String pushEventTypes;
private String pushSchoolIds;
private String pushRoleIds;
/** 推送签名密钥(可展示供复制;空=复用 appSecret) */
private String pushSecret;
}
@@ -21,7 +21,7 @@ public class UserGroupNodeVo {
/** 组名称。 */ /** 组名称。 */
private String name; private String name;
/** 是否用户直接归属的组(叶子高亮用);祖先组为 false。 */ /** 是否用户主归属组(isMain=1,无主归属时取第一个直接归属组);祖先组为 false。 */
private Boolean own; private Boolean own;
/** 子节点(仅含祖先链上的节点)。 */ /** 子节点(仅含祖先链上的节点)。 */
@@ -20,6 +20,9 @@ public class UserPermissionUserVo {
/** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */ /** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */
private Integer isMain; private Integer isMain;
/** 该行对应的组ID(同一用户多组时每个组出一行,用于按组查看权限)。 */
private Integer groupId;
/** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */ /** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */
private String groupPath; private String groupPath;
} }
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.identity;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 开放接口(oapi)切换身份入参:目标用户 + 目标学校与该学校下的角色。<br>
* 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
* 不存在则拒绝(防止越权切换到非授权身份)。<br>
* 调用方应用须在 {@code yh.identity.switch-allowed-app-keys} 白名单内(AppKey 级闸门)。
*
* @author chenx
* @date 2026/8/27
*/
@Data
public class IdentitySwitchOapiRequest {
/** 目标用户ID(由调用方应用后端从登录会话获取,禁止前端透传) */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 目标角色ID(SysRoleEnum.key) */
@NotNull(message = "角色ID不能为空")
private Integer roleId;
/** 目标学校ID(=学校组 sys_group.id;无学校维度传 0) */
@NotNull(message = "学校ID不能为空")
private Integer schoolId;
}
@@ -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;
}
@@ -0,0 +1,49 @@
package com.yunhai.userbase.model.push;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 文档约定的 8 个订阅接口:path(固定后缀,拼在订阅方 base URL 后)↔ 友好名 label。
*
* @author chenx
* @date 2026/8/14
*/
@Getter
@AllArgsConstructor
public enum PushInterfaceType {
SYNC_GROUP("group/SyncGroup", "组全量同步"),
SYNC_USER("user/SyncUser", "用户全量同步"),
ADD_GROUP("group/AddGroup", "添加组"),
UPDATE_GROUP("group/UpdateGroup", "更新组"),
DELETE_GROUP("group/DeleteGroup", "删除组"),
ADD_USER("user/AddUser", "添加用户"),
UPDATE_USER("user/UpdateUser", "更新用户"),
DELETE_USER("user/DeleteUser", "删除用户");
private final String path;
private final String label;
public static PushInterfaceType getByPath(String path) {
if (path == null) {
return null;
}
for (PushInterfaceType type : values()) {
if (type.path.equals(path)) {
return type;
}
}
return null;
}
/** 是否为组相关接口。 */
public boolean isGroup() {
return path.startsWith("group/");
}
/** 是否为用户相关接口。 */
public boolean isUser() {
return path.startsWith("user/");
}
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.model.push;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.time.LocalDateTime;
/**
* 推送订阅记录实体(订阅方维度,落库用于日志 / 重试 / 排查)。<br>
* 与 DDL(push_subscription_ddl.sql §2)的 push_record 表一一对应。
*
* @author chenx
* @date 2026/8/14
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "push_record")
public class PushRecordEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "subscriber_app_key", nullable = false)
private Integer subscriberAppKey;
@Column(name = "app_name")
private String appName;
@Column(name = "interface_path", nullable = false)
private String interfacePath;
@Column(name = "interface_label")
private String interfaceLabel;
@Column(name = "request_url", nullable = false)
private String requestUrl;
@Column(name = "payload", columnDefinition = "MEDIUMTEXT")
private String payload;
@Column(name = "sign_t")
private String signT;
@Column(name = "http_status")
private Integer httpStatus;
@Column(name = "resp_code")
private Integer respCode;
@Column(name = "resp_msg")
private String respMsg;
/** 是否成功:0失败 1成功 */
@Column(name = "success", nullable = false)
private Integer success = 0;
@Column(name = "attempt_no", nullable = false)
private Integer attemptNo = 1;
@Column(name = "cost_ms")
private Integer costMs;
/** 触发类型:manual / auto / retry */
@Column(name = "trigger_type")
private String triggerType;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.model.push;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 推送记录列表项视图。<br>
* 不含 payload(请求体原文),列表不展示,需通过下载接口单独获取,避免全量推送时数据量过大。<br>
*
* @author chenx
* @date 2026/8/17
*/
@Data
public class PushRecordListVo {
/** 主键 */
private Long id;
/** 订阅方 appKey */
private Integer subscriberAppKey;
/** 应用名称 */
private String appName;
/** 接口路径,如 group/SyncGroup */
private String interfacePath;
/** 接口中文标签 */
private String interfaceLabel;
/** 请求地址 */
private String requestUrl;
/** 签名时间 t */
private String signT;
/** HTTP 状态码 */
private Integer httpStatus;
/** 业务码 */
private Integer respCode;
/** 业务消息 */
private String respMsg;
/** 是否成功:0失败 1成功 */
private Integer success;
/** 重试次数 */
private Integer attemptNo;
/** 耗时(ms) */
private Integer costMs;
/** 触发类型:manual / auto / retry */
private String triggerType;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
/**
* 全参构造,顺序需与 {@code PushRecordEntity} 的列表投影字段一致。<br>
* 用于 JPA Criteria {@code cb.construct} 投影查询。
*/
public PushRecordListVo(Long id, Integer subscriberAppKey, String appName, String interfacePath,
String interfaceLabel, String requestUrl, String signT, Integer httpStatus,
Integer respCode, String respMsg, Integer success, Integer attemptNo,
Integer costMs, String triggerType, LocalDateTime createdAt) {
this.id = id;
this.subscriberAppKey = subscriberAppKey;
this.appName = appName;
this.interfacePath = interfacePath;
this.interfaceLabel = interfaceLabel;
this.requestUrl = requestUrl;
this.signT = signT;
this.httpStatus = httpStatus;
this.respCode = respCode;
this.respMsg = respMsg;
this.success = success;
this.attemptNo = attemptNo;
this.costMs = costMs;
this.triggerType = triggerType;
this.createdAt = createdAt;
}
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.push;
import lombok.Data;
/**
* 推送记录查询条件(前端日志页搜索用)。
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushRecordQuery {
/** 订阅方 appKey */
private Integer appKey;
/** 接口路径,如 group/AddGroup */
private String interfacePath;
/** 是否成功:0失败 1成功 */
private Integer success;
/** 起始时间 yyyy-MM-dd(含当天 00:00:00) */
private String startTime;
/** 结束时间 yyyy-MM-dd(含当天 23:59:59) */
private String endTime;
}
@@ -0,0 +1,38 @@
package com.yunhai.userbase.model.push;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* MQ 推送任务消息。<br>
* 由 {@code PushDispatcher}(生产方)投递,{@code PushConsumer}(消费方)取出后 POST 给订阅方。<br>
* 签名(t + sign)在投递时由基础平台计算一次;自动重试保持原 t/sign,手动重试重新生成。
*
* @author chenx
* @date 2026/8/14
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PushTaskMessage implements Serializable {
/** 订阅方 appKey */
private Integer appKey;
/** 接口路径,如 group/AddGroup */
private String interfacePath;
/** 接口友好名 */
private String interfaceLabel;
/** 业务数据原始 JSON 字符串(签名与发送均基于该原始字符串字节) */
private String body;
/** 签名时间戳(字符串) */
private String t;
/** HMAC-SHA256(业务JSON + t, secret) hex 小写 */
private String sign;
/** 第几次尝试:1=首次 */
private int attemptNo;
/** 触发类型:manual / auto / retry */
private String triggerType;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.push;
import lombok.Data;
import java.util.List;
/**
* 测试推送请求(测试推送页调用)。
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushTestRequest {
/** 订阅方应用 id */
private Integer appId;
/** 接口路径,如 group/AddGroup */
private String interfacePath;
/** 目标 id 列表(可选):不传则按接口类型取全量/全部 */
private List<Integer> targetIds;
}
@@ -0,0 +1,32 @@
package com.yunhai.userbase.model.push.dto;
import lombok.Data;
import java.util.List;
/**
* 文档「组」契约 DTO(group/SyncGroup、AddGroup、UpdateGroup 共用)。<br>
* 字段严格对齐《弘育基础平台数据同步接口 2》:
* <ul>
* <li>SyncGroup:children 递归树(可空)</li>
* <li>AddGroup:pid 必填(上级组 id,0 为顶级)</li>
* <li>UpdateGroup:仅 id 必填,其余可选</li>
* </ul>
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushGroupDTO {
private Integer id;
private String name;
private String code;
/** 组类别 1-8,与 GroupTypeEnum 1:1 零映射 */
private Integer type;
/** 上级组 id(AddGroup 必填) */
private Integer pid;
private Integer no;
/** 子组列表(SyncGroup 用) */
private List<PushGroupDTO> children;
}
@@ -0,0 +1,53 @@
package com.yunhai.userbase.model.push.dto;
import lombok.Data;
import java.util.List;
/**
* 文档「用户」契约 DTO(user/SyncUser、AddUser、UpdateUser 共用)。<br>
* 字段严格对齐《弘育基础平台数据同步接口 2》:
* <ul>
* <li>status:0正常 / 2禁用(由 userbase StatusEnum 映射)</li>
* <li>groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)</li>
* <li>roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)</li>
* </ul>
*
* @author chenx
* @date 2026/8/14
*/
@Data
public class PushUserDTO {
private Integer id;
private String account;
private String name;
private String idcard;
private String phone;
private String email;
/** 性别 0女 1男 */
private Integer gender;
private String birthday;
/** 用户状态 0正常 2禁用 */
private Integer status;
private String profilePicture;
private List<PushUserGroupRef> groups;
private List<PushUserRoleRef> roleList;
/** 用户所属组引用。 */
@Data
public static class PushUserGroupRef {
private Integer groupId;
private Integer no;
/** 组所属学校范围 id(沿 parentId 向上最近的 SCHOOL_GROUP) */
private Integer schoolId;
}
/** 用户身份引用。 */
@Data
public static class PushUserRoleRef {
private Integer roleId;
/** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */
private Integer schId;
}
}
@@ -20,4 +20,7 @@ public class ChangeGroupsRequest {
/** 目标组ID列表(覆盖式) */ /** 目标组ID列表(覆盖式) */
@NotEmpty(message = "请至少保留一个组") @NotEmpty(message = "请至少保留一个组")
private List<Integer> groupIds; 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,40 @@
package com.yunhai.userbase.model.sysuser;
/**
* 批量删除用户-组关系单项(与 BasePlatform 开放接口模型 {@code SysUserGroupDeleteItem} 字段对齐)。<br>
* 每条记录携带自身所属学校范围 {@code schoolId},供 {@code SysUserService.deleteUsers} 逐条校验:<br>
* groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校解绑 / 整校移除)。
*/
public class SysUserGroupDeleteItem {
/** 待解绑用户 ID */
private Integer userId;
/** 待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId) */
private Integer groupId;
/** 学校组 ID:限定该条解绑的学校范围 */
private Integer schoolId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getGroupId() {
return groupId;
}
public void setGroupId(Integer groupId) {
this.groupId = groupId;
}
public Integer getSchoolId() {
return schoolId;
}
public void setSchoolId(Integer schoolId) {
this.schoolId = schoolId;
}
}
@@ -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,24 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @description 开放接口:移动用户到指定组请求(语义同后台「调整组」——覆盖式把用户组归属替换为目标组,目标设为主组)。
*/
@Data
public class SysUserMoveRequest {
/** 待移动用户ID。 */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 目标组ID(移动后的主归属组,必须存在)。 */
@NotNull(message = "目标组ID不能为空")
private Integer groupId;
/** 学校组ID:限定移动范围,仅允许将用户在同一学校(组)范围内移动。 */
@NotNull(message = "学校ID不能为空")
private Integer schoolId;
}
@@ -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 String email;
private Integer gender; private Integer gender;
private String profilePicture; 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=禁用);不传则默认启用 */ /** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */
private Integer disabled; private Integer disabled;
private Boolean expired; 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; private Integer type;
/** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */ /** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */
private Integer sortNo; private Integer sortNo;
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
private Integer groupStatus;
/**
* 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。<br>
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
*/
private String identityNames;
private Boolean expired; private Boolean expired;
private LocalDateTime createTime; private LocalDateTime createTime;
private LocalDateTime updateTime; private LocalDateTime updateTime;

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