Compare commits
40
Commits
8ff6e7ac0c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a21c76aa | ||
|
|
426169054e | ||
|
|
751a482456 | ||
|
|
07e803b440 | ||
|
|
1047d2387f | ||
|
|
79b1d99db9 | ||
|
|
d018567642 | ||
|
|
53b2fedb7a | ||
|
|
73e404633d | ||
|
|
a937984f0b | ||
|
|
3e38d702e6 | ||
|
|
71adaf6acb | ||
|
|
bf4e1552b8 | ||
|
|
c588dedea7 | ||
|
|
ebbaeee181 | ||
|
|
6675ccc8d5 | ||
|
|
5627252eab | ||
|
|
0b0be6b8e5 | ||
|
|
f182fe44ce | ||
|
|
e4d6e1da11 | ||
|
|
355920746b | ||
|
|
db0d5e1a2d | ||
|
|
e001bc9d77 | ||
|
|
99414e2887 | ||
|
|
d472902957 | ||
|
|
f3656057d4 | ||
|
|
a91dacd442 | ||
|
|
4c67ec0ed0 | ||
|
|
b521f9634b | ||
|
|
2ca67730f7 | ||
|
|
44f966c33b | ||
|
|
52b29c5082 | ||
|
|
7a1dc29ff7 | ||
|
|
60360bfb6b | ||
|
|
ac95b2463b | ||
|
|
6543203bc6 | ||
|
|
05c7741954 | ||
|
|
165d46b156 | ||
|
|
87163ba944 | ||
|
|
236c9545f9 |
+45
-105
@@ -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,70 +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 \
|
|
||||||
--memory=1.5g --memory-swap=1.5g \
|
|
||||||
-v /data/logs/userbase:/app/logs \
|
|
||||||
-e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \
|
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \
|
|
||||||
-e NACOS_CONFIG_ENABLED=true \
|
|
||||||
-e NACOS_ADDR=nacos:8848 \
|
|
||||||
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
|
|
||||||
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
|
|
||||||
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
|
|
||||||
-e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \
|
|
||||||
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
|
|
||||||
-e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \
|
|
||||||
"$IMG"
|
|
||||||
# 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来
|
|
||||||
OK=0
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
sleep 5
|
|
||||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ 2>/dev/null || true)
|
|
||||||
if [ "$CODE" != "000" ]; then OK=1; break; fi
|
|
||||||
done
|
|
||||||
if [ "$OK" = "0" ]; then
|
|
||||||
echo "!! UNHEALTHY -> rollback to userbase-service:latest"
|
|
||||||
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
|
|
||||||
# 回滚也必须注入同样的 env,否则 bootstrap 默认 enabled=false 会连不上 nacos 拉不到线上配置
|
|
||||||
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \
|
|
||||||
--add-host sso.yunhaixingtong.com:192.168.10.160 \
|
|
||||||
--memory=1.5g --memory-swap=1.5g \
|
|
||||||
-v /data/logs/userbase:/app/logs \
|
|
||||||
-e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \
|
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \
|
|
||||||
-e NACOS_CONFIG_ENABLED=true \
|
|
||||||
-e NACOS_ADDR=nacos:8848 \
|
|
||||||
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
|
|
||||||
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
|
|
||||||
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
|
|
||||||
-e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \
|
|
||||||
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
|
|
||||||
-e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \
|
|
||||||
userbase-service:latest
|
|
||||||
echo "rolled back"; exit 7
|
|
||||||
fi
|
|
||||||
echo "DEPLOY OK: $IMG"
|
|
||||||
docker tag "$IMG" userbase-service:latest
|
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep userbase || true
|
|
||||||
|
|||||||
@@ -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);
|
||||||
+25
-12
@@ -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:
|
||||||
|
|||||||
+3
-4
@@ -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
|
||||||
|
|||||||
@@ -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,17 +68,19 @@
|
|||||||
<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>
|
||||||
@@ -194,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。 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yunhai.userbase.common.converter;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
|
||||||
|
|
||||||
|
import javax.persistence.AttributeConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserGroupRelStatusEnum 专用 JPA 转换器(int <-> 枚举)。<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(组织架构型:变更以天/周计,脏数据影响祖先链→开通判定;组写频次极低,命中率 >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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,7 +23,10 @@ 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;
|
||||||
@@ -30,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 {
|
||||||
@@ -50,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();
|
||||||
@@ -119,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);
|
||||||
@@ -127,52 +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("保存应用推送设置")
|
@OperationLog("保存应用推送设置")
|
||||||
@PostMapping("SavePushSetting/{id}")
|
@PostMapping("SavePushSetting/{id}")
|
||||||
public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) {
|
public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) {
|
||||||
|
accessScopeService.requireSuper();
|
||||||
applicationInfoService.savePushSetting(id, request);
|
applicationInfoService.savePushSetting(id, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("GetPushSetting/{id}")
|
@GetMapping("GetPushSetting/{id}")
|
||||||
public PushSettingVo getPushSetting(@PathVariable Integer id) {
|
public PushSettingVo getPushSetting(@PathVariable Integer id) {
|
||||||
|
accessScopeService.requireSuper();
|
||||||
return applicationInfoService.getPushSetting(id);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,26 @@ package com.yunhai.userbase.controller.api;
|
|||||||
import com.yunhai.userbase.config.Config;
|
import com.yunhai.userbase.config.Config;
|
||||||
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.push.PushRecordEntity;
|
import com.yunhai.userbase.model.push.PushRecordListVo;
|
||||||
import com.yunhai.userbase.model.push.PushRecordQuery;
|
import com.yunhai.userbase.model.push.PushRecordQuery;
|
||||||
import com.yunhai.userbase.model.push.PushTestRequest;
|
import com.yunhai.userbase.model.push.PushTestRequest;
|
||||||
import com.yunhai.userbase.service.push.PushDispatcher;
|
import com.yunhai.userbase.service.push.PushDispatcher;
|
||||||
import com.yunhai.userbase.service.push.PushRecordService;
|
import com.yunhai.userbase.service.push.PushRecordService;
|
||||||
|
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||||
import com.yunhai.userbase.utils.PageUtils;
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.web.SortDefault;
|
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 org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -24,6 +33,7 @@ import java.util.Map;
|
|||||||
* @date 2026/8/14
|
* @date 2026/8/14
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
|
@Slf4j
|
||||||
@RequestMapping(value = Config.API_PATH + "/push/")
|
@RequestMapping(value = Config.API_PATH + "/push/")
|
||||||
public class PushSubscriptionController {
|
public class PushSubscriptionController {
|
||||||
|
|
||||||
@@ -32,9 +42,13 @@ public class PushSubscriptionController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PushRecordService pushRecordService;
|
private PushRecordService pushRecordService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccessScopeService accessScopeService;
|
||||||
|
|
||||||
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
|
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
|
||||||
@PostMapping("Test")
|
@PostMapping("Test")
|
||||||
public Map<String, Object> test(@RequestBody PushTestRequest request) {
|
public Map<String, Object> test(@RequestBody PushTestRequest request) {
|
||||||
|
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||||
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||||
Map<String, Object> preview = pushDispatcher.previewManual(
|
Map<String, Object> preview = pushDispatcher.previewManual(
|
||||||
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||||
@@ -45,16 +59,47 @@ public class PushSubscriptionController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推送日志列表。 */
|
/** 推送日志列表(不含 payload,避免全量推送数据量过大;payload 经 Download 接口单独获取)。 */
|
||||||
@GetMapping("RecordList")
|
@GetMapping("RecordList")
|
||||||
public BasePageResult<PushRecordEntity> recordList(PushRecordQuery query, BasePageQuery pageQuery,
|
public BasePageResult<PushRecordListVo> recordList(PushRecordQuery query, BasePageQuery pageQuery,
|
||||||
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
|
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
|
||||||
|
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||||
return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort)));
|
return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动重试一条失败的推送记录。 */
|
/** 手动重试一条失败的推送记录。 */
|
||||||
@PostMapping("Retry/{id}")
|
@PostMapping("Retry/{id}")
|
||||||
public void retry(@PathVariable Long id) {
|
public void retry(@PathVariable Long id) {
|
||||||
|
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||||
pushRecordService.retry(id);
|
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,11 +38,16 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +56,10 @@ public class SysOperationLogController {
|
|||||||
* 采用与项目 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,32 +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());
|
||||||
|
return sysUserService.add(request);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,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)));
|
||||||
@@ -110,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}")
|
||||||
@@ -124,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
package com.yunhai.userbase.controller.oapi;
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
import com.yunhai.platform.utils.model.BoaGroup;
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
import com.yunhai.userbase.config.Config;
|
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 com.yunhai.userbase.service.ISysGroupService;
|
||||||
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;
|
||||||
@@ -60,4 +68,49 @@ public class GroupController {
|
|||||||
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
||||||
return sysGroupService.getGroupList(ids, 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 request 用于取 AppAuthFilter 设置的 appKey
|
||||||
* @param userId 用户ID(必填)
|
* @param userId 用户ID(必填)
|
||||||
* @param schId 学校ID/组ID(可选;为 null 时按上述规则推断)
|
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||||
* @return 扁平化的已授权模块列表(含基础平台统一 BoaModule 模型);无权限时返回空列表
|
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||||
|
* @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()) {
|
|
||||||
String icon = appVo.getPcIcon();
|
|
||||||
collectModules(appVo.getModules(), icon, result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。<br>
|
||||||
|
* 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。
|
||||||
|
*/
|
||||||
|
private Integer resolveCallerAppId(HttpServletRequest request) {
|
||||||
|
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||||
|
if (attr == null) {
|
||||||
|
log.warn("[oapi:user:modules] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
|
||||||
|
request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||||
|
if (appKeyStr.trim().isEmpty()) {
|
||||||
|
log.warn("[oapi:user:modules] AppAuth attr is blank, uri={}", request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Integer appKey;
|
||||||
|
try {
|
||||||
|
appKey = Integer.parseInt(appKeyStr.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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)。
|
||||||
|
*/
|
||||||
|
@PostMapping("password")
|
||||||
|
public void password(@RequestBody @Validated SysUserPasswordRequest request) {
|
||||||
|
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
||||||
}
|
}
|
||||||
BoaGroup school = boa.getSchool();
|
|
||||||
if (school != null && school.getId() != null) {
|
/**
|
||||||
return school.getId();
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
|
||||||
|
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
||||||
|
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。<br>
|
||||||
|
* <b>注意必须显式返回 {@link ApiResponse} 信封</b>:若直接返回 String,
|
||||||
|
* ApiResponseAdvice 会跳过包装(String 走 StringHttpMessageConverter 无法套壳),
|
||||||
|
* 响应变成裸文本 URL,调用方(yh-openapi-client)按 JSON 解析会报 deserialize failed。
|
||||||
|
*/
|
||||||
|
@PostMapping("profilePicture")
|
||||||
|
public ApiResponse<String> profilePicture(@RequestParam("userId") Integer userId,
|
||||||
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
return ApiResponse.success(sysUserService.changeProfilePicture(userId, file));
|
||||||
}
|
}
|
||||||
List<BoaGroup> groups = boa.getGroups();
|
|
||||||
if (groups != null && !groups.isEmpty()) {
|
/**
|
||||||
BoaGroup main = groups.get(0);
|
* 设置用户在指定组上的状态(转出/恢复)。<br>
|
||||||
return main == null ? null : main.getId();
|
* 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());
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
/**
|
||||||
|
* 查询用户在指定组上的状态: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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 > SCHOOL > BUREAU > 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;
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,9 @@ public class DataSyncEvent {
|
|||||||
private List<Integer> groupIds;
|
private List<Integer> groupIds;
|
||||||
/** 用户事件携带的受影响用户 id 列表 */
|
/** 用户事件携带的受影响用户 id 列表 */
|
||||||
private List<Integer> userIds;
|
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&days=7&top=10
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = Config.API_PATH + "/oapimetrics/")
|
||||||
|
public class OapiMetricsController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OapiMetricsRepository repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopN 热度报告:Top 接口 + appKey 分层分布。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "[LOADTEST-METRICS] oapi热度TopN报告", operationId = "oapiMetricsTopN")
|
||||||
|
@GetMapping("/TopN")
|
||||||
|
public ReportVo topN(
|
||||||
|
@RequestParam(value = "dimension", defaultValue = "both") String dimension,
|
||||||
|
@RequestParam(value = "days", defaultValue = "1") Integer days,
|
||||||
|
@RequestParam(value = "top", defaultValue = "10") Integer top) {
|
||||||
|
|
||||||
|
int d = clamp(days == null ? 1 : days, 1, 30);
|
||||||
|
int n = clamp(top == null ? 10 : top, 1, 100);
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(d);
|
||||||
|
ReportVo vo = new ReportVo();
|
||||||
|
vo.setGeneratedAt(LocalDateTime.now().toString());
|
||||||
|
vo.setDays(d);
|
||||||
|
vo.setTop(n);
|
||||||
|
|
||||||
|
Object[] total = repository.aggregateTotal(since, 0);
|
||||||
|
vo.setTotalCnt(asLong(total, 0));
|
||||||
|
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
|
||||||
|
vo.setTotalErrCnt(asLong(total, 2));
|
||||||
|
|
||||||
|
if ("uri".equals(dimension) || "both".equals(dimension)) {
|
||||||
|
List<UriRow> rows = new ArrayList<>();
|
||||||
|
for (Object[] r : repository.aggregateByUri(since, 0)) {
|
||||||
|
rows.add(toUriRow(r));
|
||||||
|
if (rows.size() >= n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vo.setTopUris(rows);
|
||||||
|
}
|
||||||
|
if ("appkey".equals(dimension) || "both".equals(dimension)) {
|
||||||
|
List<AppKeyRow> rows = new ArrayList<>();
|
||||||
|
for (Object[] r : repository.aggregateByAppKey(since, 0)) {
|
||||||
|
AppKeyRow row = new AppKeyRow();
|
||||||
|
row.setAppKey((String) r[0]);
|
||||||
|
row.setCnt(asLong(r, 1));
|
||||||
|
row.setAvgCostMs(avg(asLong(r, 1), asLong(r, 2)));
|
||||||
|
row.setErrCnt(asLong(r, 3));
|
||||||
|
rows.add(row);
|
||||||
|
if (rows.size() >= n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vo.setAppKeys(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 压测流量合计(单列口径,不与线上热度混算)
|
||||||
|
Object[] lt = repository.aggregateTotal(since, 1);
|
||||||
|
vo.setLoadTestCnt(asLong(lt, 0));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UriRow toUriRow(Object[] r) {
|
||||||
|
UriRow row = new UriRow();
|
||||||
|
row.setUri((String) r[0]);
|
||||||
|
row.setMethod((String) r[1]);
|
||||||
|
row.setCnt(asLong(r, 2));
|
||||||
|
row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3)));
|
||||||
|
row.setErrCnt(asLong(r, 4));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long asLong(Object[] arr, int i) {
|
||||||
|
if (arr == null || i >= arr.length || arr[i] == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return ((Number) arr[i]).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double avg(long cnt, long total) {
|
||||||
|
return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int clamp(int v, int min, int max) {
|
||||||
|
return v < min ? min : (v > max ? max : v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报告 VO(嵌套声明,压测结束随包整体删除) */
|
||||||
|
@Data
|
||||||
|
public static class ReportVo {
|
||||||
|
private String generatedAt;
|
||||||
|
private Integer days;
|
||||||
|
private Integer top;
|
||||||
|
/** 线上总请求数 */
|
||||||
|
private Long totalCnt;
|
||||||
|
private Double totalAvgCostMs;
|
||||||
|
private Long totalErrCnt;
|
||||||
|
/** Top 接口热度 */
|
||||||
|
private List<UriRow> topUris;
|
||||||
|
/** appKey 分层分布 */
|
||||||
|
private List<AppKeyRow> appKeys;
|
||||||
|
/** 压测流量请求数(is_load_test=1 单列,不混入上面各项) */
|
||||||
|
private Long loadTestCnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class UriRow {
|
||||||
|
private String uri;
|
||||||
|
private String method;
|
||||||
|
private Long cnt;
|
||||||
|
private Double avgCostMs;
|
||||||
|
private Long errCnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class AppKeyRow {
|
||||||
|
private String appKey;
|
||||||
|
private Long cnt;
|
||||||
|
private Double avgCostMs;
|
||||||
|
private Long errCnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.yunhai.userbase.loadtestmetrics;
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
|
||||||
|
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||||
|
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
|
||||||
|
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Index;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [LOADTEST-METRICS] oapi 接口访问频次指标(60s 切片聚合)。<br>
|
||||||
|
* 区别于 sys_oapi_log(逐请求审计日志):本表是方案 §2.2 的轻量统计层——
|
||||||
|
* Filter 层内存累加、定时快照,GET/401 全覆盖,不丢数据、不膨胀。<br>
|
||||||
|
* 表结构由 ddl-auto=update 自动创建;生产 DBA 手工建表脚本见
|
||||||
|
* resources/sql/oapi_metrics_loadtest.sql。
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(name = "oapi_metrics", indexes = {
|
||||||
|
@Index(name = "idx_time", columnList = "stat_time"),
|
||||||
|
@Index(name = "idx_appkey_time", columnList = "app_key, stat_time")
|
||||||
|
})
|
||||||
|
public class OapiMetricsEntity implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 统计切片时间(该 60s 窗口的结束时刻) */
|
||||||
|
@Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME")
|
||||||
|
private LocalDateTime statTime;
|
||||||
|
|
||||||
|
/** 规范化路由(去 query、纯数字路径段归一为 {id}),最长 200 */
|
||||||
|
@Column(name = "uri", nullable = false, length = 200)
|
||||||
|
private String uri;
|
||||||
|
|
||||||
|
/** GET/POST/PUT/DELETE */
|
||||||
|
@Column(name = "method", nullable = false, length = 10)
|
||||||
|
private String method;
|
||||||
|
|
||||||
|
/** 调用方 appKey:验签成功为权威值,未验签(401)为 X-App-Key header 原值,无 header 为 "-" */
|
||||||
|
@Column(name = "app_key", nullable = false, length = 32)
|
||||||
|
private String appKey;
|
||||||
|
|
||||||
|
/** 窗口内请求次数 */
|
||||||
|
@Column(name = "cnt", nullable = false)
|
||||||
|
private Long cnt;
|
||||||
|
|
||||||
|
/** 窗口内耗时累计 ms */
|
||||||
|
@Column(name = "total_cost", nullable = false)
|
||||||
|
private Long totalCost;
|
||||||
|
|
||||||
|
/** 窗口内 HTTP 非 200 次数(Filter 层口径,不含响应体 code:5 细化) */
|
||||||
|
@Column(name = "err_cnt", nullable = false)
|
||||||
|
private Long errCnt;
|
||||||
|
|
||||||
|
/** 压测流量单列:请求头 X-Load-Test: 1 → 1,不污染线上热度 */
|
||||||
|
@Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)")
|
||||||
|
private Integer isLoadTest;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.yunhai.userbase.loadtestmetrics;
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一(方案 §2.1)
|
||||||
|
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||||
|
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
|
||||||
|
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||||
|
|
||||||
|
import javax.servlet.Filter;
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.ServletRequest;
|
||||||
|
import javax.servlet.ServletResponse;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [LOADTEST-METRICS] oapi 访问频次采集过滤器。<br>
|
||||||
|
* 只在 Filter 层记 HTTP 状态(方案 §2.1),不解响应体;业务错误(HTTP 200 + code:5)
|
||||||
|
* 细化继续由 sys_oapi_log 补充分析。<br>
|
||||||
|
* 注册见 {@link LoadTestMetricsConfig}(order=HIGHEST_PRECEDENCE+10,先于 401 闸门,
|
||||||
|
* 保证 401 验签失败请求也能统计)。appKey 取值优先级:<br>
|
||||||
|
* 1. {@code AppAuthFilter.APP_AUTH_ATTR}(验签后权威值);<br>
|
||||||
|
* 2. 降级 {@code X-App-Key} header(401 请求,标记未验签的 header 原值);<br>
|
||||||
|
* 3. 均无 → "-"(无签名头,将被 RequireAppSignFilter 401)。
|
||||||
|
*/
|
||||||
|
public class OapiMetricsFilter implements Filter {
|
||||||
|
|
||||||
|
/** 压测流量标记头(方案 §2.4,k6 全部请求携带) */
|
||||||
|
public static final String HEADER_LOAD_TEST = "X-Load-Test";
|
||||||
|
|
||||||
|
/** appKey 降级取值的 header(yh-app-auth 签名三件套之一) */
|
||||||
|
public static final String HEADER_APP_KEY = "X-App-Key";
|
||||||
|
|
||||||
|
private static final int MAX_URI_LEN = 200;
|
||||||
|
private static final int MAX_APP_KEY_LEN = 32;
|
||||||
|
|
||||||
|
private final OapiMetricsCollector collector;
|
||||||
|
|
||||||
|
public OapiMetricsFilter(OapiMetricsCollector collector) {
|
||||||
|
this.collector = collector;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
HttpServletRequest req = (HttpServletRequest) request;
|
||||||
|
HttpServletResponse res = (HttpServletResponse) response;
|
||||||
|
|
||||||
|
String uri = normalizeUri(req);
|
||||||
|
String method = req.getMethod();
|
||||||
|
String appKey = resolveAppKey(req);
|
||||||
|
boolean loadTest = isLoadTest(req);
|
||||||
|
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
boolean error = false;
|
||||||
|
try {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
error = res.getStatus() != 200;
|
||||||
|
} catch (IOException | ServletException e) {
|
||||||
|
error = true;
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
collector.record(uri, method, appKey, System.currentTimeMillis() - start, error, loadTest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id}
|
||||||
|
* (如 /oapi/user/edit/123 → /oapi/user/edit/{id}),防止 PathVariable 撑爆维度基数。
|
||||||
|
*/
|
||||||
|
private String normalizeUri(HttpServletRequest req) {
|
||||||
|
String uri = req.getRequestURI();
|
||||||
|
String ctx = req.getContextPath();
|
||||||
|
if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) {
|
||||||
|
uri = uri.substring(ctx.length());
|
||||||
|
}
|
||||||
|
if (uri.indexOf('/') >= 0) {
|
||||||
|
String[] segs = uri.split("/");
|
||||||
|
StringBuilder sb = new StringBuilder(uri.length());
|
||||||
|
for (String seg : segs) {
|
||||||
|
if (seg.length() > 0 && isDigits(seg)) {
|
||||||
|
seg = "{id}";
|
||||||
|
}
|
||||||
|
sb.append('/').append(seg);
|
||||||
|
}
|
||||||
|
uri = sb.toString();
|
||||||
|
}
|
||||||
|
if (uri.length() > MAX_URI_LEN) {
|
||||||
|
uri = uri.substring(0, MAX_URI_LEN);
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDigits(String s) {
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
if (!Character.isDigit(s.charAt(i))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** appKey:验签 attr 权威值 → X-App-Key header(401 未验签)→ "-" */
|
||||||
|
private String resolveAppKey(HttpServletRequest req) {
|
||||||
|
Object attr = req.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||||
|
if (attr != null) {
|
||||||
|
String v = String.valueOf(attr).trim();
|
||||||
|
if (!v.isEmpty()) {
|
||||||
|
return truncate(v, MAX_APP_KEY_LEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String header = req.getHeader(HEADER_APP_KEY);
|
||||||
|
if (header != null && !header.trim().isEmpty()) {
|
||||||
|
return truncate(header.trim(), MAX_APP_KEY_LEN);
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */
|
||||||
|
private boolean isLoadTest(HttpServletRequest req) {
|
||||||
|
String v = req.getHeader(HEADER_LOAD_TEST);
|
||||||
|
if (v == null || v.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
v = v.trim();
|
||||||
|
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String s, int max) {
|
||||||
|
return s.length() > max ? s.substring(0, max) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yunhai.userbase.loadtestmetrics;
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一
|
||||||
|
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||||
|
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。
|
||||||
|
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** [LOADTEST-METRICS] oapi_metrics 聚合查询(TopN 报告用)。 */
|
||||||
|
public interface OapiMetricsRepository extends JpaRepository<OapiMetricsEntity, Long> {
|
||||||
|
|
||||||
|
/** 按 uri+method 聚合(热度 TopN),按总次数降序 */
|
||||||
|
@Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
|
||||||
|
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest "
|
||||||
|
+ "group by e.uri, e.method order by sum(e.cnt) desc")
|
||||||
|
List<Object[]> aggregateByUri(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
|
||||||
|
|
||||||
|
/** 按 appKey 聚合(调用方分层分布),按总次数降序 */
|
||||||
|
@Query("select e.appKey, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
|
||||||
|
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest "
|
||||||
|
+ "group by e.appKey order by sum(e.cnt) desc")
|
||||||
|
List<Object[]> aggregateByAppKey(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
|
||||||
|
|
||||||
|
/** 全量汇总(总次数/总耗时/总错误) */
|
||||||
|
@Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e "
|
||||||
|
+ "where e.statTime >= :since and e.isLoadTest = :isLoadTest")
|
||||||
|
Object[] aggregateTotal(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.yunhai.userbase.model;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysGroup;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_group 全表不可变快照({@code groupSnapshot} 缓存的值类型,单条目装整个快照)。<br><br>
|
||||||
|
*
|
||||||
|
* 形态:不可变 List + 两个派生索引(byId / childrenMap),整体驱逐 + 重建,
|
||||||
|
* <b>不做增量维护</b>(避免在缓存层重复实现树结构逻辑)。<br>
|
||||||
|
* 存放:Caffeine 仅本地(约 1 万行 ≈ JVM 4~8MB;进 Redis 则每次读取网络传输 2MB,
|
||||||
|
* 权限计算一次请求读 N 次,无意义流量),消费方拿到的是对象引用,零序列化零网络。<br>
|
||||||
|
* 失效:{@code GroupChangedEvent} AFTER_COMMIT 驱逐整条,下次读取排队重建。<br>
|
||||||
|
* <b>消费方约定:只读,严禁改动快照内的实体对象</b>(快照被全请求共享,写一个等于污染全部)。
|
||||||
|
*/
|
||||||
|
public class GroupSnapshot {
|
||||||
|
|
||||||
|
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
|
||||||
|
private static final int ROOT_PARENT_ID = 0;
|
||||||
|
|
||||||
|
/** 全表记录(构造时已定序,顺序与派生索引一致)。 */
|
||||||
|
private final List<SysGroup> all;
|
||||||
|
/** id → 组。 */
|
||||||
|
private final Map<Integer, SysGroup> byId;
|
||||||
|
/** parentId(null 归一为 0)→ 直接子节点列表(同层按 sort 升序、再按 id 升序)。 */
|
||||||
|
private final Map<Integer, List<SysGroup>> childrenMap;
|
||||||
|
/** 行级字节粗估(Caffeine 按字节限重的 weigher 用,量级正确即可)。 */
|
||||||
|
private final long estimatedBytes;
|
||||||
|
|
||||||
|
public GroupSnapshot(List<SysGroup> groups) {
|
||||||
|
List<SysGroup> list = new ArrayList<>();
|
||||||
|
Map<Integer, SysGroup> idMap = new HashMap<>();
|
||||||
|
Map<Integer, List<SysGroup>> childMap = new HashMap<>();
|
||||||
|
if (groups != null) {
|
||||||
|
for (SysGroup g : groups) {
|
||||||
|
if (g == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list.add(g);
|
||||||
|
idMap.put(g.getId(), g);
|
||||||
|
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
|
||||||
|
childMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Comparator<SysGroup> siblingOrder = (a, b) -> {
|
||||||
|
int sa = a.getSort() == null ? 999 : a.getSort();
|
||||||
|
int sb = b.getSort() == null ? 999 : b.getSort();
|
||||||
|
if (sa != sb) {
|
||||||
|
return Integer.compare(sa, sb);
|
||||||
|
}
|
||||||
|
return Integer.compare(a.getId(), b.getId());
|
||||||
|
};
|
||||||
|
for (List<SysGroup> siblings : childMap.values()) {
|
||||||
|
siblings.sort(siblingOrder);
|
||||||
|
}
|
||||||
|
this.all = Collections.unmodifiableList(list);
|
||||||
|
this.byId = Collections.unmodifiableMap(idMap);
|
||||||
|
this.childrenMap = Collections.unmodifiableMap(childMap);
|
||||||
|
// 行均按 512B 保守估算(名称/类型/审计字段等实际 150~250B,翻倍留余量)
|
||||||
|
this.estimatedBytes = 512L * list.size() + 65536L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SysGroup> getAll() {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Integer, SysGroup> getById() {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Integer, List<SysGroup>> getChildrenMap() {
|
||||||
|
return childrenMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否存在任何组(空表时 false)。 */
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return all.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caffeine weigher 用的字节粗估值。 */
|
||||||
|
public long getEstimatedBytes() {
|
||||||
|
return estimatedBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public class UserGroupNodeVo {
|
|||||||
/** 组名称。 */
|
/** 组名称。 */
|
||||||
private String name;
|
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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.yunhai.userbase.model.sysuser;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户身份回显 VO(GET /sysuser/UserIdentity)。<br>
|
||||||
|
* 后端按所在组解析 schoolId 后返回;同一学校多组返回同一 schoolId 与角色并集。<br>
|
||||||
|
* 编辑请求提交时原样携带 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/27
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UserIdentityVo {
|
||||||
|
|
||||||
|
/** 身份归属学校(解析后的真实组 id:学校组/区域组/根节点) */
|
||||||
|
private Integer schoolId;
|
||||||
|
|
||||||
|
/** 该校用户身份角色(SysRoleEnum.key 1-7,并集去重) */
|
||||||
|
private List<Integer> roleIds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.yunhai.userbase.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysOapiLog;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口调用日志仓储。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
public interface SysOapiLogRepository extends JpaRepository<SysOapiLog, Long>,
|
||||||
|
JpaSpecificationExecutor<SysOapiLog> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。<br>
|
||||||
|
* 用于定时清理:一般日志保留 12 个月,重要/次要日志保留 36 个月。<br>
|
||||||
|
*
|
||||||
|
* @param logLevel 日志级别编码
|
||||||
|
* @param createTime 截止时间(早于该时间的记录被删除)
|
||||||
|
* @return 删除条数
|
||||||
|
*/
|
||||||
|
long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.userbase.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysSchoolAppOverride;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校应用私有覆盖仓储。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/9/4
|
||||||
|
*/
|
||||||
|
public interface SysSchoolAppOverrideRepository extends JpaRepository<SysSchoolAppOverride, Integer> {
|
||||||
|
|
||||||
|
/** 指定学校集合的全部应用覆盖行(AppList/AppSortList 合并用) */
|
||||||
|
List<SysSchoolAppOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
|
||||||
|
|
||||||
|
SysSchoolAppOverride findBySchoolIdAndAppId(Integer schoolId, Integer appId);
|
||||||
|
|
||||||
|
/** 删除应用时级联清理该应用在所有学校的覆盖行 */
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM SysSchoolAppOverride o WHERE o.appId = :appId")
|
||||||
|
void deleteByAppIdBulk(@Param("appId") Integer appId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.userbase.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysSchoolCategoryOverride;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校应用类型私有覆盖仓储。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/9/4
|
||||||
|
*/
|
||||||
|
public interface SysSchoolCategoryOverrideRepository extends JpaRepository<SysSchoolCategoryOverride, Integer> {
|
||||||
|
|
||||||
|
/** 指定学校集合的全部类型覆盖行(AppTypeList 合并用) */
|
||||||
|
List<SysSchoolCategoryOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
|
||||||
|
|
||||||
|
SysSchoolCategoryOverride findBySchoolIdAndCategoryId(Integer schoolId, Integer categoryId);
|
||||||
|
|
||||||
|
/** 删除应用类型时级联清理该类型在所有学校的覆盖行 */
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM SysSchoolCategoryOverride o WHERE o.categoryId = :categoryId")
|
||||||
|
void deleteByCategoryIdBulk(@Param("categoryId") Integer categoryId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.yunhai.userbase.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysUserCurrentRole;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/18
|
||||||
|
* @description 用户当前激活身份仓储(一对一:user_id 唯一)
|
||||||
|
*/
|
||||||
|
public interface SysUserCurrentRoleRepository extends JpaRepository<SysUserCurrentRole, Integer>, JpaSpecificationExecutor<SysUserCurrentRole> {
|
||||||
|
|
||||||
|
Optional<SysUserCurrentRole> findByUserId(Integer userId);
|
||||||
|
|
||||||
|
/** 删除用户当前激活身份记录(级联删用户时清理残留切换记录) */
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM SysUserCurrentRole r WHERE r.userId = :userId")
|
||||||
|
void deleteByUserIdBulk(@Param("userId") Integer userId);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ public interface SysUserRepository extends JpaRepository<SysUser, Integer>, JpaS
|
|||||||
* 入参 name / disabled 为可空:为空(NULL)时该过滤条件整体跳过。
|
* 入参 name / disabled 为可空:为空(NULL)时该过滤条件整体跳过。
|
||||||
*/
|
*/
|
||||||
@Query(value =
|
@Query(value =
|
||||||
"SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo " +
|
"SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo, MAX(COALESCE(r.status, 0)) AS groupStatus " +
|
||||||
"FROM sys_user u " +
|
"FROM sys_user u " +
|
||||||
"INNER JOIN sys_user_group_rel r ON r.user_id = u.id " +
|
"INNER JOIN sys_user_group_rel r ON r.user_id = u.id " +
|
||||||
"WHERE r.group_id IN (:groupIds) " +
|
"WHERE r.group_id IN (:groupIds) " +
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.yunhai.userbase.repository;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||||
|
import com.yunhai.userbase.enums.StatusEnum;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/18
|
||||||
|
* @description 用户-SysRoleEnum角色-学校 关联仓储(平台身份角色权威源)
|
||||||
|
*/
|
||||||
|
public interface SysUserRoleRelRepository extends JpaRepository<SysUserRoleRel, Integer>, JpaSpecificationExecutor<SysUserRoleRel> {
|
||||||
|
|
||||||
|
/** 用户全部可选身份(含禁用态由调用方过滤) */
|
||||||
|
List<SysUserRoleRel> findByUserId(Integer userId);
|
||||||
|
|
||||||
|
/** 用户某状态下的全部可选身份 */
|
||||||
|
List<SysUserRoleRel> findByUserIdAndStatus(Integer userId, StatusEnum status);
|
||||||
|
|
||||||
|
/** 批量取用户在指定状态下的全部身份行(列表页/导出批量主身份解析用,替代已删除的 findByUserIdInAndIsMain) */
|
||||||
|
List<SysUserRoleRel> findByUserIdInAndStatus(List<Integer> userIds, StatusEnum status);
|
||||||
|
|
||||||
|
/** 用户在某学校下、某状态下的全部角色关联 */
|
||||||
|
List<SysUserRoleRel> findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用)。<br>
|
||||||
|
* 必须 bulk 立即发 SQL:派生 delete 只把删除动作入队,同一事务内随后 saveAll 的 INSERT
|
||||||
|
* 在刷新时会先于 DELETE 执行(Hibernate ActionQueue 顺序),与 uk(user_id, role_id, school_id)
|
||||||
|
* 撞唯一键(编辑提交原身份集即触发 Duplicate entry)。
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId AND r.schoolId = :schoolId")
|
||||||
|
void deleteByUserIdAndSchoolId(@Param("userId") Integer userId, @Param("schoolId") Integer schoolId);
|
||||||
|
|
||||||
|
/** 删除用户全部身份行(级联删用户时清理,bulk delete 直接发 SQL) */
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId")
|
||||||
|
void deleteByUserIdBulk(@Param("userId") Integer userId);
|
||||||
|
|
||||||
|
/** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds / resolveUserType) */
|
||||||
|
@Query("SELECT DISTINCT r.roleId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status")
|
||||||
|
List<Integer> findDistinctRoleIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status);
|
||||||
|
|
||||||
|
/** 用户所有正常态的 schoolId 去重列表(用于 BoaUser.schools,schoolId>0 的过滤在调用方做) */
|
||||||
|
@Query("SELECT DISTINCT r.schoolId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status")
|
||||||
|
List<Integer> findDistinctSchoolIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.yunhai.userbase.service;
|
package com.yunhai.userbase.service;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||||
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
import com.yunhai.userbase.entity.AppCategory;
|
import com.yunhai.userbase.entity.AppCategory;
|
||||||
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||||
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||||
@@ -33,4 +35,18 @@ public interface IAppCategoryService {
|
|||||||
*/
|
*/
|
||||||
List<AppAuthzCategoryVo> listWithApps();
|
List<AppAuthzCategoryVo> listWithApps();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。<br>
|
||||||
|
* 规则:<br>
|
||||||
|
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br>
|
||||||
|
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
|
||||||
|
* ③ 不透出 appSecret。<br>
|
||||||
|
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param clientType 客户端类型(null=不按端过滤)
|
||||||
|
* @return 应用类型 + 有权应用列表
|
||||||
|
*/
|
||||||
|
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ public interface IApplicationInfoService {
|
|||||||
*/
|
*/
|
||||||
ApplicationInfo getById(Integer id);
|
ApplicationInfo getById(Integer id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 appKey 查询应用主键 id(appKeyToId 本地缓存,消除开放接口每请求一次 findByAppKey)。
|
||||||
|
*/
|
||||||
|
Integer findIdByAppKey(Integer appKey);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存/更新应用信息(写后同步更新缓存)。
|
* 保存/更新应用信息(写后同步更新缓存)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yunhai.userbase.service;
|
package com.yunhai.userbase.service;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaModule;
|
||||||
import com.yunhai.userbase.model.groupauthz.*;
|
import com.yunhai.userbase.model.groupauthz.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -83,6 +84,12 @@ public interface IGroupAuthzService {
|
|||||||
*/
|
*/
|
||||||
List<UserPermissionUserVo> searchUsers(String keyword);
|
List<UserPermissionUserVo> searchUsers(String keyword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回归属组与范围有交集的行;
|
||||||
|
* null 表示不过滤(超管/内部调用)。
|
||||||
|
*/
|
||||||
|
List<UserPermissionUserVo> searchUsers(String keyword, java.util.Set<Integer> scopeGroupIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
|
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
|
||||||
* 只返回<b>有授权模块</b>的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
|
* 只返回<b>有授权模块</b>的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
|
||||||
@@ -115,6 +122,23 @@ public interface IGroupAuthzService {
|
|||||||
*/
|
*/
|
||||||
List<UserGroupNodeVo> findUserGroupTree(Integer userId);
|
List<UserGroupNodeVo> findUserGroupTree(Integer userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:查询用户在<b>指定调用方应用</b>下的已授权模块(扁平化 BoaModule 列表)。<br>
|
||||||
|
* 与 {@link #findUserPermissionTree(Integer, Integer)} 的核心差别:<br>
|
||||||
|
* - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);<br>
|
||||||
|
* - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。<br>
|
||||||
|
* <br>
|
||||||
|
* 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。<br>
|
||||||
|
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
|
||||||
|
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。
|
||||||
|
*
|
||||||
|
* @param appId 调用方应用ID(必填;查不到或该应用无开通=返回空列表)
|
||||||
|
* @param schId 学校/组上下文(可选;为 null 时按"主学校→主归属组"规则推断)
|
||||||
|
* @param userId 用户ID(必填)
|
||||||
|
* @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表)
|
||||||
|
*/
|
||||||
|
List<BoaModule> findUserModulesForApp(Integer appId, Integer schId, Integer userId);
|
||||||
|
|
||||||
// ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) =====================
|
// ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) =====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,4 +182,15 @@ public interface IGroupAuthzService {
|
|||||||
* @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll)
|
* @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll)
|
||||||
*/
|
*/
|
||||||
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
|
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。<br>
|
||||||
|
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
|
||||||
|
* - 超级管理员:全部应用;<br>
|
||||||
|
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 有权应用ID列表(无则空列表)
|
||||||
|
*/
|
||||||
|
List<Integer> findUserAuthorizedAppIds(Integer userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
|||||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||||
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||||
import com.yunhai.platform.utils.model.BoaGroup;
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
import com.yunhai.userbase.entity.SysGroup;
|
||||||
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||||
|
import com.yunhai.userbase.service.resolve.AccessScope;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author chenx
|
* @author chenx
|
||||||
@@ -17,17 +21,77 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public interface ISysGroupService {
|
public interface ISysGroupService {
|
||||||
|
|
||||||
void add(SysGroupRequest request);
|
BoaGroup add(SysGroupRequest request);
|
||||||
|
|
||||||
void edit(Integer id, SysGroupRequest request);
|
BoaGroup edit(Integer id, SysGroupRequest request);
|
||||||
|
|
||||||
void delete(Integer id);
|
void delete(Integer id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:在学校(组)范围内删除组。<br>
|
||||||
|
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
|
||||||
|
* ① id 不能等于 schoolId(禁止删学校根组);<br>
|
||||||
|
* ② id 必须属于 schoolId 子树(禁止跨校删组)。<br>
|
||||||
|
* 任意一条不满足即抛异常。
|
||||||
|
*
|
||||||
|
* @param id 待删除组ID
|
||||||
|
* @param schoolId 学校组ID(限定删除范围)
|
||||||
|
*/
|
||||||
|
void delete(Integer id, Integer schoolId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口通用:校验学校锚点 schoolId 合法——非空、存在、且必须为学校组(SCHOOL_GROUP)。<br>
|
||||||
|
* 供 delete/move/用户组关系等带 schoolId 边界的开放接口统一调用。
|
||||||
|
*
|
||||||
|
* @param schoolId 学校组ID(限定范围)
|
||||||
|
*/
|
||||||
|
void assertSchoolId(Integer schoolId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析唯一真实根节点(parentId=0 中 id 最小者,与 {@link #getGroups()} 约定一致)。<br>
|
||||||
|
* 供「parentId/pid 为 0 或 null 表示挂根下」场景与身份归属解析({@link #resolveSchoolId})共用。
|
||||||
|
*
|
||||||
|
* @return 根节点实体
|
||||||
|
* @throws com.yunhai.platform.utils.exception.DException 当不存在任何根节点时
|
||||||
|
*/
|
||||||
|
SysGroup resolveRootGroup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析组归属的 schoolId(身份归属):沿该组的祖先链(含自身)——<br>
|
||||||
|
* ① 最近的学校组(SCHOOL_GROUP)→ 该校组 id;<br>
|
||||||
|
* ② 无学校组,则最近的区域组(AREA_GROUP)→ 该区域组 id;<br>
|
||||||
|
* ③ 学校组、区域组都没有 → 根节点 id。<br>
|
||||||
|
* 同校多组解析到同一 schoolId;schoolId 恒为真实组 id(原表注释「区域/学段/根=0」约定废弃)。<br>
|
||||||
|
* 该规则属后端职责,前端只按接口返回的 schoolId 加载/保存,不感知解析逻辑。
|
||||||
|
*
|
||||||
|
* @param groupId 组ID
|
||||||
|
* @return 归属 schoolId
|
||||||
|
*/
|
||||||
|
Integer resolveSchoolId(Integer groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量解析组归属 schoolId(身份归属),语义与 {@link #resolveSchoolId} 完全一致:
|
||||||
|
* 一次全量加载 sys_group 建内存表后沿祖先链解析(O(树深)),避免 N 次逐级 findById。<br>
|
||||||
|
* 供列表页/导出的批量主身份解析(IdentityService.resolveMainIdentityByUser)使用;
|
||||||
|
* 组表量级 = 学校数×层级(千级),与 searchUsers buildAllGroupMap 全表加载先例成本同量级。<br>
|
||||||
|
* 组不存在(悬挂关系)按 {@link #resolveSchoolId} 同规则回落根节点 id。
|
||||||
|
*
|
||||||
|
* @param groupIds 待解析组ID集合
|
||||||
|
* @return groupId → schoolId 映射(入参全部有值)
|
||||||
|
*/
|
||||||
|
Map<Integer, Integer> resolveSchoolIds(Collection<Integer> groupIds);
|
||||||
|
|
||||||
List<SysGroupVo> list(SysGroupQuery query);
|
List<SysGroupVo> list(SysGroupQuery query);
|
||||||
|
|
||||||
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
|
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
|
||||||
List<SysGroupVo> getTree();
|
List<SysGroupVo> getTree();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取组织架构树(按访问范围裁剪):SUPER 原样全量;BUREAU 以区域组为根的子树;
|
||||||
|
* SCHOOL 各学校子树(多校 = 多根数组)。scope 为 null 或 SUPER 级时行为与 {@link #getTree()} 完全一致。
|
||||||
|
*/
|
||||||
|
List<SysGroupVo> getTree(AccessScope scope);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取「学校组」剪枝树:末端叶子一定是学校组。<br>
|
* 获取「学校组」剪枝树:末端叶子一定是学校组。<br>
|
||||||
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);<br>
|
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);<br>
|
||||||
@@ -35,8 +99,14 @@ public interface ISysGroupService {
|
|||||||
*/
|
*/
|
||||||
List<SysGroupVo> getSchoolTree();
|
List<SysGroupVo> getSchoolTree();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取「学校组」剪枝树(按访问范围裁剪):BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
|
||||||
|
* scope 为 null 或 SUPER 级时行为与 {@link #getSchoolTree()} 完全一致。
|
||||||
|
*/
|
||||||
|
List<SysGroupVo> getSchoolTree(AccessScope scope);
|
||||||
|
|
||||||
/** 在指定父组下按规则批量生成子组。 */
|
/** 在指定父组下按规则批量生成子组。 */
|
||||||
void batchAdd(SysGroupBatchRequest request);
|
List<BoaGroup> batchAdd(SysGroupBatchRequest request);
|
||||||
|
|
||||||
/** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */
|
/** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */
|
||||||
void sortChildren(SysGroupSortRequest request);
|
void sortChildren(SysGroupSortRequest request);
|
||||||
@@ -44,6 +114,18 @@ public interface ISysGroupService {
|
|||||||
/** 将 moveId 移动到 pid 下(环检测)。 */
|
/** 将 moveId 移动到 pid 下(环检测)。 */
|
||||||
void move(SysGroupMoveRequest request);
|
void move(SysGroupMoveRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:在学校(组)范围内移动组。<br>
|
||||||
|
* 与 {@link #move(SysGroupMoveRequest)} 移动逻辑一致,但额外强制边界:<br>
|
||||||
|
* ① moveId 不能等于 schoolId(禁止移动学校根组);<br>
|
||||||
|
* ② pid 不能是平台根(0)(禁止移出学校范围);<br>
|
||||||
|
* ③ moveId 与 pid 都必须属于 schoolId 子树(禁止跨校移动)。<br>
|
||||||
|
*
|
||||||
|
* @param request 待移动节点 moveId + 目标父节点 pid
|
||||||
|
* @param schoolId 学校组ID(限定移动范围)
|
||||||
|
*/
|
||||||
|
void move(SysGroupMoveRequest request, Integer schoolId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收集指定组自身及其所有子孙组的ID(沿 parentId 向下遍历)。<br>
|
* 收集指定组自身及其所有子孙组的ID(沿 parentId 向下遍历)。<br>
|
||||||
* 用于「查询该节点及其子组下所有用户」等子树聚合场景。<br>
|
* 用于「查询该节点及其子组下所有用户」等子树聚合场景。<br>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.yunhai.userbase.service;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysOapiLog;
|
||||||
|
import com.yunhai.userbase.model.BasePageQuery;
|
||||||
|
import com.yunhai.userbase.model.BasePageResult;
|
||||||
|
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||||
|
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口调用日志业务接口。<br>
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
* @date 2026/8/20
|
||||||
|
*/
|
||||||
|
public interface ISysOapiLogService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步保存开放接口日志。<br>
|
||||||
|
* 由 OapiLogAspect 在 finally 中调用,失败仅记录日志,不影响主业务。<br>
|
||||||
|
*
|
||||||
|
* @param entity 日志实体
|
||||||
|
*/
|
||||||
|
void saveAsync(SysOapiLog entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询开放接口日志列表。<br>
|
||||||
|
* 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。<br>
|
||||||
|
*
|
||||||
|
* @param query 查询条件
|
||||||
|
* @param pageQuery 分页参数(pageIndex 从 1 起)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
BasePageResult<SysOapiLogListVo> pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取某条日志的响应结果(解压后原文)。<br>
|
||||||
|
*
|
||||||
|
* @param id 日志ID
|
||||||
|
* @return 解压后的响应字符串,无记录或无响应数据返回 null
|
||||||
|
*/
|
||||||
|
String getResponseData(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志。<br>
|
||||||
|
* 一般日志(level=3)保留 12 个月,重要(level=1)/次要(level=2)保留 36 个月。<br>
|
||||||
|
*
|
||||||
|
* @return 删除条数
|
||||||
|
*/
|
||||||
|
long cleanupExpired();
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.yunhai.userbase.service;
|
package com.yunhai.userbase.service;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||||
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||||
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 org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -19,14 +22,16 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public interface ISysUserService {
|
public interface ISysUserService {
|
||||||
|
|
||||||
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。 */
|
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
|
||||||
void add(SysUserRequest request);
|
Integer add(SysUserRequest request);
|
||||||
|
|
||||||
/** 编辑用户;密码为空时不修改密码。 */
|
/** 编辑用户;密码为空时不修改密码。 */
|
||||||
void edit(Integer id, SysUserRequest request);
|
void edit(Integer id, SysUserRequest request);
|
||||||
|
|
||||||
/** 删除用户:同时清理其组用户关联。 */
|
/**
|
||||||
void delete(Integer id);
|
* 批量新增用户:逐条走完整校验(含密码强度),返回创建后的用户ID列表(顺序与入参一致)。
|
||||||
|
*/
|
||||||
|
List<Integer> batchAdd(List<SysUserRequest> requests);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。
|
* 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。
|
||||||
@@ -44,6 +49,26 @@ public interface ISysUserService {
|
|||||||
*/
|
*/
|
||||||
void batchDeleteFromGroup(List<Integer> userIds, Integer groupId);
|
void batchDeleteFromGroup(List<Integer> userIds, Integer groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:在学校(组)范围内按组解绑用户。<br>
|
||||||
|
* 与 {@link #deleteFromGroup(Integer, Integer)} 语义一致,但额外强制边界:<br>
|
||||||
|
* ① groupId 不能等于 schoolId(禁止对学校根组解绑,避免整校移除);<br>
|
||||||
|
* ② groupId 必须属于 schoolId 子树(禁止跨校解绑)。<br>
|
||||||
|
* 任意一条不满足即抛异常。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param groupId 当前组ID
|
||||||
|
* @param schoolId 学校组ID(限定解绑范围)
|
||||||
|
*/
|
||||||
|
void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:批量删除用户-组关系;每条携带自身 (userId, groupId, schoolId),逐条做上述学校边界校验后解绑。
|
||||||
|
*
|
||||||
|
* @param items 待删除项列表({@link SysUserGroupDeleteItem})
|
||||||
|
*/
|
||||||
|
void deleteUsers(List<SysUserGroupDeleteItem> items);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启用/禁用用户。<br>
|
* 启用/禁用用户。<br>
|
||||||
*
|
*
|
||||||
@@ -78,14 +103,43 @@ public interface ISysUserService {
|
|||||||
*/
|
*/
|
||||||
List<Integer> findGroupIdsByUserId(Integer userId);
|
List<Integer> findGroupIdsByUserId(Integer userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户身份(管理端「用户身份」回显):后端按所在组解析 schoolId 后返回 {schoolId, roleIds};<br>
|
||||||
|
* 同一学校多组返回同一 schoolId 与角色并集。前端回显唯一来源(roles 列已停写,不再作为真源)。<br>
|
||||||
|
* 编辑请求提交时原样携带返回的 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param groupId 所在组ID(Select 当前选中项;schoolId 解析规则见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId})
|
||||||
|
* @return {schoolId, roleIds}
|
||||||
|
*/
|
||||||
|
UserIdentityVo getUserIdentity(Integer userId, Integer groupId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
|
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
|
||||||
* 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。
|
* 主归属组按三级降级确定:<br>
|
||||||
|
* ① mainGroupId 显式传入(须 ∈ groupIds,否则 400)→ 所有用户统一置主;<br>
|
||||||
|
* ② 未传 → 逐用户沿用旧主组(旧主组 ∈ 新 groupIds);<br>
|
||||||
|
* ③ 旧主组不在新集合 → 取 groupIds 首项(现状行为兜底)。<br>
|
||||||
|
* ② 级为行为增强:只要用户没把主组勾掉,调整组后主组不动(旧实现按数组顺序随机重指)。
|
||||||
*
|
*
|
||||||
* @param userIds 待调整用户ID列表
|
* @param userIds 待调整用户ID列表
|
||||||
* @param groupIds 目标组ID列表
|
* @param groupIds 目标组ID列表
|
||||||
|
* @param mainGroupId 主归属组ID(可选;null 走 ②③ 级降级)
|
||||||
*/
|
*/
|
||||||
void changeGroups(List<Integer> userIds, List<Integer> groupIds);
|
void changeGroups(List<Integer> userIds, List<Integer> groupIds, Integer mainGroupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:在学校(组)范围内移动用户。<br>
|
||||||
|
* 语义同 {@link #changeGroups}(覆盖式把用户组归属替换为目标组,目标设为主组),但额外强制:<br>
|
||||||
|
* ① 用户当前必须已在该学校子树内(至少一个组归属落在 schoolId 子树);<br>
|
||||||
|
* ② 目标 groupId 必须也在该学校子树内。<br>
|
||||||
|
* 任意一条不满足即抛异常,禁止跨校移动。
|
||||||
|
*
|
||||||
|
* @param userId 待移动用户ID
|
||||||
|
* @param groupId 目标组ID(移动后的主归属组)
|
||||||
|
* @param schoolId 学校组ID(限定移动范围,仅允许同校内移动)
|
||||||
|
*/
|
||||||
|
void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调整用户在某组内的排序号(sort_no)。<br>
|
* 调整用户在某组内的排序号(sort_no)。<br>
|
||||||
@@ -124,4 +178,70 @@ public interface ISysUserService {
|
|||||||
* @param response 直接写入 Excel 流
|
* @param response 直接写入 Excel 流
|
||||||
*/
|
*/
|
||||||
void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException;
|
void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户的业务角色身份列表(oapi 开放接口专用)。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 角色身份列表(roleId/roleName + 主归属学校组上下文)
|
||||||
|
*/
|
||||||
|
List<BoaRoleInfo> findUserRoles(Integer userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
|
||||||
|
* oapi 开放接口专用;不加操作日志(避免明文密码入日志)。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param oldPassword 旧密码(须与库中 BCrypt 密文匹配)
|
||||||
|
* @param newPassword 新密码(弱密码规则校验后加密写入)
|
||||||
|
*/
|
||||||
|
void changePassword(Integer userId, String oldPassword, String newPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
|
||||||
|
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param file 头像图片文件(multipart)
|
||||||
|
* @return 新头像完整可访问 URL
|
||||||
|
*/
|
||||||
|
String changeProfilePicture(Integer userId, MultipartFile file);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:设置用户在指定组上的状态(转出/恢复)。<br>
|
||||||
|
* 仅作关系标记,不影响成员查询、组织链与下游推送;同值重复调用幂等。<br>
|
||||||
|
* 边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/学校根组操作)。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param groupId 组ID
|
||||||
|
* @param schoolId 学校组ID(限定范围)
|
||||||
|
* @param state 目标状态:0=恢复正常,1=转出
|
||||||
|
*/
|
||||||
|
void changeUserGroupState(Integer userId, Integer groupId, Integer schoolId, Integer state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:查询用户在指定组上的状态(0=正常,1=转出)。<br>
|
||||||
|
* 存量数据 status 为 NULL 视为正常返回 0。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param groupId 组ID
|
||||||
|
* @param schoolId 学校组ID(限定范围)
|
||||||
|
* @return 状态值 0/1
|
||||||
|
*/
|
||||||
|
Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:跨校转入(转出后迁移到其他学校)。<br>
|
||||||
|
* 前置:用户已在源学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系;
|
||||||
|
* 操作成功后:删除源学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系
|
||||||
|
* (主归属、状态恢复正常,转出流程结束),并推送编辑用户订阅(UPDATE_USER)。<br>
|
||||||
|
* 边界:fromSchoolId 与目标 schoolId 均须为学校组且不相同;目标组须属于目标学校子树且不等于学校根组;
|
||||||
|
* 用户已在目标学校存在组关系时拒绝(转入语义为进入全新学校)。
|
||||||
|
*
|
||||||
|
* @param userId 待迁移用户ID
|
||||||
|
* @param fromSchoolId 原学校组ID(源学校)
|
||||||
|
* @param toSchoolId 目标学校组ID
|
||||||
|
* @param groupId 目标组ID(目标学校内的挂载组,迁移后的主归属)
|
||||||
|
*/
|
||||||
|
void transferUserToSchool(Integer userId, Integer fromSchoolId, Integer toSchoolId, Integer groupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.yunhai.userbase.service.impl;
|
package com.yunhai.userbase.service.impl;
|
||||||
|
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||||
import com.yunhai.platform.utils.exception.DException;
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.platform.utils.model.BoaApp;
|
||||||
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
import com.yunhai.userbase.entity.AppCategory;
|
import com.yunhai.userbase.entity.AppCategory;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
import com.yunhai.userbase.model.appauthz.AppAuthzAppRow;
|
import com.yunhai.userbase.model.appauthz.AppAuthzAppRow;
|
||||||
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||||
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||||
@@ -11,6 +15,7 @@ import com.yunhai.userbase.model.appcategory.AppCategoryVo;
|
|||||||
import com.yunhai.userbase.repository.AppCategoryRepository;
|
import com.yunhai.userbase.repository.AppCategoryRepository;
|
||||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
import com.yunhai.userbase.service.IAppCategoryService;
|
import com.yunhai.userbase.service.IAppCategoryService;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -18,9 +23,14 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import javax.transaction.Transactional;
|
import javax.transaction.Transactional;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +46,9 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ApplicationInfoRepository applicationInfoRepository;
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -133,6 +146,97 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType) {
|
||||||
|
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
|
||||||
|
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
|
||||||
|
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
|
||||||
|
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
|
||||||
|
.thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo)));
|
||||||
|
// categoryId -> 该类型下应用列表(插入顺序即排序)
|
||||||
|
Map<Integer, List<ApplicationInfo>> appMap = new LinkedHashMap<>();
|
||||||
|
for (ApplicationInfo app : apps) {
|
||||||
|
if (app.getCategoryId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
|
||||||
|
}
|
||||||
|
List<BoaAppType> result = new ArrayList<>();
|
||||||
|
for (AppCategory category : categories) {
|
||||||
|
BoaAppType type = new BoaAppType();
|
||||||
|
type.setId(category.getId());
|
||||||
|
type.setName(category.getName());
|
||||||
|
List<BoaApp> boaApps = new ArrayList<>();
|
||||||
|
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
|
||||||
|
// 用户权限过滤
|
||||||
|
if (!authorizedAppIds.contains(app.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 客户端类型位掩码过滤:clients 为空视为不限端
|
||||||
|
if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boaApps.add(convertToBoaApp(app));
|
||||||
|
}
|
||||||
|
type.setApps(boaApps);
|
||||||
|
result.add(type);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApplicationInfo -> BoaApp 映射。不透出 appSecret;模块列表留空(由调用方按需填充)。
|
||||||
|
*/
|
||||||
|
private BoaApp convertToBoaApp(ApplicationInfo app) {
|
||||||
|
BoaApp boa = new BoaApp();
|
||||||
|
boa.setId(app.getId());
|
||||||
|
boa.setName(app.getName());
|
||||||
|
boa.setIcon(app.getPcIcon());
|
||||||
|
boa.setType(app.getCategoryId());
|
||||||
|
boa.setWebAddr(app.getPcUrl());
|
||||||
|
boa.setWebAddress(app.getPcUrl());
|
||||||
|
boa.setAppKey(app.getAppKey());
|
||||||
|
boa.setOrderNumber(app.getSortNo());
|
||||||
|
boa.setCreateUser(app.getCreateUser());
|
||||||
|
boa.setCreateTime(app.getCreateTime());
|
||||||
|
boa.setMobiIcon(app.getAppIcon());
|
||||||
|
boa.setClients(toClientMask(app.getClients()));
|
||||||
|
boa.setWxgzhAddress(app.getOfficialAccountUrl());
|
||||||
|
boa.setDingAddress(app.getDingtalkUrl());
|
||||||
|
return boa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clients 列表([1,2,4])折叠为位掩码叠加值(7)。
|
||||||
|
*/
|
||||||
|
private Integer toClientMask(List<Integer> clients) {
|
||||||
|
if (clients == null || clients.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int mask = 0;
|
||||||
|
for (Integer c : clients) {
|
||||||
|
if (c != null) {
|
||||||
|
mask |= c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断客户端类型位掩码是否与 app.clients 有交集;clients 为空视为不限端。
|
||||||
|
*/
|
||||||
|
private boolean containsClient(List<Integer> clients, Integer clientKey) {
|
||||||
|
if (clients == null || clients.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (Integer c : clients) {
|
||||||
|
if (c != null && (c & clientKey) != 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ public class AppRoleService implements IAppRoleService {
|
|||||||
} else {
|
} else {
|
||||||
ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null);
|
ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null);
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
throw new DException("角色所属应用不存在: " + role.getAppId());
|
// 应用已删除的孤儿角色:不再抛错导致整页 500,返回空授权树(数据侧请用清理 SQL 修复)
|
||||||
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
apps = Collections.singletonList(app);
|
apps = Collections.singletonList(app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import com.yunhai.userbase.repository.SysGroupModulePermRepository;
|
|||||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
import com.yunhai.platform.utils.exception.DException;
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.userbase.config.CacheNames;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
|
||||||
import org.springframework.cache.annotation.CachePut;
|
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -72,19 +71,37 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
private ApplicationEventPublisher eventPublisher;
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。
|
* 查询缓存:底层优先 Redis,Redis 故障时自动降级 Caffeine。<br>
|
||||||
|
* 不存在或不存在的 id 返回 null 不入缓存(unless 拦截)——不能用 sync=true:SD Redis 2.7 的
|
||||||
|
* sync 路径对 null 返回值会无条件 put 并在禁 null 缓存下抛 IllegalArgumentException(查不存在=500)。<br>
|
||||||
|
* 失效走 ApplicationInfoCacheChangedEvent(AFTER_COMMIT 清整缓存,见 CacheInvalidationListener),
|
||||||
|
* 不再用 @CachePut/@CacheEvict——事务方法上注解驱逐发生在 commit 前,存在回源旧值竞态(方案 5.3 禁令)。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Cacheable(value = "appInfo", key = "#id", sync = true)
|
@Cacheable(value = CacheNames.APP_INFO, key = "#id", unless = "#result == null")
|
||||||
public ApplicationInfo getById(Integer id) {
|
public ApplicationInfo getById(Integer id) {
|
||||||
return applicationInfoRepository.findById(id).orElse(null);
|
return applicationInfoRepository.findById(id).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新缓存:方法返回即写入缓存(与 @Cacheable 同一 cacheName/key,保证一致)。
|
* appKey → appId 映射(appKeyToId 本地缓存,TTL 2h):消除 /oapi/user/modules 等
|
||||||
|
* 每请求一次 findByAppKey 查库;应用增删改经 ApplicationInfoCacheChangedEvent 搭车整清。
|
||||||
|
* 查无此 appKey 返回 null 不入缓存(无效 appKey 已被验签拦截,穿透可忽略)。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Cacheable(value = CacheNames.APP_KEY_TO_ID, key = "#appKey", unless = "#result == null")
|
||||||
|
public Integer findIdByAppKey(Integer appKey) {
|
||||||
|
if (appKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
|
||||||
|
return app == null ? null : app.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@CachePut(value = "appInfo", key = "#info.id")
|
|
||||||
public ApplicationInfo save(ApplicationInfo info) {
|
public ApplicationInfo save(ApplicationInfo info) {
|
||||||
ApplicationInfo saved = applicationInfoRepository.save(info);
|
ApplicationInfo saved = applicationInfoRepository.save(info);
|
||||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||||
@@ -92,10 +109,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除缓存:删除数据时同步清除对应缓存项。
|
* 按主键删除:删除成功后发布变更事件(含 appKey),监听器清缓存 + 摘除签名密钥。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@CacheEvict(value = "appInfo", key = "#id")
|
|
||||||
public void deleteById(Integer id) {
|
public void deleteById(Integer id) {
|
||||||
ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null);
|
ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null);
|
||||||
applicationInfoRepository.deleteById(id);
|
applicationInfoRepository.deleteById(id);
|
||||||
@@ -143,8 +159,8 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理),并清除 appInfo 缓存
|
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
|
||||||
@CacheEvict(value = "appInfo", key = "#id")
|
// appInfo 缓存失效统一走 AFTER_COMMIT 事件(原 @CacheEvict 在事务方法上驱逐时机早于 commit,存在竞态,已移除)
|
||||||
public void delete(Integer id) {
|
public void delete(Integer id) {
|
||||||
Objects.requireNonNull(id, "应用ID不能为空");
|
Objects.requireNonNull(id, "应用ID不能为空");
|
||||||
ApplicationInfo existing = applicationInfoRepository.findById(id)
|
ApplicationInfo existing = applicationInfoRepository.findById(id)
|
||||||
@@ -226,8 +242,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
// AI-FIX: 重置密钥后必须清除 appInfo 缓存,否则控制器随后 getById 命中旧缓存、返回旧密钥
|
// AI-FIX: 重置密钥后 appInfo 缓存由 AFTER_COMMIT 事件统一清空(原 @CacheEvict 驱逐早于 commit,存在回源旧密钥竞态,已移除)
|
||||||
@CacheEvict(value = "appInfo", key = "#id")
|
|
||||||
public void resetAppSecret(Integer id) {
|
public void resetAppSecret(Integer id) {
|
||||||
Objects.requireNonNull(id, "应用ID不能为空");
|
Objects.requireNonNull(id, "应用ID不能为空");
|
||||||
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
||||||
@@ -256,6 +271,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
}
|
}
|
||||||
applicationInfoRepository.updateSortNoById(id, i + 1);
|
applicationInfoRepository.updateSortNoById(id, i + 1);
|
||||||
}
|
}
|
||||||
|
// 排序结果缓存于 appInfo(实体含 sortNo),提交后发布变更事件统一失效
|
||||||
|
ApplicationInfo first = applicationInfoRepository.findById(ids.get(0)).orElse(null);
|
||||||
|
if (first != null) {
|
||||||
|
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, first));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ApplicationInfoVo convertToVo(ApplicationInfo entity) {
|
private ApplicationInfoVo convertToVo(ApplicationInfo entity) {
|
||||||
@@ -271,7 +291,6 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@CachePut(value = "appInfo", key = "#id")
|
|
||||||
public ApplicationInfo savePushSetting(Integer id, PushSettingRequest request) {
|
public ApplicationInfo savePushSetting(Integer id, PushSettingRequest request) {
|
||||||
Objects.requireNonNull(id, "应用ID不能为空");
|
Objects.requireNonNull(id, "应用ID不能为空");
|
||||||
Objects.requireNonNull(request, "推送设置入参不能为空");
|
Objects.requireNonNull(request, "推送设置入参不能为空");
|
||||||
@@ -296,7 +315,10 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
if (request.getPushSecret() != null) {
|
if (request.getPushSecret() != null) {
|
||||||
entity.setPushSecret(request.getPushSecret());
|
entity.setPushSecret(request.getPushSecret());
|
||||||
}
|
}
|
||||||
return applicationInfoRepository.save(entity);
|
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||||
|
// 推送配置写入实体字段,提交后发布变更事件统一失效 appInfo/appKeyToId(原 @CachePut 驱逐早于 commit,已移除)
|
||||||
|
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user