Compare commits
131
Commits
e3512c59fb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0632d3c7b | ||
|
|
e713d92d49 | ||
|
|
6f60d65195 | ||
|
|
809d88c838 | ||
|
|
72fe0f218a | ||
|
|
b1b769a1b8 | ||
|
|
bf3f3344c1 | ||
|
|
996eed3caf | ||
|
|
8e7b69c795 | ||
|
|
7247c1193a | ||
|
|
ddaa3a6f42 | ||
|
|
cb40524b8e | ||
|
|
195936236d | ||
|
|
ad434dbc36 | ||
|
|
035df02f63 | ||
|
|
2b754c08ca | ||
|
|
336b50675c | ||
|
|
fe6c07cc4b | ||
|
|
c790187f35 | ||
|
|
d7d239e77c | ||
|
|
34750c03ac | ||
|
|
96855a8565 | ||
|
|
705cbde7cf | ||
|
|
90c08a5f14 | ||
|
|
f0eaebd186 | ||
|
|
8f8a6b8cca | ||
|
|
7e222f19b8 | ||
|
|
fe19954d8b | ||
|
|
257ba2dd64 | ||
|
|
3846ec21a9 | ||
|
|
0b4a596bce | ||
|
|
6cc1bc23f7 | ||
|
|
712e94803f | ||
|
|
ef5849377a | ||
|
|
8799d72d47 | ||
|
|
30b4b0504a | ||
|
|
9ec65698b6 | ||
|
|
ce131bf399 | ||
|
|
283ac4ca0a | ||
|
|
b0a02e56b0 | ||
|
|
0b67382076 | ||
|
|
877e800f82 | ||
|
|
2106ba3d00 | ||
|
|
d6e995a954 | ||
|
|
5cec25736a | ||
|
|
7acfabfa70 | ||
|
|
fb3c5671c6 | ||
|
|
3421851b6f | ||
|
|
46a9587160 | ||
|
|
4131dca66e | ||
|
|
a2a6145ed2 | ||
|
|
f5680be7a8 | ||
|
|
909117ea42 | ||
|
|
0d0a680b82 | ||
|
|
5347559112 | ||
|
|
783ccd99fe | ||
|
|
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 | ||
|
|
8ff6e7ac0c | ||
|
|
e55c2cb209 | ||
|
|
0233ecb525 | ||
|
|
767ad3a7dd | ||
|
|
00278d997a | ||
|
|
b5f6679eb5 | ||
|
|
5cfed17956 | ||
|
|
8113045471 | ||
|
|
3855ea2eca | ||
|
|
cfee8012a6 | ||
|
|
ec407970e0 | ||
|
|
c35a9fa47e | ||
|
|
b4a6af0ec5 | ||
|
|
a5ced786b4 | ||
|
|
f55c865ab1 | ||
|
|
4a10fad893 | ||
|
|
32d03e3eb4 | ||
|
|
54f9fb7e3f | ||
|
|
cb2a4d7ea4 | ||
|
|
97959757d0 | ||
|
|
baff5fc8df | ||
|
|
c0be7895ef | ||
|
|
0bd99f9aa4 | ||
|
|
48173ba60e | ||
|
|
1bfff41c10 | ||
|
|
6cfc820ce8 | ||
|
|
a8c0793a92 | ||
|
|
ed07f380da | ||
|
|
a0aa1cfb35 | ||
|
|
b226a567b8 | ||
|
|
38029801fb | ||
|
|
aa4ae32ac5 | ||
|
|
2190e7d58f | ||
|
|
9d436c1ac1 | ||
|
|
7ff8325999 | ||
|
|
9ce3e831b5 | ||
|
|
4ec19486e0 |
@@ -0,0 +1,7 @@
|
||||
target/*
|
||||
!target/app.jar
|
||||
src
|
||||
.idea
|
||||
*.iml
|
||||
*.log
|
||||
.workbuddy
|
||||
@@ -0,0 +1,114 @@
|
||||
name: build
|
||||
|
||||
# Spring Boot 应用 CI(仿 UserBase ci.yml build 阶段 + projman webhook 建档,不含部署)
|
||||
# 触发:push 到 main / 手动 Run workflow(可勾选缓存重建)
|
||||
# 链路:clone 源码 -> maven 容器构建 jar(复用宿主 m2 缓存)-> docker build -f docker/Dockerfile -> push Nexus -> 通知 projman 建档(部署由运维在 DeployTab 手动触发)
|
||||
# 依赖仓库 Secrets:NEXUS_USER / NEXUS_PASSWORD(docker-hosted 推送)/ DEPLOY_TOKEN(webhook 鉴权)
|
||||
# 依赖 docker/Dockerfile(由 projman 注入,开发者也可自备——已写则尊重不覆盖)
|
||||
# maven 依赖走 http://nexus:8081/repository/maven-public/(infra-infra-net 内容器名直连,匿名可拉公司 yh-* starter)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cache_bust:
|
||||
description: '构建前清空 maven m2 缓存'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:24
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl(alpine)
|
||||
run: |
|
||||
set -e
|
||||
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
|
||||
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
|
||||
chosen=""
|
||||
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
|
||||
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
|
||||
chosen="${host}"
|
||||
break
|
||||
fi
|
||||
echo "apk 源不可达: ${host}"
|
||||
done
|
||||
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
|
||||
echo "使用 apk 源: ${chosen}"
|
||||
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
|
||||
apk add --no-cache git curl
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
|
||||
cp -a /tmp/src/. .
|
||||
rm -rf /tmp/src
|
||||
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
|
||||
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
|
||||
echo "MB=$(echo ${GITHUB_SHA} | cut -c1-6)" >> "$GITHUB_ENV"
|
||||
|
||||
# 可选:清空 m2 缓存(仅手动勾选 cache_bust 时)
|
||||
- name: 可选清空 m2 缓存
|
||||
if: ${{ inputs.cache_bust == 'true' }}
|
||||
run: |
|
||||
docker run --rm -v /opt/docker/projman-m2:/root/.m2 alpine sh -c 'rm -rf /root/.m2/*'
|
||||
echo "m2 cache busted"
|
||||
|
||||
# maven 构建:起独立 maven 容器(入 infra-infra-net 用容器名 nexus 直连 maven-public)
|
||||
# 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物
|
||||
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖
|
||||
# settings.xml 用 profile 方式追加 nexus maven-public 仓库(匿名可拉,不带凭据)
|
||||
# maven 实际产物名 = artifactId-version.jar(随 pom 变,-DfinalName 命令行 property 不可靠覆盖不了),
|
||||
# 故 mvn package 后在容器内 cp 成固定名 app.jar,使 docker cp 与 docker/Dockerfile 的 COPY target/app.jar 绑定
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f mavenbuild-${MB} 2>/dev/null || true
|
||||
docker run -d --name mavenbuild-${MB} \
|
||||
--network infra-infra-net \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild-${MB}:/build/
|
||||
docker exec mavenbuild-${MB} 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 mavenbuild-${MB} sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild-${MB} sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild-${MB}:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild-${MB}
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
set -e
|
||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin
|
||||
docker build -t "nexus:8083/docker-hosted/userbase-service:${TAG}" -f docker/Dockerfile .
|
||||
docker push "nexus:8083/docker-hosted/userbase-service:${TAG}"
|
||||
echo "TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
|
||||
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
|
||||
- name: 通知 projman 建档(部署由运维手动触发)
|
||||
run: |
|
||||
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\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# ---- java build ----
|
||||
target/
|
||||
|
||||
# ---- IDE ----
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# ---- local tooling ----
|
||||
.workbuddy/
|
||||
|
||||
# ---- env secrets (keep *.example) ----
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# ---- logs ----
|
||||
*.log
|
||||
deploy/*_run*.log
|
||||
deploy/verify_run*.log
|
||||
deploy/push_run.log
|
||||
|
||||
# ---- OS ----
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# ---- scanner artifacts ----
|
||||
defender_chk.txt
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
|
||||
</settings>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||
|
||||
<servers>
|
||||
<server>
|
||||
<id>nexus-releases</id>
|
||||
<username>admin</username>
|
||||
<password>@4Jo8iDj10uSHvg</password>
|
||||
</server>
|
||||
<server>
|
||||
<id>nexus-snapshots</id>
|
||||
<username>admin</username>
|
||||
<password>@4Jo8iDj10uSHvg</password>
|
||||
</server>
|
||||
</servers>
|
||||
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>nexus-public</id>
|
||||
<name>Nexus Public Group</name>
|
||||
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
|
||||
<mirrorOf>*</mirrorOf>
|
||||
<allowInsecureProtocol>true</allowInsecureProtocol>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
|
||||
</settings>
|
||||
@@ -0,0 +1,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);
|
||||
@@ -0,0 +1,20 @@
|
||||
# UserBase 后端镜像(基于已构建好的 target/app.jar,CI 已把 maven 产物 cp 成固定名)
|
||||
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
|
||||
FROM eclipse-temurin:8-jre
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl tzdata \
|
||||
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY target/app.jar /app/app.jar
|
||||
# 镜像内 application-prod.yml 即生产配置基准(nacos 已停用),
|
||||
# 启动由 run-app.sh 以 application-prod.yml + 环境变量注入完成。
|
||||
COPY docker/application-prod.yml /app/config/application-prod.yml
|
||||
COPY docker/run-app.sh /app/run-app.sh
|
||||
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
|
||||
|
||||
EXPOSE 8060
|
||||
ENTRYPOINT ["/app/run-app.sh"]
|
||||
@@ -0,0 +1,101 @@
|
||||
# UserBase 生产配置(镜像内自足:配置中心已停用,运行时密码由 Gitea Secrets / projman .env 环境变量注入)
|
||||
server:
|
||||
port: 8060
|
||||
servlet:
|
||||
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/userbase
|
||||
context-path: /userbase
|
||||
|
||||
spring:
|
||||
mvc:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 200MB
|
||||
jackson:
|
||||
time-zone: GMT+8
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
|
||||
redis:
|
||||
host: redis-server
|
||||
port: 6379
|
||||
database: 0
|
||||
# 兜底密码运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets / projman .env,不入镜像)
|
||||
password: ${REDIS_PASSWORD}
|
||||
timeout: 2000ms
|
||||
data:
|
||||
redis:
|
||||
repositories:
|
||||
enabled: false
|
||||
|
||||
# 镜像内兜底:db 密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets / projman .env,不入镜像)
|
||||
datasource:
|
||||
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
min-idle: 10
|
||||
max-active: 30
|
||||
|
||||
jpa:
|
||||
database: MySQL
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate.order_by.default_null_ordering: last
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
open-in-view: true
|
||||
|
||||
rabbitmq:
|
||||
host: rabbitmq
|
||||
port: 5672
|
||||
username: admin
|
||||
# 兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(值来自 Gitea Secrets / projman .env,不入镜像)
|
||||
password: ${RABBITMQ_PASSWORD}
|
||||
listener:
|
||||
direct:
|
||||
auto-startup: false
|
||||
simple:
|
||||
auto-startup: false
|
||||
|
||||
# 健康检查(projman 健康总览探活口径:容器 Running + HTTP 200 即健康):
|
||||
# 仅暴露 health;DB/Redis 计入总体状态(连不上即 DOWN=503,projman 显示 fail),rabbit 推送链路不算
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
health:
|
||||
rabbit:
|
||||
enabled: false
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com/cas/
|
||||
client-host-url: http://base.yunhaixingtong.com/userbase
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
url-pattern: /api/*
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
|
||||
ADMIN_TAG_ID: 290
|
||||
|
||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||
FILE_PATH: /data/upload
|
||||
FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/
|
||||
UPLOAD_WEB_PATH_HTTP: http://base.yunhaixingtong.com/userbase/upload/file/
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
file:
|
||||
path: /app/logs
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# UserBase 启动入口:配置中心(nacos)已停用,生产配置=镜像内烤好的 application-prod.yml
|
||||
# + 运行时环境变量注入(Gitea Secrets / projman .env:DB_PASSWORD/REDIS_PASSWORD/RABBITMQ_PASSWORD)。
|
||||
set -e
|
||||
|
||||
echo "[run-app] starting UserBase (profile=prod) ..."
|
||||
|
||||
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \
|
||||
--spring.profiles.active=prod
|
||||
@@ -0,0 +1,64 @@
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
rstrtmgr = ctypes.windll.rstrtmgr
|
||||
|
||||
class RM_UNIQUE_PROCESS(ctypes.Structure):
|
||||
_fields_ = [("dwProcessId", wintypes.DWORD),
|
||||
("ProcessStartTime", wintypes.FILETIME)]
|
||||
|
||||
class RM_PROCESS_INFO(ctypes.Structure):
|
||||
_fields_ = [("Process", RM_UNIQUE_PROCESS),
|
||||
("strAppName", ctypes.c_wchar * 255),
|
||||
("strServiceShortName", ctypes.c_wchar * 63),
|
||||
("ApplicationType", ctypes.c_int),
|
||||
("AppStatus", ctypes.c_uint),
|
||||
("TSSessionId", wintypes.DWORD),
|
||||
("bRestartable", wintypes.BOOL)]
|
||||
|
||||
RmStartSession = rstrtmgr.RmStartSession
|
||||
RmStartSession.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, ctypes.c_wchar_p]
|
||||
RmStartSession.restype = ctypes.c_uint32
|
||||
|
||||
RmRegisterResources = rstrtmgr.RmRegisterResources
|
||||
RmRegisterResources.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p,
|
||||
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.c_uint32, ctypes.c_wchar_p]
|
||||
RmRegisterResources.restype = ctypes.c_uint32
|
||||
|
||||
RmGetList = rstrtmgr.RmGetList
|
||||
RmGetList.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32),
|
||||
ctypes.POINTER(RM_PROCESS_INFO), ctypes.POINTER(ctypes.c_uint32)]
|
||||
RmGetList.restype = ctypes.c_uint32
|
||||
|
||||
RmEndSession = rstrtmgr.RmEndSession
|
||||
RmEndSession.argtypes = [ctypes.c_uint32]
|
||||
RmEndSession.restype = ctypes.c_uint32
|
||||
|
||||
session = ctypes.c_uint32(0)
|
||||
desc = ctypes.create_unicode_buffer("diag")
|
||||
rc = RmStartSession(ctypes.byref(session), 0, desc)
|
||||
if rc != 0:
|
||||
print("RmStartSession failed", rc)
|
||||
raise SystemExit
|
||||
|
||||
path = ctypes.create_unicode_buffer(r"C:\ub-build\userbase\classes\application.yml")
|
||||
rc = RmRegisterResources(session, 1, path, 0, None, 0, None)
|
||||
print("RmRegisterResources rc=", rc)
|
||||
|
||||
nNeeded = ctypes.c_uint32(0)
|
||||
nActual = ctypes.c_uint32(0)
|
||||
reason = ctypes.c_uint32(0)
|
||||
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), None, ctypes.byref(reason))
|
||||
print("first RmGetList rc=", rc, "needed=", nNeeded.value)
|
||||
if nNeeded.value > 0:
|
||||
arr = (RM_PROCESS_INFO * nNeeded.value)()
|
||||
nActual = ctypes.c_uint32(nNeeded.value)
|
||||
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), arr, ctypes.byref(reason))
|
||||
print("second RmGetList rc=", rc, "actual=", nActual.value)
|
||||
for i in range(nActual.value):
|
||||
p = arr[i]
|
||||
print("HOLDER PID=", p.Process.dwProcessId, "app=", p.strAppName)
|
||||
else:
|
||||
print("NO PROCESS HOLDER (likely ACL/permission, not a lock)")
|
||||
RmEndSession(session)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,280 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>com.yunhai</groupId>
|
||||
<artifactId>yunhaibase</artifactId>
|
||||
<version>0.0.1</version>
|
||||
<name>UserBase</name>
|
||||
<description>用户中心</description>
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
<cn.hutool.version>5.8.36</cn.hutool.version>
|
||||
<easyexcel.version>3.1.2</easyexcel.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-platform-dependencies</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>com.yunhai.platform</groupId><artifactId>yh-mobile-auth</artifactId><version>1.1.0-mobauth03-rc.1</version></dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架;密码加密与 CAS 校验共用 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 健康检查端点 /actuator/health:供 projman 健康总览探活(容器间直连,不走 Caddy/鉴权) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点:暴露 /actuator/prometheus 供压测观测(方案 §2.5)。
|
||||
压测结束后整段删除,并同步回退 application.yml 的 management 段。见《埋点移除清单.md》。 -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<!-- [LOADTEST-METRICS-END] -->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RabbitMQ:推送订阅消息队列(串行消费 + 死信重试)。版本由 Boot 2.7 父 POM 统一管理 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cache 抽象层(含 CaffeineCacheManager) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context-support</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 本地内存缓存兜底:Caffeine(版本由 Boot 2.7 父 POM 统一管理) -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Jackson JSON 序列化(Redis value 序列化,解决泛型/乱码) -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<!-- Jackson Java 8 日期时间支持(LocalDateTime / LocalDate 序列化);
|
||||
虽 spring-boot-starter-json 会传递引入,但此处显式声明以防上层排除 → 序列化异常 -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redisson 核心:分布式锁、限流器、Lua 复杂操作(与 Spring Cache 隔离,手动配置 RedissonClient 避免自动配置冲突) -->
|
||||
<dependency>
|
||||
<groupId>org.redisson</groupId>
|
||||
<artifactId>redisson</artifactId>
|
||||
<version>3.13.6</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
<version>1.2.80</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 核心基础工具 -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-core</artifactId>
|
||||
<version>${cn.hutool.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;替代旧 Springfox) -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-ui</artifactId>
|
||||
<version>1.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- easypoi 导出相关 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.afterturn</groupId>
|
||||
<artifactId>easypoi-base</artifactId>
|
||||
<version>4.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台架包:认证(CAS/会话)+ 应用间签名鉴权 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-auth-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 接口规范:成功响应统一包装为 {code,msg,data};异常以应用内 GlobalExceptionHandler 为主,
|
||||
本 starter 的异常处理器仅作「未被处理异常」的兜底。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-api-std</artifactId>
|
||||
</dependency>
|
||||
<!-- 前端运行时配置(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>
|
||||
<!-- 服务间出站调用引擎(YschOpenCall 自动装配):外观代理端点的上游调用链。
|
||||
UserBase 是聚合 starter 例外,须显式引入(Notice/svc-hall/flow-center 走聚合包已含)。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-openapi-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 代理 UserHome)。
|
||||
平台件,不进聚合包;前端零消费(通道 init 裸 fetch),alova 勿生成定义。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-appearance-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
|
||||
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>userbase</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
|
||||
"Error while storing the mojo status / 拒绝访问" -->
|
||||
<useIncrementalCompilation>false</useIncrementalCompilation>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.yunhai.userbase.UserBaseApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>nexus-public</id>
|
||||
<name>bsch/nexus public</name>
|
||||
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<!--
|
||||
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
|
||||
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
|
||||
"Error while storing the mojo status / 拒绝访问"。
|
||||
另:输出目录需从未被 IDE 打开过,否则同一棵输出树下的 application.yml 等资源
|
||||
会被 IDE 的 JPS 预加载进程持有句柄而写不进(拒绝访问)。常规本地构建无需激活此 profile。
|
||||
说明:C:/ci-build 已被 IDE 锁过,现改用全新目录 C:/ub-build。
|
||||
-->
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>ci</id>
|
||||
<build>
|
||||
<directory>C:/ub-build2/userbase</directory>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<profile>
|
||||
<id>mobauth03</id>
|
||||
<build><directory>${mobauth03.output}/${project.artifactId}</directory></build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
// Deploy front/dist to host /data/www/userbase/front using the Docker daemon
|
||||
// that act_runner already mounts into the job container (/var/run/docker.sock).
|
||||
//
|
||||
// Why this script exists:
|
||||
// act_runner (v0.6.1) ignores both `container.options` extra --volume and
|
||||
// workflow `container.volumes` in this setup, so the host static dir can't be
|
||||
// bind-mounted directly into the job container. But the host docker.sock IS
|
||||
// mounted by default, so we use the Docker HTTP API to:
|
||||
// 1. tar the built assets straight out of THIS container's filesystem
|
||||
// (GET /containers/{jobId}/archive?path=...), and
|
||||
// 2. upload that tar into a throwaway helper container that has
|
||||
// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive),
|
||||
// then `mv` the extracted folder so files land at the correct top level.
|
||||
//
|
||||
// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt
|
||||
// on the HOST via `docker exec` into the helper (the job container itself cannot
|
||||
// write there). The Gitea logs API is unavailable here, so this file is our only
|
||||
// reliable window into what happened.
|
||||
//
|
||||
// No docker CLI and no network egress required — we speak the Docker HTTP API
|
||||
// over the unix socket directly with Node's http module.
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SOCKET = '/var/run/docker.sock';
|
||||
const HOST_STATIC = '/data/www/userbase';
|
||||
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
|
||||
const STAGING = HOST_STATIC + '/_staging';
|
||||
|
||||
// --- low level docker http helpers -------------------------------------------
|
||||
|
||||
function dockerJson(method, p, bodyObj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = bodyObj ? JSON.stringify(bodyObj) : null;
|
||||
const headers = {};
|
||||
if (data) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(data);
|
||||
}
|
||||
const req = http.request(
|
||||
{ socketPath: SOCKET, path: p, method, headers },
|
||||
(res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => (buf += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function getArchive(id, p) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: SOCKET,
|
||||
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
||||
method: 'GET',
|
||||
},
|
||||
(res) => resolve(res)
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function putArchive(id, p, tarStream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: SOCKET,
|
||||
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-tar' },
|
||||
},
|
||||
(res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => (buf += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
tarStream.pipe(req);
|
||||
});
|
||||
}
|
||||
|
||||
// Run a command inside the helper and return its stdout/stderr (Detach:false so
|
||||
// the call resolves only after the command finishes — reliable for sequencing).
|
||||
async function runExec(id, cmd) {
|
||||
const created = await dockerJson('POST', `/containers/${id}/exec`, {
|
||||
Cmd: ['sh', '-c', cmd],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
if (created.status >= 400) {
|
||||
throw new Error('exec create failed: ' + created.status + ' ' + created.body);
|
||||
}
|
||||
const execId = JSON.parse(created.body).Id;
|
||||
const started = await dockerJson('POST', `/exec/${execId}/start`, {
|
||||
Detach: false,
|
||||
Tty: false,
|
||||
});
|
||||
return started; // { status, body(stdout+stderr) }
|
||||
}
|
||||
|
||||
// --- helper container + diagnostics ------------------------------------------
|
||||
|
||||
function getContainerId() {
|
||||
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||
const m = cg.match(/[0-9a-f]{64}/);
|
||||
if (!m) {
|
||||
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
|
||||
return h;
|
||||
}
|
||||
return m[0];
|
||||
}
|
||||
|
||||
let HELPER_ID = null;
|
||||
let DEBUG_BUF = [];
|
||||
|
||||
function dbg(line) {
|
||||
const ts = new Date().toISOString();
|
||||
DEBUG_BUF.push(`[${ts}] ${line}`);
|
||||
console.log(line); // also emit to job log (best-effort)
|
||||
}
|
||||
|
||||
// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing
|
||||
// the payload inline (single-quoted) avoids env-var/newline fragility that broke
|
||||
// the earlier diagnostics channel. Uses Detach:false so we know it completed.
|
||||
async function flushDebug() {
|
||||
if (!HELPER_ID) {
|
||||
console.error('flushDebug: no helper, cannot write host debug');
|
||||
return;
|
||||
}
|
||||
const content = DEBUG_BUF.join('\n') + '\n';
|
||||
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
||||
const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`;
|
||||
try {
|
||||
const r = await runExec(HELPER_ID, cmd);
|
||||
if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body);
|
||||
else dbg('debug written to host (' + content.length + ' bytes)');
|
||||
} catch (e) {
|
||||
console.error('flushDebug error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- main --------------------------------------------------------------------
|
||||
|
||||
function resolveDistPath() {
|
||||
const candidates = [
|
||||
path.join(process.cwd(), 'front', 'dist'),
|
||||
'/workspace/admin/userbase/front/dist',
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
dbg('START script version=2026-08-04c');
|
||||
dbg('cwd=' + process.cwd());
|
||||
|
||||
// 1) helper container with the host static dir mounted RW; keep it alive.
|
||||
const createBody = {
|
||||
Image: HELPER_IMAGE,
|
||||
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
|
||||
Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`],
|
||||
};
|
||||
const created = await dockerJson('POST', '/containers/create', createBody);
|
||||
if (created.status >= 400) {
|
||||
throw new Error('create helper failed: ' + created.status + ' ' + created.body);
|
||||
}
|
||||
HELPER_ID = JSON.parse(created.body).Id;
|
||||
dbg('helper created id=' + HELPER_ID);
|
||||
|
||||
const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`);
|
||||
if (started.status >= 400) {
|
||||
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
|
||||
}
|
||||
dbg('helper started');
|
||||
// clean any stale staging from a previous failed run
|
||||
await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`);
|
||||
|
||||
// 2) locate the built assets inside THIS container.
|
||||
const jobId = getContainerId();
|
||||
dbg('job container id=' + jobId);
|
||||
|
||||
const distDir = resolveDistPath();
|
||||
if (!distDir) {
|
||||
dbg('dist dir NOT found; cwd listing:');
|
||||
try {
|
||||
dbg(fs.readdirSync(process.cwd()).join(' '));
|
||||
const front = path.join(process.cwd(), 'front');
|
||||
if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' '));
|
||||
} catch (e) {
|
||||
dbg('ls err: ' + e.message);
|
||||
}
|
||||
throw new Error('built dist not found under any candidate path');
|
||||
}
|
||||
dbg('dist dir=' + distDir);
|
||||
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
|
||||
|
||||
// 3) tar of the built assets, fetched from THIS container's own filesystem.
|
||||
// We fetch WITHOUT a trailing slash so the tar contains a top-level "dist/"
|
||||
// folder; we then mv it into place (deterministic, no reliance on the
|
||||
// trailing-slash "contents only" quirk which this moby build ignores).
|
||||
const tar = await getArchive(jobId, distDir);
|
||||
if (tar.statusCode !== 200) {
|
||||
let msg = '';
|
||||
tar.on('data', (c) => (msg += c));
|
||||
await new Promise((r) => tar.on('end', r));
|
||||
throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg);
|
||||
}
|
||||
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
|
||||
|
||||
// 4) upload the tar into the staging dir of the helper's mounted host dir.
|
||||
const put = await putArchive(HELPER_ID, STAGING, tar);
|
||||
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
|
||||
if (put.status >= 400) {
|
||||
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
|
||||
}
|
||||
|
||||
// 5) move staging/dist -> HOST_STATIC/front (deterministic top-level landing).
|
||||
const mv = await runExec(
|
||||
HELPER_ID,
|
||||
`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, ' | '));
|
||||
|
||||
// 6) verify on host via exec into helper.
|
||||
const verify = await runExec(
|
||||
HELPER_ID,
|
||||
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` +
|
||||
`echo "has_stray_dist=$(test -d ${HOST_STATIC}/front/dist && echo YES || echo NO)"; ` +
|
||||
`ls -la ${HOST_STATIC}/front | head -20`
|
||||
);
|
||||
dbg('verify output: ' + verify.body.replace(/\n/g, ' | '));
|
||||
|
||||
dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no dist/ nesting)');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
// On success, keep diagnostics only briefly, then remove the debug file so
|
||||
// it is not left web-served at /userbase/_debug.txt. Failures keep it for
|
||||
// post-mortem (see .catch below).
|
||||
await flushDebug();
|
||||
try {
|
||||
if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`);
|
||||
} catch (e) {
|
||||
console.error('cleanup debug file failed:', e.message);
|
||||
}
|
||||
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (err) => {
|
||||
dbg('DEPLOY FAILED: ' + err.stack);
|
||||
await flushDebug(); // keep debug file on failure for post-mortem
|
||||
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
||||
console.error('DEPLOY FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
-- =====================================================================
|
||||
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
|
||||
-- sys_group 增列 include_children:学校节点上的一个开关,
|
||||
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
|
||||
-- 交付文件,不在本机执行;由人工在 160 执行
|
||||
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 增列 include_children
|
||||
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
|
||||
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
|
||||
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
|
||||
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
|
||||
-- ---------------------------------------------------------------------------
|
||||
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sys_group'
|
||||
AND COLUMN_NAME = 'include_children');
|
||||
|
||||
SET @sql := IF(@col = 0,
|
||||
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
|
||||
'SELECT ''include_children already exists'' AS msg');
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 核验(可选)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
|
||||
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ALTER TABLE sys_group DROP COLUMN include_children;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- =====================================================================
|
||||
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
|
||||
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
|
||||
-- / ub_custom_department_member(部门成员关系)
|
||||
-- 交付文件,不在本机执行;由人工在 160 执行
|
||||
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
|
||||
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
|
||||
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
|
||||
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
|
||||
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
|
||||
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
|
||||
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
|
||||
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
|
||||
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
|
||||
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
|
||||
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
|
||||
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
|
||||
deleted_at DATETIME NULL COMMENT '软删时间',
|
||||
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
|
||||
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
|
||||
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
|
||||
KEY idx_school_sort (school_id, is_deleted, sort_no)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
|
||||
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
|
||||
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
|
||||
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ub_custom_department (
|
||||
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
|
||||
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
|
||||
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
|
||||
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
|
||||
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
|
||||
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
|
||||
deleted_at DATETIME NULL COMMENT '软删时间',
|
||||
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
|
||||
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
|
||||
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
|
||||
KEY idx_category_list (category_id, is_deleted, sort_no),
|
||||
KEY idx_school_list (school_id, is_deleted, enabled)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
|
||||
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
|
||||
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
|
||||
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
|
||||
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
|
||||
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
|
||||
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
|
||||
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
|
||||
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_department_user (department_id, user_id),
|
||||
KEY idx_department (department_id),
|
||||
KEY idx_school (school_id),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 核验(可选,执行后逐条确认)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SHOW CREATE TABLE ub_custom_dept_category;
|
||||
-- SHOW CREATE TABLE ub_custom_department;
|
||||
-- SHOW CREATE TABLE ub_custom_department_member;
|
||||
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
|
||||
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
|
||||
-- 唯一键生效自检(手工插入应报 1062):
|
||||
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
|
||||
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
|
||||
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- DROP TABLE IF EXISTS ub_custom_department_member;
|
||||
-- DROP TABLE IF EXISTS ub_custom_department;
|
||||
-- DROP TABLE IF EXISTS ub_custom_dept_category;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- sys_group 加启停状态列(2026-10-20 部门线 P0 最小批)
|
||||
-- 目标环境:160(MySQL 8,容器网 mysql8:3306,库见 UserBase 生产连接串)
|
||||
-- 幂等:INFORMATION_SCHEMA 判列存在即跳过,可重复执行;160 一律以本脚本为准(不依赖 ddl-auto)
|
||||
-- 回滚:ALTER TABLE sys_group DROP COLUMN status;(存量数据全 0=启用,回滚无行为影响)
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_sys_group_status $$
|
||||
CREATE PROCEDURE add_sys_group_status()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sys_group'
|
||||
AND COLUMN_NAME = 'status'
|
||||
) THEN
|
||||
ALTER TABLE sys_group
|
||||
ADD COLUMN status TINYINT NOT NULL DEFAULT 0
|
||||
COMMENT '0 启用 / 1 停用(停用后新建选不到,已有引用与在途流程不受影响)';
|
||||
END IF;
|
||||
END $$
|
||||
|
||||
CALL add_sys_group_status() $$
|
||||
DROP PROCEDURE IF EXISTS add_sys_group_status $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 执行后核对(计数入台账,业务数据不入):
|
||||
-- SELECT status, COUNT(*) FROM sys_group GROUP BY status; -- 预期全部 status=0
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.userbase;
|
||||
|
||||
import com.alibaba.fastjson.parser.ParserConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableTransactionManagement
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class UserBaseApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 开启安全模式
|
||||
ParserConfig.getGlobalInstance().setSafeMode(true);
|
||||
SpringApplication.run(UserBaseApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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.nio.charset.StandardCharsets;
|
||||
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>
|
||||
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
|
||||
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class OapiLogAspect {
|
||||
|
||||
/** 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 {
|
||||
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;
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, saveResponse);
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
// 业务异常(DException/BizException)同样构造实际返回的响应体落库,便于详情下载
|
||||
fillErrorResponse(entity, e, saveResponse);
|
||||
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) {
|
||||
if (!saveResponse || result == null) {
|
||||
return;
|
||||
}
|
||||
writeResponseData(entity, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常路径:与 {@code GlobalExceptionHandler} 保持一致的响应体(DException/BizException → ApiResponse),
|
||||
* 使日志下载内容等于调用方实际收到的 body;其余异常不构造(响应不可预期)。
|
||||
*/
|
||||
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse) {
|
||||
if (!saveResponse) {
|
||||
return;
|
||||
}
|
||||
ApiResponse<?> response = toApiResponse(e);
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
writeResponseData(entity, response);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = SensitiveDataMasker.maskObject(payload);
|
||||
if (resp == null) {
|
||||
return;
|
||||
}
|
||||
entity.setResponseData(GzipUtil.compress(resp));
|
||||
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
|
||||
} catch (Exception e) {
|
||||
log.warn("开放接口日志响应序列化失败, uri={}", entity.getRequestUri(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLen) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.yunhai.userbase.aspect;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.context.UserContext;
|
||||
import com.yunhai.userbase.entity.SysOperationLog;
|
||||
import com.yunhai.userbase.service.ISysOperationLogService;
|
||||
import com.yunhai.userbase.utils.GzipUtil;
|
||||
import com.yunhai.userbase.utils.IpUtils;
|
||||
import com.yunhai.userbase.utils.OperationLogResolver;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 操作日志切面。<br>
|
||||
* 拦截所有 Controller 方法,按如下规则自动采集并落库:<br>
|
||||
* - GET 请求:自动记录为「一般(查询)」,无需任何注解;<br>
|
||||
* - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);<br>
|
||||
* - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。<br>
|
||||
* 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class OperationLogAspect {
|
||||
|
||||
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
|
||||
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Around("execution(* com.yunhai.userbase.controller..*.*(..))"
|
||||
+ " && !execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
// 排除日志查看接口自身
|
||||
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = getRequest();
|
||||
String httpMethod = request != null ? request.getMethod() : "";
|
||||
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
||||
|
||||
// 解析方法上的 @OperationLog 注解(可能为 null)
|
||||
OperationLog operationLog = findAnnotation(point);
|
||||
|
||||
// 决策:GET 自动记录;非 GET 仅在有注解时记录
|
||||
if (!isGet && operationLog == null) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
SysOperationLog entity = buildEntity(point, request, httpMethod);
|
||||
|
||||
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
|
||||
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, saveResponse);
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
throw e;
|
||||
} finally {
|
||||
entity.setCostTime(System.currentTimeMillis() - start);
|
||||
sysOperationLogService.saveAsync(entity);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从连接点解析方法上的 @OperationLog 注解。
|
||||
*/
|
||||
private OperationLog findAnnotation(ProceedingJoinPoint point) {
|
||||
try {
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
return method.getAnnotation(OperationLog.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装日志实体(基础字段 + 级别/类型判定)。
|
||||
*/
|
||||
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
|
||||
SysOperationLog entity = new SysOperationLog();
|
||||
|
||||
Integer userId = UserContext.getCurrentUser();
|
||||
entity.setUserId(userId != null ? userId.longValue() : 0L);
|
||||
String username = request != null ? request.getRemoteUser() : null;
|
||||
entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous");
|
||||
|
||||
String requestUri = "";
|
||||
if (request != null) {
|
||||
entity.setRequestIp(IpUtils.getIpAddr(request));
|
||||
requestUri = request.getRequestURI();
|
||||
entity.setUserAgent(request.getHeader("User-Agent"));
|
||||
entity.setRequestParams(buildRequestParams(point, request));
|
||||
} else {
|
||||
entity.setRequestParams(buildRequestParams(point, null));
|
||||
}
|
||||
entity.setRequestUri(requestUri);
|
||||
entity.setRequestMethod(httpMethod);
|
||||
|
||||
String methodName = point.getSignature().getName();
|
||||
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
|
||||
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
|
||||
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建请求参数。<br>
|
||||
* GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。<br>
|
||||
*/
|
||||
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||
try {
|
||||
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||
String qs = request.getQueryString();
|
||||
if (StringUtils.isNotBlank(qs)) {
|
||||
return qs;
|
||||
}
|
||||
}
|
||||
Object[] args = point.getArgs();
|
||||
if (args == null || args.length == 0) {
|
||||
return "";
|
||||
}
|
||||
List<Object> filtered = new ArrayList<Object>();
|
||||
for (Object arg : args) {
|
||||
if (arg == null) {
|
||||
continue;
|
||||
}
|
||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||
|| arg instanceof HttpSession) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(arg);
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return JSON.toJSONString(filtered);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入响应结果(全量 GZIP 压缩入库,不再截断)。
|
||||
*/
|
||||
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse) {
|
||||
if (!saveResponse || result == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = JSON.toJSONString(result);
|
||||
entity.setResponseData(GzipUtil.compress(resp));
|
||||
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
|
||||
} catch (Exception e) {
|
||||
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLen) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yunhai.userbase.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.ConstraintViolationException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回,
|
||||
* 避免前端收到整段 Validation failed ... 堆栈文本。<br>
|
||||
* 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400),
|
||||
* 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。<br>
|
||||
* 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理
|
||||
* (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/** 参数校验失败的业务码,前端按「参数错误」处理 */
|
||||
private static final int CODE_PARAM_ERROR = 4;
|
||||
|
||||
/** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
|
||||
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
||||
}
|
||||
|
||||
/** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */
|
||||
@ExceptionHandler(BindException.class)
|
||||
public Map<String, Object> handleBind(BindException ex) {
|
||||
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
||||
}
|
||||
|
||||
/** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
String msg = ex.getConstraintViolations().stream()
|
||||
.map(ConstraintViolation::getMessage)
|
||||
.collect(Collectors.joining(";"));
|
||||
return paramError(msg);
|
||||
}
|
||||
|
||||
/** 缺少必需的请求参数(@RequestParam 必填项未传,如 oapi 调用方漏传 groupId)。 */
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public Map<String, Object> handleMissingParam(MissingServletRequestParameterException ex) {
|
||||
return paramError("缺少必需的请求参数: " + ex.getParameterName());
|
||||
}
|
||||
|
||||
/** 请求参数类型不匹配(如 Integer 参数收到非数字字符串)。 */
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public Map<String, Object> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
return paramError("参数类型不匹配: " + ex.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体反序列化失败(Jackson 在进 Controller 前抛出,@Valid 兜不到)。典型如 birthday 等非空字段格式非法:
|
||||
* 从异常最内层 {@link JsonMappingException} 的 path 取失败字段名,birthday 给点名提示,其余走通用文案。
|
||||
* 空串/null 由 Jackson JSR-310 归一为 null、不抛,故不会误入此分支(满足「空不管」)。
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public Map<String, Object> handleNotReadable(HttpMessageNotReadableException ex) {
|
||||
String field = null;
|
||||
Throwable cause = ex.getMostSpecificCause();
|
||||
if (cause instanceof JsonMappingException) {
|
||||
List<JsonMappingException.Reference> path = ((JsonMappingException) cause).getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
field = path.get(path.size() - 1).getFieldName();
|
||||
}
|
||||
}
|
||||
if ("birthday".equals(field)) {
|
||||
return paramError("birthday 字段类型需为yyyy-MM-dd");
|
||||
}
|
||||
return paramError("请求参数格式错误");
|
||||
}
|
||||
|
||||
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
|
||||
if (fieldErrors == null || fieldErrors.isEmpty()) {
|
||||
return "参数校验失败";
|
||||
}
|
||||
return fieldErrors.stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
/** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */
|
||||
private Map<String, Object> paramError(String msg) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("code", CODE_PARAM_ERROR);
|
||||
body.put("msg", msg);
|
||||
body.put("data", null);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.userbase.common.annotation;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 开放接口日志注解。<br>
|
||||
* 标注在 {@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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.userbase.common.annotation;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 操作日志注解。<br>
|
||||
* 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface OperationLog {
|
||||
|
||||
/**
|
||||
* 操作描述,可选。
|
||||
*/
|
||||
String value() default "";
|
||||
|
||||
/**
|
||||
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||
*/
|
||||
boolean saveResponse() default true;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.userbase.common.context;
|
||||
|
||||
/**
|
||||
* 开放接口(oapi)调用方来源上下文:在 oapi 请求链路中保存"触发本次数据变更的订阅方 appKey",
|
||||
* 供业务层发布 {@code DataSyncEvent} 时写入事件来源,推送分发器据此回推时排除来源应用自身
|
||||
* (避免应用 A 自己调 oapi 写接口后又收到自己的回声)。<br><br>
|
||||
* <b>生命周期:</b>由 {@code OapiCallerAppInterceptor} 在 {@code preHandle} 写入、
|
||||
* {@code afterCompletion} 强制清除(防线程池复用泄漏)。管理后台 /api/* 写接口不走 oapi 拦截器,
|
||||
* 此处为 null,表示"来源非订阅方",推送不对其做来源排除(全量投递)。<br><br>
|
||||
* 设计与 {@link UserContext} 同套路(ThreadLocal + remove)。
|
||||
*/
|
||||
public class CallerAppContext {
|
||||
|
||||
private static final ThreadLocal<Integer> CALLER_APP_KEY = new ThreadLocal<>();
|
||||
|
||||
public static void setAppKey(Integer appKey) {
|
||||
CALLER_APP_KEY.set(appKey);
|
||||
}
|
||||
|
||||
public static Integer getAppKey() {
|
||||
return CALLER_APP_KEY.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CALLER_APP_KEY.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.context;
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private static final ThreadLocal<Integer> CURRENT_USER = new ThreadLocal<>();
|
||||
|
||||
public static void setCurrentUser(Integer userId) {
|
||||
CURRENT_USER.set(userId);
|
||||
}
|
||||
|
||||
public static Integer getCurrentUser() {
|
||||
return CURRENT_USER.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CURRENT_USER.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* ClientEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class ClientEnumConverter extends CommonIntEnumConverter<ClientEnum> {
|
||||
|
||||
public ClientEnumConverter() {
|
||||
super(ClientEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* 通用「int <-> 实现 IEnum<Integer> 的枚举」JPA 转换器抽象基类。<br>
|
||||
* <p>
|
||||
* <b>绑定机制(通用做法):</b>为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定,
|
||||
* 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如:
|
||||
* <pre>
|
||||
* public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
||||
* public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
|
||||
* }
|
||||
* </pre>
|
||||
* 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有<b>独立的转换器实例</b>,
|
||||
* 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的,
|
||||
* 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。
|
||||
* </p>
|
||||
*
|
||||
* @param <E> 必须实现 IEnum<Integer> 的枚举类型
|
||||
* @author chenx
|
||||
*/
|
||||
public abstract class CommonIntEnumConverter<E extends Enum<E> & IEnum<Integer>>
|
||||
implements AttributeConverter<E, Integer> {
|
||||
|
||||
private final Class<E> enumCls;
|
||||
|
||||
protected CommonIntEnumConverter(Class<E> enumCls) {
|
||||
this.enumCls = enumCls;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer convertToDatabaseColumn(E enumVal) {
|
||||
return enumVal == null ? null : enumVal.getKey();
|
||||
}
|
||||
|
||||
@Override
|
||||
public E convertToEntityAttribute(Integer dbVal) {
|
||||
if (dbVal == null) {
|
||||
return null;
|
||||
}
|
||||
// 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败)
|
||||
return IEnum.getByKey(enumCls, dbVal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.GenderEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* GenderEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class GenderEnumConverter extends CommonIntEnumConverter<GenderEnum> {
|
||||
|
||||
public GenderEnumConverter() {
|
||||
super(GenderEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* GroupTypeEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
||||
|
||||
public GroupTypeEnumConverter() {
|
||||
super(GroupTypeEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.IntegEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* IntegEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class IntegEnumConverter extends CommonIntEnumConverter<IntegEnum> {
|
||||
|
||||
public IntegEnumConverter() {
|
||||
super(IntegEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
import javax.persistence.Converter;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/14
|
||||
* @description 将 List<Integer> 与 MySQL JSON 字符串互相转换。
|
||||
* 使用 Jackson 的 {@link TypeReference} 明确反序列化为 List<Integer>,
|
||||
* 避免 Hibernate 内置 JSON 类型把数组元素类型读错的问题。
|
||||
*/
|
||||
@Converter
|
||||
public class ListIntegerJsonConverter implements AttributeConverter<List<Integer>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final TypeReference<List<Integer>> LIST_INTEGER_TYPE = new TypeReference<List<Integer>>() {
|
||||
};
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<Integer> attribute) {
|
||||
if (attribute == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("角色列表序列化 JSON 失败: " + attribute, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Integer> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
List<Integer> list = MAPPER.readValue(dbData, LIST_INTEGER_TYPE);
|
||||
return list == null ? Collections.emptyList() : list;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("角色 JSON 解析失败: " + dbData, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* StatusEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class StatusEnumConverter extends CommonIntEnumConverter<StatusEnum> {
|
||||
|
||||
public StatusEnumConverter() {
|
||||
super(StatusEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* SysRoleEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。<br>
|
||||
* 注意:sys_user.roles 是「角色 key 的 JSON 列表」,使用 ListIntegerJsonConverter,
|
||||
* 若某处需要「单角色」字段,再引用本转换器即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class SysRoleEnumConverter extends CommonIntEnumConverter<SysRoleEnum> {
|
||||
|
||||
public SysRoleEnumConverter() {
|
||||
super(SysRoleEnum.class);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.common.converter;
|
||||
|
||||
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||
|
||||
import javax.persistence.AttributeConverter;
|
||||
|
||||
/**
|
||||
* UserTypeEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
public class UserTypeEnumConverter extends CommonIntEnumConverter<UserTypeEnum> {
|
||||
|
||||
public UserTypeEnumConverter() {
|
||||
super(UserTypeEnum.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.userbase.common.interceptor;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.common.context.UserContext;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 把当前登录用户写入 {@link UserContext}(供 JPA 审计监听器 @PrePersist/@PreUpdate 与操作日志切面使用)。<br><br>
|
||||
* 登录名取自 CAS 客户端写入的 {@code request.getRemoteUser()}(cas-client-core 过滤器已建立会话);
|
||||
* 再经本地 {@link UserCenterService} 组装 {@link BoaUser} 取其 id 写入 ThreadLocal。
|
||||
* 原 bsch 的 {@code BschBaseOpenApi.getUserInfoWithDept} 与 {@code LocalMemCache} 已移除,
|
||||
* 统一改用基础平台用户模型与本地服务中心。
|
||||
*/
|
||||
@Component
|
||||
public class BoaUserContextInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Autowired
|
||||
private UserCenterService userCenterService;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler) {
|
||||
String loginName = request.getRemoteUser();
|
||||
if (StringUtils.isBlank(loginName)) {
|
||||
return true;
|
||||
}
|
||||
BoaUser user = userCenterService.getUserInfoWithDeptByAccount(loginName);
|
||||
if (user != null && user.getId() != null && user.getId() != 0) {
|
||||
UserContext.setCurrentUser(user.getId());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler, Exception ex) {
|
||||
UserContext.clear();
|
||||
// 访问范围请求级缓存与用户上下文同生命周期,一并清理(防线程复用串档)
|
||||
AccessScopeService.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/9/4
|
||||
*/
|
||||
@Component
|
||||
public class ManageAccessInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 白名单(显式 /api 前缀,路径闭集):
|
||||
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
|
||||
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
|
||||
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
|
||||
*/
|
||||
private static final List<String> WHITELIST = Arrays.asList(
|
||||
"/api/base/GetUserInfo",
|
||||
"/api/base/GetUserScope",
|
||||
"/api/base/GetSysDict",
|
||||
"/api/base/RedirectTo/*",
|
||||
"/api/appearance",
|
||||
"/api/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,33 @@
|
||||
package com.yunhai.userbase.common.listener;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userbase.common.context.UserContext;
|
||||
import com.yunhai.userbase.entity.BaseEntity;
|
||||
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.PreUpdate;
|
||||
|
||||
public class UserAuditListener {
|
||||
|
||||
@PrePersist
|
||||
public void prePersist(BaseEntity entity) {
|
||||
System.out.println("=== prePersist 触发 ===");
|
||||
Integer userId = UserContext.getCurrentUser();
|
||||
System.out.println("ThreadLocal userId: " + userId);
|
||||
if (userId != null) {
|
||||
entity.setCreateUser(userId);
|
||||
entity.setUpdateUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate(BaseEntity entity) {
|
||||
System.out.println("=== preUpdate 触发 ===");
|
||||
Integer userId = UserContext.getCurrentUser();
|
||||
System.out.println("ThreadLocal userId: " + userId);
|
||||
|
||||
if (userId != null) {
|
||||
entity.setUpdateUser(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.userbase.common.validation;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 22:11
|
||||
* @description
|
||||
*/
|
||||
public interface ValidationGroups {
|
||||
interface Create {}
|
||||
interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.appauth.AppSecretProvider;
|
||||
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥
|
||||
* (覆盖全部「已颁发」应用),并以配置式 {@code yh.app-auth.trustedApps} 兜底平台自身默认密钥。
|
||||
* 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验
|
||||
* 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。<br><br>
|
||||
* 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml
|
||||
* 配置 app-key/app-secret。<br>
|
||||
* <b>注意:</b>两个 Bean 都加 {@code @Primary},保证 yh-platform starter 在按类型注入时
|
||||
* (如 AppSignInterceptor 需要 LocalAppIdentityProvider)不会因多个候选而报
|
||||
* 「required a single bean, but 2 were found」。
|
||||
*/
|
||||
@Configuration
|
||||
public class AppSecretConfig {
|
||||
|
||||
/**
|
||||
* 入站验签密钥提供方(按类型匹配 AppSecretProvider,@Primary 优先)。
|
||||
* SignKeyCacheService 注入时按具体类名 UserBaseAppSecretProvider 匹配,不走这个接口类型。
|
||||
*/
|
||||
@Bean(name = "appSecretProvider")
|
||||
@Primary
|
||||
public AppSecretProvider appSecretProvider(ApplicationInfoRepository repository,
|
||||
AppAuthProperties properties) {
|
||||
return new UserBaseAppSecretProvider(repository, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 出站身份提供方(按类型匹配 LocalAppIdentityProvider,@Primary 优先)。
|
||||
* AppSignInterceptor 等 yh-platform starter 内部按接口类型注入,需要明确唯一候选。
|
||||
*/
|
||||
@Bean(name = "localAppIdentityProvider")
|
||||
@Primary
|
||||
public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) {
|
||||
return new UserBaseLocalAppIdentityProvider(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* 异步线程池配置。<br>
|
||||
* 日志类异步落库统一走有界池 {@code oapiLogExecutor}:<br>
|
||||
* - core 4 / max 16 / queue 1000,高并发下不会为每条日志 new 一个线程;<br>
|
||||
* - DiscardPolicy 丢弃策略:池满时直接丢弃新任务,日志宁可丢失也不拖垮/阻塞主流程。<br>
|
||||
* 管理端操作日志(SysOperationLogServiceImpl.saveAsync)与开放接口日志
|
||||
* (SysOapiLogServiceImpl.saveAsync)均绑定本池。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Configuration
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 日志异步落库线程池(有界)。
|
||||
*/
|
||||
@Bean("oapiLogExecutor")
|
||||
public Executor oapiLogExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(16);
|
||||
executor.setQueueCapacity(1000);
|
||||
executor.setThreadNamePrefix("oplog-");
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.Expiry;
|
||||
import com.github.benmanes.caffeine.cache.Weigher;
|
||||
import com.yunhai.userbase.config.CacheNames.Meta;
|
||||
import com.yunhai.userbase.utils.ConstantUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.interceptor.CacheErrorHandler;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.CachingConfigurer;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.cache.RedisCacheWriter;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 缓存统一基建(Spring Cache 抽象)。<br><br>
|
||||
*
|
||||
* 组成(缓存名单一定义、一注册、一路由,全部由 {@link CacheNames#REGISTRY} 驱动):
|
||||
* <ul>
|
||||
* <li><b>redisCacheManager</b>:注册表中 REDIS 存放的缓存按名定制 TTL(30s~2h 分级定价),
|
||||
* 写入经抖动装饰器打散过期点(±10%,防同时过期型雪崩);未注册名兜底 5min;</li>
|
||||
* <li><b>caffeineCacheManager</b>:Redis 降级兜底,按字节限重(maximumWeight + 启发式估算,
|
||||
* 防巨型条目绕过条数限制撑爆内存);</li>
|
||||
* <li><b>localCacheManager</b>:LOCAL 存放专用(groupSnapshot / appKeyToId),Caffeine 独立实例,
|
||||
* 永不进 Redis;</li>
|
||||
* <li><b>FallbackCacheManager</b>(@Primary):本地专用名恒路由 localCacheManager,
|
||||
* 其余 Redis 健康走 Redis、故障降级 Caffeine,后台 10s 探活自动切回;</li>
|
||||
* <li><b>CacheErrorHandler</b>:堵探活间隙——Redis 读写异常降级为 log+miss 而非上冒 500,
|
||||
* 驱逐失败打 error(脏数据存活至 TTL 后自愈)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@EnableCaching
|
||||
@Configuration
|
||||
public class CacheConfig 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 分布式缓存管理器。<br>
|
||||
* 各缓存 TTL 由 {@link CacheNames#REGISTRY} 定价(配置型 1h~2h / 用户身份型 5min / 空值哨兵 30s),
|
||||
* 未注册名兜底全局 5min;每次写入 TTL 加 ±10% 随机抖动。
|
||||
*/
|
||||
@Bean
|
||||
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
|
||||
Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
|
||||
jacksonSerializer.setObjectMapper(newCacheObjectMapper());
|
||||
|
||||
RedisCacheConfiguration baseConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(SerializationPair.fromSerializer(jacksonSerializer))
|
||||
.disableCachingNullValues();
|
||||
|
||||
Map<String, RedisCacheConfiguration> perCache = new HashMap<>();
|
||||
for (Meta meta : CacheNames.REGISTRY.values()) {
|
||||
if (meta.getStore() == CacheNames.Store.REDIS) {
|
||||
perCache.put(meta.getName(), baseConfig.entryTtl(meta.getTtl()));
|
||||
}
|
||||
}
|
||||
|
||||
return RedisCacheManager.builder(new JitteringRedisCacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(factory)))
|
||||
.cacheDefaults(baseConfig.entryTtl(ConstantUtil.CACHE_TTL))
|
||||
.withInitialCacheConfigurations(perCache)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。<br>
|
||||
* 按字节限重(原 maximumSize(10000) 条数统计会被巨型条目欺骗),权重为保守启发式估算。
|
||||
*/
|
||||
@Bean
|
||||
public CaffeineCacheManager caffeineCacheManager() {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||
manager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 未注册名默认 TTL 保持一致
|
||||
.maximumWeight(CAFFEINE_MAX_WEIGHT_BYTES)
|
||||
.weigher((Weigher<Object, Object>) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4)))
|
||||
.recordStats());
|
||||
manager.setAllowNullValues(false);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认 RedisTemplate(Jackson 序列化),供项目已有工具类(如 RedisUtil)及手动缓存管理使用。<br>
|
||||
* 覆盖 Boot 自动配置的 JDK 序列化默认实现,与 Spring Cache 注解缓存读写互不干扰。<br>
|
||||
* 当 @Cacheable 无法满足需求(如 Hash/List/Set 结构、按字段读写)时,注入本模板或 RedisUtil 手动管理即可。
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
template.setValueSerializer(jacksonSerializer());
|
||||
template.setHashValueSerializer(jacksonSerializer());
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。<br>
|
||||
* 注册表 LOCAL 存放的缓存恒路由本地实例;其余优先 Redis,
|
||||
* Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。
|
||||
*/
|
||||
@Primary
|
||||
@Bean
|
||||
public CacheManager cacheManager(RedisCacheManager redisCacheManager,
|
||||
CaffeineCacheManager caffeineCacheManager,
|
||||
CaffeineCacheManager localCacheManager,
|
||||
RedisConnectionFactory 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,无业务报错。
|
||||
*/
|
||||
public static class FallbackCacheManager implements CacheManager {
|
||||
|
||||
private final RedisCacheManager redisManager;
|
||||
private final CaffeineCacheManager caffeineManager;
|
||||
private final CaffeineCacheManager localManager;
|
||||
private final RedisConnectionFactory connectionFactory;
|
||||
private final ScheduledExecutorService healthScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "redis-cache-health");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private volatile boolean redisUp = true;
|
||||
|
||||
FallbackCacheManager(RedisCacheManager redisManager,
|
||||
CaffeineCacheManager caffeineManager,
|
||||
CaffeineCacheManager localManager,
|
||||
RedisConnectionFactory connectionFactory) {
|
||||
this.redisManager = redisManager;
|
||||
this.caffeineManager = caffeineManager;
|
||||
this.localManager = localManager;
|
||||
this.connectionFactory = connectionFactory;
|
||||
if (connectionFactory != null) {
|
||||
// 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回)
|
||||
healthScheduler.scheduleAtFixedRate(this::probe, 0, 10, TimeUnit.SECONDS);
|
||||
} else {
|
||||
redisUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void probe() {
|
||||
if (connectionFactory == null) {
|
||||
redisUp = false;
|
||||
return;
|
||||
}
|
||||
RedisConnection connection = null;
|
||||
try {
|
||||
connection = connectionFactory.getConnection();
|
||||
redisUp = connection.ping() != null;
|
||||
} catch (Exception e) {
|
||||
redisUp = false;
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (Exception ignored) {
|
||||
// 关闭失败忽略,下次探活重试
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Redis 是否健康(对账接口上报降级状态用)。 */
|
||||
public boolean isRedisUp() {
|
||||
return redisUp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cache getCache(String name) {
|
||||
// 本地专用缓存(groupSnapshot/appKeyToId)恒走本地实例,与 Redis 状态无关
|
||||
if (CacheNames.isLocalOnly(name)) {
|
||||
return localManager.getCache(name);
|
||||
}
|
||||
// AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。
|
||||
// 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s),
|
||||
// resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。
|
||||
return redisUp ? redisManager.getCache(name) : caffeineManager.getCache(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getCacheNames() {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
names.addAll(localManager.getCacheNames());
|
||||
names.addAll(redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames());
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== TTL 随机抖动 =====================
|
||||
|
||||
/**
|
||||
* RedisCacheWriter 装饰器:每次写入在缓存配置 TTL 基础上加 ±10% 随机抖动,
|
||||
* 打散同一缓存所有条目的过期时刻,防「同时过期型雪崩」。无 TTL(不过期)条目原样透传。
|
||||
*/
|
||||
static class JitteringRedisCacheWriter implements RedisCacheWriter {
|
||||
|
||||
private final RedisCacheWriter delegate;
|
||||
private final double jitterRatio;
|
||||
|
||||
JitteringRedisCacheWriter(RedisCacheWriter delegate) {
|
||||
this(delegate, 0.1);
|
||||
}
|
||||
|
||||
JitteringRedisCacheWriter(RedisCacheWriter delegate, double jitterRatio) {
|
||||
this.delegate = delegate;
|
||||
this.jitterRatio = jitterRatio;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String name, byte[] key, byte[] value, Duration ttl) {
|
||||
delegate.put(name, key, value, jitter(ttl));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(String name, byte[] key) {
|
||||
return delegate.get(name, key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] putIfAbsent(String name, byte[] key, byte[] value, Duration ttl) {
|
||||
return delegate.putIfAbsent(name, key, value, jitter(ttl));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String name, byte[] key) {
|
||||
delegate.remove(name, key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clean(String name, byte[] pattern) {
|
||||
delegate.clean(name, pattern);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearStatistics(String name) {
|
||||
delegate.clearStatistics(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public org.springframework.data.redis.cache.CacheStatistics getCacheStatistics(String name) {
|
||||
return delegate.getCacheStatistics(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisCacheWriter withStatisticsCollector(
|
||||
org.springframework.data.redis.cache.CacheStatisticsCollector collector) {
|
||||
return delegate.withStatisticsCollector(collector);
|
||||
}
|
||||
|
||||
private Duration jitter(Duration ttl) {
|
||||
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
|
||||
return ttl;
|
||||
}
|
||||
return Duration.ofNanos(jitteredNanos(ttl.toNanos(), jitterRatio));
|
||||
}
|
||||
}
|
||||
|
||||
/** 均匀抖动到 [1-ratio, 1+ratio) 区间(ratio=0.1 即 ±10%)。 */
|
||||
static long jitteredNanos(long baseNanos, double ratio) {
|
||||
double factor = 1 - ratio + 2 * ratio * ThreadLocalRandom.current().nextDouble();
|
||||
return Math.max(1L, (long) (baseNanos * factor));
|
||||
}
|
||||
|
||||
/** Caffeine 变长过期:每条目在基础 TTL 上独立抖动(本地缓存同款防雪崩语义)。 */
|
||||
private static Expiry<Object, Object> jitterExpiry(Duration base) {
|
||||
long baseNanos = base.toNanos();
|
||||
return new Expiry<Object, Object>() {
|
||||
@Override
|
||||
public long expireAfterCreate(Object key, Object value, long currentTime) {
|
||||
return jitteredNanos(baseNanos, 0.1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long expireAfterUpdate(Object key, Object value, long currentTime, long currentDuration) {
|
||||
return jitteredNanos(baseNanos, 0.1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long expireAfterRead(Object key, Object value, long currentTime, long currentDuration) {
|
||||
return currentDuration; // 读不续期
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== 权重估算(Caffeine 按字节限重用) =====================
|
||||
|
||||
/**
|
||||
* 保守的对象图字节估算(非精确值,只要量级正确即可完成「防巨型条目」使命):
|
||||
* 字符串按 UTF-16 长度、集合/Map 递归求和(限深度)、单实体对象按 2KB 保底。
|
||||
*/
|
||||
static long estimateWeight(Object value, int depth) {
|
||||
if (value == null) {
|
||||
return 128;
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return 64 + 2L * ((String) value).length();
|
||||
}
|
||||
if (value instanceof byte[]) {
|
||||
return 64 + ((byte[]) value).length;
|
||||
}
|
||||
if (value instanceof Collection) {
|
||||
Collection<?> c = (Collection<?>) value;
|
||||
if (depth <= 0) {
|
||||
return 1024L * c.size();
|
||||
}
|
||||
long w = 64;
|
||||
for (Object o : c) {
|
||||
w += estimateWeight(o, depth - 1);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
Map<?, ?> m = (Map<?, ?>) value;
|
||||
if (depth <= 0) {
|
||||
return 2048L * m.size();
|
||||
}
|
||||
long w = 64;
|
||||
for (Map.Entry<?, ?> e : m.entrySet()) {
|
||||
w += estimateWeight(e.getKey(), depth - 1) + estimateWeight(e.getValue(), depth - 1);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
return 2048;
|
||||
}
|
||||
|
||||
/** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */
|
||||
private Jackson2JsonRedisSerializer<Object> jacksonSerializer() {
|
||||
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
|
||||
jackson.setObjectMapper(newCacheObjectMapper());
|
||||
return jackson;
|
||||
}
|
||||
|
||||
/** 构建缓存专用 ObjectMapper:类型信息 + 可见性 + Java 8 时间类型支持。 */
|
||||
private ObjectMapper newCacheObjectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
// 允许任意字段可见,保证反序列化不依赖 getter/setter
|
||||
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
// 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失)
|
||||
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
|
||||
// 支持 Java 8 时间类型(LocalDateTime 等),否则缓存含时间字段的实体(如 ApplicationInfo.createTime)会序列化失败
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。<br>
|
||||
* key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);<br>
|
||||
* 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
|
||||
* (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。<br>
|
||||
* 设计说明:<b>不用 sync=true</b>——方法对「连主身份都没有」的用户合法返回 null(unless 拦截不入缓存),
|
||||
* SD Redis 2.7 的 sync 路径对 null 会无条件 put 并在 {@code disableCachingNullValues} 下抛 IAE
|
||||
* (同 {@link com.yunhai.userbase.service.impl.BoaUserLoader} 既有纪律)。
|
||||
*/
|
||||
public static final String IDENTITY_CURRENT = "identityCurrent";
|
||||
|
||||
/**
|
||||
* 用户可见应用列表(GET /oapi/app/list 读路径,压测占比 20%,每请求 ~8 条 SQL)。<br>
|
||||
* key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())
|
||||
* + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个;
|
||||
* 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐),
|
||||
* 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);<br>
|
||||
* TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);<br>
|
||||
* 存放:Redis;依赖表:sys_group_app_rel、sys_group_module_perm、sys_user_app_role_rel、sys_app_role、
|
||||
* sys_app_role_module_rel、app_module、app_category、application_info、sys_group、
|
||||
* sys_school_app_override、sys_school_category_override(2026-09-10 起后两者经
|
||||
* SchoolAppOverrideService.applyAlias 正式在读链上:返回前合并学校私有别名/图标/排序);<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(应用授权域 24 写口整清,
|
||||
* 含学校别名覆盖全部写口)+
|
||||
* {@link com.yunhai.userbase.event.UserChangedEvent}(按用户 16 条可枚举 key 精确驱逐)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
|
||||
* (组移动改变继承开通面 / 应用启停排序删除整清)。
|
||||
*/
|
||||
public static final String USER_APP_LIST = "userAppList";
|
||||
|
||||
/**
|
||||
* 学校维度应用清单(GET /oapi/app/list-by-school 读路径,2026-10-10;流程中心 flw_app 源池)。<br>
|
||||
* key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}——
|
||||
* 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清,
|
||||
* 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);<br>
|
||||
* TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);<br>
|
||||
* 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、
|
||||
* sys_school_app_override、sys_school_category_override;<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(授权域写口整清,含学校别名)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
|
||||
* (组开通面变化 / 应用启停排序删除整清)。
|
||||
*/
|
||||
public static final String SCHOOL_APP_LIST = "schoolAppList";
|
||||
|
||||
/**
|
||||
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
|
||||
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
|
||||
* 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);<br>
|
||||
* TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);<br>
|
||||
* 存放:Redis;依赖表:sys_user_group_rel;<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。<br>
|
||||
* 无组关系用户为空 Map 恒非 null,sync=true 安全(穿透防护天然成立)。
|
||||
*/
|
||||
public static final String USER_GROUP_REL = "userGroupRel";
|
||||
|
||||
// ===================== 注册表元数据(驱动 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"));
|
||||
m.put(IDENTITY_CURRENT, new Meta(IDENTITY_CURRENT, Store.REDIS, Duration.ofMinutes(5), 0.1,
|
||||
"'id:'+userId",
|
||||
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
||||
"sys_user_current_role, sys_user_role_rel, sys_user_group_rel, sys_group"));
|
||||
m.put(USER_APP_LIST, new Meta(USER_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
|
||||
"'u:'+userId+':c:'+(clientType?.name()?:'all')+':s:'+(schoolId?:'auto')(8 client × auto/显式校)",
|
||||
"AppAuthzChangedEvent(整清,含学校别名写口)+ UserChangedEvent(按用户 16 条可枚举 key 驱逐,"
|
||||
+ "显式学校后缀 TTL 自愈)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent(整清)"
|
||||
+ " → CacheInvalidationListener",
|
||||
"sys_group_app_rel, sys_group_module_perm, sys_user_app_role_rel, sys_app_role,"
|
||||
+ " sys_app_role_module_rel, app_module, app_category, application_info, sys_group,"
|
||||
+ " sys_school_app_override, sys_school_category_override(2026-09-10 起在读链:applyAlias 合并别名)"));
|
||||
m.put(USER_GROUP_REL, new Meta(USER_GROUP_REL, Store.REDIS, Duration.ofMinutes(5), 0.1,
|
||||
"'u:'+userId(整张 groupId→status Map)",
|
||||
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
||||
"sys_user_group_rel"));
|
||||
m.put(SCHOOL_APP_LIST, new Meta(SCHOOL_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
|
||||
"'s:'+schoolId+':c:'+(clientType?.name()?:'all')(8 client × 学校 id,不可枚举→整清)",
|
||||
"AppAuthzChangedEvent(整清,含学校别名写口)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent"
|
||||
+ "(整清)→ CacheInvalidationListener;直改 DB 靠 TTL 自愈",
|
||||
"sys_group_app_rel, app_category, application_info, sys_group,"
|
||||
+ " sys_school_app_override, sys_school_category_override"));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @version 1.0
|
||||
* @date 2025/4/27 14:31
|
||||
*/
|
||||
|
||||
public enum Config
|
||||
{
|
||||
Inst;
|
||||
|
||||
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
||||
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
|
||||
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
|
||||
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
|
||||
public static final String OPEN_API_PATH = "/oapi/";
|
||||
|
||||
public String getString(String key)
|
||||
{
|
||||
ResourceBundle resource = ResourceBundle.getBundle("config");
|
||||
return resource.getString(key);
|
||||
}
|
||||
|
||||
public int getInt(String key)
|
||||
{
|
||||
String value = getString(key);
|
||||
return Integer.parseInt(value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor;
|
||||
import com.yunhai.userbase.common.interceptor.ManageAccessInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class DWebMvcConfig implements WebMvcConfigurer
|
||||
{
|
||||
@Autowired
|
||||
private BoaUserContextInterceptor boaUserContextInterceptor;
|
||||
@Autowired
|
||||
private OapiCallerAppInterceptor oapiCallerAppInterceptor;
|
||||
@Autowired
|
||||
private ManageAccessInterceptor manageAccessInterceptor;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry)
|
||||
{
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins("*")
|
||||
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
|
||||
.allowedHeaders("*")
|
||||
.maxAge(3600).allowCredentials(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用
|
||||
registry.addInterceptor(oapiCallerAppInterceptor)
|
||||
.addPathPatterns("/oapi/**");
|
||||
// 用户上下文必须先于访问范围拦截器注册(后者依赖 UserContext 判定 NONE)
|
||||
registry.addInterceptor(boaUserContextInterceptor)
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
||||
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
|
||||
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
|
||||
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
|
||||
// /v3/api-docs、/swagger-ui:接口文档工具链。
|
||||
registry.addInterceptor(manageAccessInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
|
||||
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
|
||||
"/webjars/**");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.userbase.utils.FileUtil;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* 文件存储配置注入。<br>
|
||||
* 在 Spring 启动完成后,将上传根目录与 Web 访问前缀注入 FileUtil 静态字段,<br>
|
||||
* 供上传工具类拼接完整可访问 URL 使用。<br>
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@Component
|
||||
public class FileStorageConfig {
|
||||
|
||||
@Value("${FILE_PATH}")
|
||||
private String filePath;
|
||||
|
||||
@Value("${FILE_WEB_PATH}")
|
||||
private String fileWebPath;
|
||||
|
||||
/**
|
||||
* 启动时注入存储配置到 FileUtil。<br>
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
FileUtil.init(filePath, fileWebPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 全局 Jackson 日期格式统一。
|
||||
*
|
||||
* <p>Spring Boot 的 {@code spring.jackson.date-format} 只作用于 {@link java.util.Date},
|
||||
* 对 {@code LocalDateTime}/{@code LocalDate} 无效;且 {@code spring.jackson.local-date-time-format}
|
||||
* 并非 {@code JacksonProperties} 的合法绑定项(会被静默忽略)。本配置把 Nacos 中已配置的
|
||||
* {@code spring.jackson.date-format} 同时应用到 JSR-310 时间类型,做到全局统一。
|
||||
*
|
||||
* <p>仅用 {@code serializerByType}/{@code deserializerByType} 覆盖 JavaTimeModule 默认序列化器,
|
||||
* 不 new 整个 ObjectMapper,因此不会禁用 Spring Boot 的 Jackson 自动配置;
|
||||
* 字段上的 {@code @JsonFormat} 优先级更高,不受影响。
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer(
|
||||
@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") String dateFormat) {
|
||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
|
||||
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
return (Jackson2ObjectMapperBuilder builder) -> {
|
||||
builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
|
||||
builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter));
|
||||
builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter));
|
||||
builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import java.time.Clock;
|
||||
|
||||
@Configuration(proxyBeanMethods=false)
|
||||
public class MobileStrongConfiguration {
|
||||
@Bean public FilterRegistrationBean<StrongRequestFilter> userBaseMobileStrong(
|
||||
@org.springframework.beans.factory.annotation.Qualifier("stringRedisTemplate") ObjectProvider<StringRedisTemplate> redis,
|
||||
@Value("${yh.mobile.env:}") String env,
|
||||
@Value("${yh.mobile.app-id:}") String appId,@Value("${yh.mobile.key-registry-file:}") String keyFile,
|
||||
@Value("${yh.mobile.key-registry-sha256:}") String keyHash) {
|
||||
StrongRequestFilter filter=new StrongRequestFilter(()-> {
|
||||
StringRedisTemplate r=redis.getIfAvailable();if(r==null)return null;
|
||||
return new RequestAssertion(new PinnedKeyRegistry(keyFile,keyHash),new RedisReplay(r),Clock.systemUTC(),"userbase");
|
||||
},r->{String path=r.getRequestURI().substring(r.getContextPath().length());String action=SensitivePaths.action(r.getMethod(),path);
|
||||
if(action==null)return null;
|
||||
AuthFailure.require(!env.isEmpty()&&!appId.isEmpty(),503,"CALLER_VERIFIER_UNAVAILABLE");
|
||||
return new StrongRequestFilter.Required(env,appId,action,path);});
|
||||
FilterRegistrationBean<StrongRequestFilter> bean=new FilterRegistrationBean<>(filter);
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE+3);bean.addUrlPatterns("/*");return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class MyWebAppConfigurer implements WebMvcConfigurer {
|
||||
|
||||
@Value("${FILE_PATH}")
|
||||
public String FILE_PATH;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String IMG_FILE_WEB_PATH = "/upload/image/";
|
||||
String File_FILE_WEB_PATH = "/upload/file/";
|
||||
String ICON_FILE_WEB_PATH = "/upload/icon/";
|
||||
String WORD_FILE_WEB_PATH = "/upload/word/";
|
||||
String HTML_FILE_WEB_PATH = "/upload/html/";
|
||||
String AVATAR_FILE_WEB_PATH = "/upload/avatar/";
|
||||
registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
|
||||
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
|
||||
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
|
||||
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
|
||||
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
|
||||
registry.addResourceHandler(AVATAR_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/avatar/");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.userbase.common.context.CallerAppContext;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* oapi 调用方来源注入拦截器:在 oapi 请求处理前,从 yh-app-auth 验签过滤器写入的
|
||||
* {@link AppAuthFilter#APP_AUTH_ATTR}(调用方 appKey)解析出整型 appKey,
|
||||
* 存入 {@link CallerAppContext};请求结束(无论成功/异常)在 {@code afterCompletion} 强制清除,
|
||||
* 防止线程池复用导致 appKey 串号、进而误把其他订阅方排除。<br><br>
|
||||
* 配合 {@code PushDispatcher} 的来源排除逻辑,实现"应用自己触发的变更不回推给自己"。
|
||||
* 管理后台 /api/* 不走此拦截器 → CallerAppContext 为 null → 推送对该来源不做排除(全量投递)。
|
||||
*/
|
||||
@Component
|
||||
public class OapiCallerAppInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler) {
|
||||
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||
if (attr == null) {
|
||||
return true;
|
||||
}
|
||||
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||
if (StringUtils.isBlank(appKeyStr)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
CallerAppContext.setAppKey(Integer.valueOf(appKeyStr.trim()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 非法 appKey 格式:不设置来源,等同"非订阅方来源",推送全量投递
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler, Exception ex) {
|
||||
CallerAppContext.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.auth.UserDetailsProvider;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
|
||||
* <br>
|
||||
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
|
||||
* 但 UserBase 作为用户中心的开放接口({/open/api/**} 与 {/oapi/**})绝不允许匿名访问。
|
||||
* 本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
|
||||
* 已验签(属性存在)则放行,否则返回 401。<br>
|
||||
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
|
||||
* <br>
|
||||
*
|
||||
* 额外注册 {@code UserDetailsProvider}(@Bean @Primary,名=userDetailsProvider):
|
||||
* 覆盖 yh-auth-spring-boot-starter 新版默认的 NoOpUserDetailsProvider(调用直接抛
|
||||
* UnsupportedOperationException「未配置 UserDetailsProvider」)。必须「显式 @Bean
|
||||
* + @Primary」
|
||||
* 而不能只靠 @Component 扫描:starter 新版 AutoConfiguration 运行顺序可能早于 UserBase 组件扫描,
|
||||
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({ AppAuthProperties.class })
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/open/api/*", "/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
|
||||
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
|
||||
* 与 starter 默认同名,即使开启 allow-bean-definition-overriding=false 也能优先匹配。
|
||||
*/
|
||||
@Bean(name = "userDetailsProvider")
|
||||
@Primary
|
||||
public UserDetailsProvider userDetailsProvider(UserCenterService userCenterService) {
|
||||
return new UserBaseUserDetailsProvider(userCenterService);
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null
|
||||
&& !(req.getAttribute(com.yunhai.platform.mobile.StrongRequestFilter.VERIFIED)
|
||||
instanceof com.yunhai.platform.mobile.CallerAppContext)) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 密码编码器配置:统一使用 BCrypt。<br>
|
||||
* 用户中心存库与后续 CAS 登录校验共用同一编码器,保证加密/校验规则一致。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/23
|
||||
* @description BCrypt 密码编码器 Bean
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
/**
|
||||
* BCrypt 密码编码器。<br>
|
||||
* strength 使用默认 10,兼顾安全与性能;CAS 侧用同一 encoder 的 matches 校验即可。<br>
|
||||
*
|
||||
* @return PasswordEncoder
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.amqp.core.*;
|
||||
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||
import org.springframework.amqp.support.converter.MessageConverter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.yunhai.userbase.model.push.PushTaskMessage;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 推送订阅 MQ 配置。<br><br>
|
||||
* 拓扑:
|
||||
* <pre>
|
||||
* push.topic (topic, durable)
|
||||
* └─ push.main (durable, concurrency=1, x-dead-letter-exchange=push.dlx) ← 串行消费,可观测
|
||||
* push.dlx (fanout, durable)
|
||||
* └─ push.dlq (durable) ← 死信归档(重试耗尽)
|
||||
* </pre>
|
||||
* 连接信息(host/port/user/pwd/vhost)由 Nacos {@code application-infra.yaml} 的 {@code spring.rabbitmq.*}
|
||||
* 公共配置注入,本类不写死(也不改 bootstrap.yml / application.yml)。<br>
|
||||
* 可靠性:消费端手动 ack,失败由 {@code PushConsumer} 应用层自管重试(共 3 次),耗尽进死信队列。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/14
|
||||
*/
|
||||
@Configuration
|
||||
public class PushMqConfig {
|
||||
|
||||
public static final String EXCHANGE_TOPIC = "push.topic";
|
||||
public static final String EXCHANGE_DLX = "push.dlx";
|
||||
public static final String QUEUE_MAIN = "push.main";
|
||||
public static final String QUEUE_DLQ = "push.dlq";
|
||||
|
||||
/** 统一 JSON 消息转换器(固定反序列化为 PushTaskMessage,绕过 trusted-packages 版本差异)。 */
|
||||
@Bean
|
||||
public MessageConverter pushMessageConverter() {
|
||||
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
|
||||
converter.setClassMapper(new PushTaskClassMapper(PushTaskMessage.class));
|
||||
return converter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TopicExchange pushTopicExchange() {
|
||||
return new TopicExchange(EXCHANGE_TOPIC, true, false);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FanoutExchange pushDlxExchange() {
|
||||
return new FanoutExchange(EXCHANGE_DLX, true, false);
|
||||
}
|
||||
|
||||
/** 主队列:单消费者串行 + 死信路由到 push.dlx。 */
|
||||
@Bean
|
||||
public Queue pushMainQueue() {
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("x-dead-letter-exchange", EXCHANGE_DLX);
|
||||
return new Queue(QUEUE_MAIN, true, false, false, args);
|
||||
}
|
||||
|
||||
/** 死信队列:归档重试耗尽的任务。 */
|
||||
@Bean
|
||||
public Queue pushDlqQueue() {
|
||||
return new Queue(QUEUE_DLQ, true, false, false);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Binding pushMainBinding() {
|
||||
return BindingBuilder.bind(pushMainQueue()).to(pushTopicExchange()).with("#");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Binding pushDlqBinding() {
|
||||
return BindingBuilder.bind(pushDlqQueue()).to(pushDlxExchange());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RabbitTemplate pushRabbitTemplate(ConnectionFactory connectionFactory) {
|
||||
RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||
template.setMessageConverter(pushMessageConverter());
|
||||
return template;
|
||||
}
|
||||
|
||||
/** 消费者容器:手动 ack + 单并发 + prefetch=1,保证串行可观测。 */
|
||||
@Bean(name = "pushListenerContainerFactory")
|
||||
public SimpleRabbitListenerContainerFactory pushListenerContainerFactory(ConnectionFactory connectionFactory) {
|
||||
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
|
||||
factory.setConnectionFactory(connectionFactory);
|
||||
factory.setMessageConverter(pushMessageConverter());
|
||||
factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.MANUAL);
|
||||
factory.setConcurrentConsumers(1);
|
||||
factory.setMaxConcurrentConsumers(1);
|
||||
factory.setPrefetchCount(1);
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.support.converter.ClassMapper;
|
||||
|
||||
/**
|
||||
* 推送消息固定反序列化为 {@link PushTaskMessage},绕过 Jackson2JsonMessageConverter 的
|
||||
* trusted-packages 安全限制(不同 spring-amqp 版本该方法签名/默认值不一致,编译期不可靠)。<br>
|
||||
* 生产者端序列化仍由 Jackson2JsonMessageConverter 完成(写入 __TypeId__),消费者端忽略 __TypeId__
|
||||
* 直接按本映射器返回 PushTaskMessage,类型单一且可控。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/14
|
||||
*/
|
||||
public class PushTaskClassMapper implements ClassMapper {
|
||||
|
||||
private final Class<?> payloadClass;
|
||||
|
||||
public PushTaskClassMapper(Class<?> payloadClass) {
|
||||
this.payloadClass = payloadClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> toClass(MessageProperties properties) {
|
||||
return payloadClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fromClass(Class<?> clazz, MessageProperties properties) {
|
||||
properties.setHeader("__TypeId__", clazz.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import org.springdoc.core.GroupedOpenApi;
|
||||
import org.springdoc.core.customizers.GlobalOperationCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
/**
|
||||
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
|
||||
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数,
|
||||
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。<br><br>
|
||||
* 文档分组:
|
||||
* <ul>
|
||||
* <li>{@code oapi}——开放接口(/oapi/**,不含 /oapi/internal/**),是双端 SDK(yh-platform / yh-platform-dotnet)
|
||||
* 契约的权威源,可经 {@code GET /v3/api-docs/oapi} 导出 OpenAPI 3 快照入库对拍;</li>
|
||||
* <li>{@code api}——业务接口(/api/**,含会话登录态,供前端联调)。</li>
|
||||
* </ul>
|
||||
* 生产环境建议关闭文档入口(见 application.yml 的 springdoc 注释)。
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI userBaseOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("用户中心接口文档")
|
||||
.description("UserBase 用户中心 OpenAPI 文档(springdoc-openapi)")
|
||||
.version("0.0.1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 开放接口分组:/oapi/** 但排除 /oapi/internal/**(内部密钥分发端点,仅内网 + IP 白名单,不对外暴露契约)。
|
||||
* 导出快照:GET /v3/api-docs/oapi
|
||||
*/
|
||||
@Bean
|
||||
public GroupedOpenApi oapiGroup() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("oapi")
|
||||
.pathsToMatch("/oapi/**")
|
||||
.pathsToExclude("/oapi/internal/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 业务接口分组:/api/**(会话鉴权的面向前端接口,与开放接口契约隔离)。 */
|
||||
@Bean
|
||||
public GroupedOpenApi apiGroup() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("api")
|
||||
.pathsToMatch("/api/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 隐藏切面注入的登录用户参数,保持文档整洁。 */
|
||||
@Bean
|
||||
public GlobalOperationCustomizer hideAuthUserParam() {
|
||||
return (operation, handlerMethod) -> {
|
||||
if (operation.getParameters() != null) {
|
||||
operation.getParameters().removeIf(p -> "authUserWithDept".equals(p.getName()));
|
||||
}
|
||||
return operation;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.platform.appauth.AppSecretProvider;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* UserBase 作为「应用密钥权威源」的验签密钥提供方。<br>
|
||||
* <br>
|
||||
* 解析优先级:<br>
|
||||
* 1) 本库 application_info —— 覆盖全部「已颁发」应用,新增/重置密钥后即时生效;<br>
|
||||
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps} —— 平台自身默认密钥(如 应用中心 100616)
|
||||
* 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。<br>
|
||||
* <br>
|
||||
* 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。<br>
|
||||
* <br>
|
||||
* 这样 UserBase 作为颁发方不依赖 Redis 预热、也不局限于 trustedApps 写死的少量密钥,
|
||||
* 能够验签所有它已颁发的应用。
|
||||
*/
|
||||
public class UserBaseAppSecretProvider implements AppSecretProvider {
|
||||
|
||||
private final ApplicationInfoRepository repository;
|
||||
private final Map<String, String> trustedApps;
|
||||
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
private static final long TTL_MS = 60_000L;
|
||||
|
||||
public UserBaseAppSecretProvider(ApplicationInfoRepository repository, AppAuthProperties properties) {
|
||||
this.repository = repository;
|
||||
this.trustedApps = properties.getTrustedApps();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSecret(String appKey) {
|
||||
if (appKey == null) {
|
||||
return null;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry hit = cache.get(appKey);
|
||||
if (hit != null && now - hit.ts < TTL_MS) {
|
||||
return hit.secret;
|
||||
}
|
||||
String secret = null;
|
||||
try {
|
||||
Integer id = parse(appKey);
|
||||
if (id != null) {
|
||||
ApplicationInfo info = repository.findByAppKey(id);
|
||||
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||
secret = info.getAppSecret();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询异常不阻断,继续走配置兜底
|
||||
}
|
||||
if (secret == null) {
|
||||
secret = trustedApps.get(appKey);
|
||||
}
|
||||
cache.put(appKey, new CacheEntry(now, secret));
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动失效:立刻清除本地缓存中指定 appKey 的条目,密钥重置后调用 → 下一次 getSecret 直查 DB 拿新值,
|
||||
* 不再受最长 60s TTL 生效窗口限制。
|
||||
*/
|
||||
public void invalidate(String appKey) {
|
||||
if (appKey == null) {
|
||||
return;
|
||||
}
|
||||
cache.remove(appKey);
|
||||
}
|
||||
|
||||
/** 主动失效:立刻清空全部本地缓存(refreshAll / Redis 全量对账时调用)。 */
|
||||
public void invalidateAll() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private static Integer parse(String appKey) {
|
||||
try {
|
||||
return Integer.parseInt(appKey.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CacheEntry {
|
||||
final long ts;
|
||||
final String secret;
|
||||
|
||||
CacheEntry(long ts, String secret) {
|
||||
this.ts = ts;
|
||||
this.secret = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.appauth.LocalAppIdentityProvider;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* UserBase 自身作为「出站签名调用方」的身份提供方。<br><br>
|
||||
* 约定:<br>
|
||||
* 1) appKey 固定占用 100000(基石应用段),由 application_bootstrap.sql 初始化脚本幂等插入;<br>
|
||||
* 2) appSecret 从 application_info 表按 appKey=100000 读取,带 60s 本地缓存(密钥重设后 60s 内生效)。<br><br>
|
||||
* 显式覆盖 yh-app-auth-spring-boot-starter 默认的 PropertyLocalAppIdentityProvider(yml 不再需要
|
||||
* 配置 yh.app-auth.app-key / app-secret)。
|
||||
*/
|
||||
public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvider {
|
||||
|
||||
private static final String USER_CENTER_APP_KEY = "100000";
|
||||
private static final int USER_CENTER_APP_KEY_INT = 100000;
|
||||
private static final long TTL_MS = 60_000L;
|
||||
|
||||
private final ApplicationInfoRepository repository;
|
||||
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public UserBaseLocalAppIdentityProvider(ApplicationInfoRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAppKey() {
|
||||
return USER_CENTER_APP_KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAppSecret() {
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry hit = cache.get(USER_CENTER_APP_KEY);
|
||||
if (hit != null && now - hit.ts < TTL_MS) {
|
||||
return hit.secret;
|
||||
}
|
||||
String secret = null;
|
||||
try {
|
||||
ApplicationInfo info = repository.findByAppKey(USER_CENTER_APP_KEY_INT);
|
||||
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||
secret = info.getAppSecret();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
cache.put(USER_CENTER_APP_KEY, new CacheEntry(now, secret));
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动失效:立刻清除 UserBase 自身(appKey=100000)的本地缓存,密钥重置后调用 →
|
||||
* 下一次 getAppSecret 直查 DB 拿新值,不再受最长 60s TTL 生效窗口限制。
|
||||
*/
|
||||
public void invalidate() {
|
||||
cache.remove(USER_CENTER_APP_KEY);
|
||||
}
|
||||
|
||||
private static final class CacheEntry {
|
||||
final long ts;
|
||||
final String secret;
|
||||
|
||||
CacheEntry(long ts, String secret) {
|
||||
this.ts = ts;
|
||||
this.secret = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.auth.UserDetailsProvider;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
|
||||
/**
|
||||
* 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后,
|
||||
* 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。<br><br>
|
||||
*
|
||||
* 本类不再加 {@code @Component},改为由 {@link OpenApiAuthEnforceConfiguration}
|
||||
* 显式以 {@code @Bean @Primary} 注册(bean 名固定为 {@code userDetailsProvider}),
|
||||
* 原因:yh-auth-spring-boot-starter 新版 AutoConfiguration 处理顺序可能早于 UserBase 组件扫描,
|
||||
* 导致其默认注册的 {@code NoOpUserDetailsProvider}(直接抛 UnsupportedOperationException)
|
||||
* 抢先被 PathBasedAuthInterceptor 注入。显式 @Bean+@Primary 可稳定覆盖默认实现。
|
||||
*/
|
||||
public class UserBaseUserDetailsProvider implements UserDetailsProvider {
|
||||
|
||||
private final UserCenterService userCenterService;
|
||||
|
||||
public UserBaseUserDetailsProvider(UserCenterService userCenterService) {
|
||||
this.userCenterService = userCenterService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser loadByLoginName(String loginName) {
|
||||
if (loginName == null) {
|
||||
return null;
|
||||
}
|
||||
return userCenterService.getUserInfoWithDeptByAccount(loginName.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser loadByPhone(String phone) {
|
||||
if (phone == null) {
|
||||
return null;
|
||||
}
|
||||
return userCenterService.getUserInfoWithDeptByPhone(phone.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.userbase.constant;
|
||||
|
||||
/**
|
||||
* 「整应用授权」的 module_id 哨兵编码。<br>
|
||||
* <p>
|
||||
* 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
|
||||
* (owner_id, module_id) 两列,<b>没有 app_id</b>。当某个应用下没有任何 {@code app_module} 记录时
|
||||
* (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 <b>-appId</b> 作为该应用
|
||||
* 「整体已授权」的哨兵值:
|
||||
* <ul>
|
||||
* <li><b>不是 workaround</b>:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
|
||||
* 应用级通道,与真实模块不会冲突;</li>
|
||||
* <li>不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
|
||||
* {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;</li>
|
||||
* <li>权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
|
||||
* 哨兵值永远匹配不上,不会被误判成某个模块的权限。</li>
|
||||
* </ul>
|
||||
* 写入侧由 Service 内部把「入参 moduleId=0(整应用语义)」翻译成本编码,前端无需感知。
|
||||
*/
|
||||
public final class AppLevelModuleId {
|
||||
|
||||
private AppLevelModuleId() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用ID → 整应用授权的哨兵 module_id(-appId)。appId 为空时返回 null。
|
||||
*/
|
||||
public static Integer of(Integer appId) {
|
||||
return appId == null ? null : -appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 哨兵 module_id → 应用ID;传入真实模块 id(正数)或 null 时返回 null。
|
||||
*/
|
||||
public static Integer resolveAppId(Integer moduleId) {
|
||||
return (moduleId != null && moduleId < 0) ? -moduleId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为整应用授权的哨兵值。
|
||||
*/
|
||||
public static boolean isAppLevel(Integer moduleId) {
|
||||
return moduleId != null && moduleId < 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.userbase.constant;
|
||||
|
||||
/**
|
||||
* 应用性质(application_info.app_nature)。<br>
|
||||
* BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;<br>
|
||||
* BUSINESS=业务应用:门户可见,参与开通/授权。<br>
|
||||
* 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。
|
||||
*/
|
||||
public enum AppNature { BASE, BUSINESS;
|
||||
|
||||
/**
|
||||
* UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。<br>
|
||||
* 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。
|
||||
*/
|
||||
public static final int USER_CENTER_APP_KEY = 100000;
|
||||
|
||||
/**
|
||||
* 条件一:UserBase 自身(延续原「appKey > 100000」排除逻辑的取反语义,行为不变)。
|
||||
*/
|
||||
public static boolean isUserbaseSelf(Integer appKey) {
|
||||
return appKey != null && appKey <= USER_CENTER_APP_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件二:显式标记的基石应用(UserHome / 移动门户等)。
|
||||
*/
|
||||
public static boolean isMarkedBase(String appNature) {
|
||||
return BASE.name().equals(appNature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。<br>
|
||||
* 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态,
|
||||
* 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。
|
||||
*/
|
||||
public static boolean isBaseApp(Integer appKey, String appNature) {
|
||||
return isUserbaseSelf(appKey) || isMarkedBase(appNature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserVo;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleVo;
|
||||
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
|
||||
import com.yunhai.userbase.service.IAppRoleService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.SortDefault;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 应用业务角色管理(角色 CRUD + 角色模块授权 + 用户角色绑定)。<br>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/approle/")
|
||||
public class AppRoleController {
|
||||
|
||||
@Autowired
|
||||
private IAppRoleService appRoleService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/**
|
||||
* 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称
|
||||
*/
|
||||
@GetMapping("/RoleList")
|
||||
public BasePageResult<SysAppRoleVo> roleList(SysAppRoleQuery query, BasePageQuery pageQuery,
|
||||
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增角色:普通角色(allAppFlag=false,appId 必填,同应用下名称唯一);<br>
|
||||
* 全应用角色(allAppFlag=true,appId 为空,全应用角色之间名称唯一)。
|
||||
*/
|
||||
@OperationLog("新增角色")
|
||||
@PostMapping("RoleAdd")
|
||||
public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.add(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑角色:仅可改 name/remark/disabled;allAppFlag 与 appId 不允许修改。
|
||||
*/
|
||||
@OperationLog("编辑角色")
|
||||
@PostMapping("RoleEdit/{id}")
|
||||
public void roleEdit(@PathVariable Integer id,
|
||||
@RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.edit(id, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色:级联清理该角色的模块授权与用户绑定记录。
|
||||
*/
|
||||
@OperationLog("删除角色")
|
||||
@PostMapping("RoleDelete/{id}")
|
||||
public void roleDelete(@PathVariable Integer id) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色模块授权回显:返回该角色可授权的应用及模块树(已授权 checked=true)。<br>
|
||||
* 普通角色仅本应用;全应用角色返回全部应用(可跨应用授权)。
|
||||
*/
|
||||
@GetMapping("/RoleModuleTree")
|
||||
public List<AppRoleAuthzAppVo> roleModuleTree(@RequestParam Integer roleId) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
return appRoleService.findRoleModuleTree(roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色模块授权保存:全量覆盖式更新该角色的模块授权记录。
|
||||
*/
|
||||
@OperationLog("保存角色模块授权")
|
||||
@PostMapping("SaveRoleModules")
|
||||
public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.saveRoleModules(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询角色已绑定的用户列表(一行 = 一条「用户×组」绑定,含部门路径)。<br>
|
||||
* 路径查询时按 groupId 实时拼接;非超管按数据范围过滤行并裁剪路径(学校管理员只看到本校路径)。
|
||||
*/
|
||||
@GetMapping("/RoleUsers")
|
||||
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
return appRoleService.findRoleUsers(roleId, accessScopeService.scopeGroupIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色绑定用户保存(角色维度,增量添加「用户×组」绑定)。
|
||||
*/
|
||||
@OperationLog("保存角色用户绑定")
|
||||
@PostMapping("SaveRoleUsers")
|
||||
public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.saveRoleUsers(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户绑定角色保存(用户维度,按组覆盖:只覆盖该用户在指定组下的绑定)。
|
||||
*/
|
||||
@OperationLog("保存用户角色绑定")
|
||||
@PostMapping("SaveUserRoles")
|
||||
public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.saveUserRoles(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单条「用户×组×角色」绑定(精确删除一行,非全量重提)。<br>
|
||||
* 前端「用户管理 - 删除」调用此接口。
|
||||
*/
|
||||
@OperationLog("移除角色用户绑定")
|
||||
@PostMapping("RemoveRoleUser")
|
||||
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId,
|
||||
@RequestParam Integer groupId) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.removeRoleUser(roleId, userId, groupId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.enums.AccessLevelEnum;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
import com.yunhai.userbase.model.application.PushSettingRequest;
|
||||
import com.yunhai.userbase.model.application.PushSettingVo;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleVo;
|
||||
import com.yunhai.userbase.service.IAppCategoryService;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import com.yunhai.userbase.service.IAppModuleService;
|
||||
import com.yunhai.userbase.service.impl.SchoolAppOverrideService;
|
||||
import com.yunhai.userbase.service.impl.SignKeyCacheService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScope;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用管理 + 学校私有覆盖(管理端权限分级改造 §3.4)。<br>
|
||||
* 访问矩阵:<br>
|
||||
* - 仅超管:新增/删除应用、密钥、推送设置、模块管理、新增/删除类型;<br>
|
||||
* - 超管+局端:AppAuthzList(应用开通页数据源);<br>
|
||||
* - 学校管理员:AppList/AppTypeList/AppSortList/EditApp/AppSort/EditType/AppTypeSort 走覆盖表
|
||||
* (仅本校已开通应用,名称/描述/图标/排序,均只对该校生效);<br>
|
||||
* - 局端不开放应用维护(局端的应用动作是「开通」,在应用开通页),落到写接口一律 requireSuper。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/app/")
|
||||
public class AppSettingsController {
|
||||
|
||||
@Autowired
|
||||
private IAppCategoryService appCategoryService;
|
||||
|
||||
@Autowired
|
||||
private IApplicationInfoService applicationInfoService;
|
||||
|
||||
@Autowired
|
||||
private IAppModuleService appModuleService;
|
||||
|
||||
@Autowired
|
||||
private SignKeyCacheService signKeyCacheService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
@Autowired
|
||||
private SchoolAppOverrideService schoolAppOverrideService;
|
||||
|
||||
/** 当前 scope 是否学校管理员(覆盖表通道) */
|
||||
private boolean isSchoolLevel() {
|
||||
return accessScopeService.resolveCurrentScope().getLevel() == AccessLevelEnum.SCHOOL;
|
||||
}
|
||||
|
||||
@OperationLog("新增应用类型")
|
||||
@PostMapping("AddType")
|
||||
public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) {
|
||||
accessScopeService.requireSuper();
|
||||
appCategoryService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("编辑应用类型")
|
||||
@PostMapping("EditType/{id}")
|
||||
public void editType(@PathVariable Integer id,
|
||||
@RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
if (isSchoolLevel()) {
|
||||
// 学校:仅改名,写分类覆盖表(该校私有)
|
||||
schoolAppOverrideService.editCategoryForSchool(id, request.getName(),
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
return;
|
||||
}
|
||||
// 主表维护仅超管(局端不开放应用管理)
|
||||
accessScopeService.requireSuper();
|
||||
appCategoryService.edit(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("删除应用类型")
|
||||
@PostMapping("DeleteType/{id}")
|
||||
public void deleteType(@PathVariable Integer id) {
|
||||
accessScopeService.requireSuper();
|
||||
// 级联清理该类型在所有学校的覆盖行
|
||||
schoolAppOverrideService.deleteCategoryOverrides(id);
|
||||
appCategoryService.delete(id);
|
||||
}
|
||||
|
||||
@GetMapping("/AppTypeList")
|
||||
public List<AppCategoryVo> appTypeList(AppCategoryQuery query,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
List<AppCategoryVo> base = appCategoryService.list(query);
|
||||
if (isSchoolLevel()) {
|
||||
// 合并学校私有改名/排序
|
||||
return schoolAppOverrideService.mergeCategoryList(base,
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
@OperationLog("应用类型排序")
|
||||
@PostMapping("/AppTypeSort")
|
||||
public void appTypeSort(@RequestBody List<Integer> ids,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
if (isSchoolLevel()) {
|
||||
schoolAppOverrideService.saveCategorySortForSchool(ids,
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
return;
|
||||
}
|
||||
accessScopeService.requireSuper();
|
||||
appCategoryService.sort(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。<br>
|
||||
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。<br>
|
||||
* 应用开通页数据源,仅超管+局端。
|
||||
*/
|
||||
@GetMapping("/AppAuthzList")
|
||||
public List<AppAuthzCategoryVo> appAuthzList() {
|
||||
accessScopeService.requireSuperOrBureau();
|
||||
return appCategoryService.listWithApps();
|
||||
}
|
||||
|
||||
@OperationLog("新增应用")
|
||||
@PostMapping("AddApp")
|
||||
public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) {
|
||||
accessScopeService.requireSuper();
|
||||
applicationInfoService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("编辑应用")
|
||||
@PostMapping("EditApp/{id}")
|
||||
public void editApp(@PathVariable Integer id,
|
||||
@RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
if (isSchoolLevel()) {
|
||||
// 学校:只接受 名称/描述/图标 三字段 upsert 覆盖表,appKey/回调/推送等一律不可动
|
||||
schoolAppOverrideService.editAppForSchool(id, request,
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
return;
|
||||
}
|
||||
accessScopeService.requireSuper();
|
||||
applicationInfoService.edit(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("删除应用")
|
||||
@PostMapping("DeleteApp/{id}")
|
||||
public void deleteApp(@PathVariable Integer id) {
|
||||
accessScopeService.requireSuper();
|
||||
// 级联清理该应用在所有学校的覆盖行
|
||||
schoolAppOverrideService.deleteAppOverrides(id);
|
||||
applicationInfoService.delete(id);
|
||||
}
|
||||
|
||||
@OperationLog("重置应用密钥")
|
||||
@PostMapping("ResetAppSecret/{id}")
|
||||
public String resetAppSecret(@PathVariable Integer id) {
|
||||
accessScopeService.requireSuper();
|
||||
applicationInfoService.resetAppSecret(id);
|
||||
ApplicationInfo info= applicationInfoService.getById(id);
|
||||
return info.getAppSecret();
|
||||
}
|
||||
|
||||
@OperationLog("全量刷新应用签名密钥到Redis")
|
||||
@PostMapping("RefreshSignKeys")
|
||||
public Map<String, Object> refreshSignKeys() {
|
||||
accessScopeService.requireSuper();
|
||||
int count = signKeyCacheService.refreshAll();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("refreshed", count);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/AppList")
|
||||
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
// 排序必须带唯一兜底键:同键行 MySQL 不保证稳定序,加删一行即整组洗牌(应用列表乱序的根因)。
|
||||
// 全部(未选类别)= id 升序——sort_no 是「类型内」序号,跨类型打平无全局意义;
|
||||
// 选中类别 = 未排序(NULL)沉底 + sortNo 升序 + id 兜底——由 Specification 内嵌 orderBy 实现
|
||||
// (NULL 沉底须 case 表达式,Sort 只走属性解析表达不了;此处传 unsorted,否则 Pageable 排序会覆盖 spec 的 orderBy)
|
||||
Sort sort = query.getCategoryId() == null
|
||||
? Sort.by(Sort.Direction.ASC, "id")
|
||||
: Sort.unsorted();
|
||||
if (isSchoolLevel()) {
|
||||
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
|
||||
AccessScope scope = accessScopeService.resolveCurrentScope();
|
||||
return PageUtils.toResult(
|
||||
schoolAppOverrideService.listSchoolApps(query, PageUtils.toPageable(pageQuery, sort), scope, schoolId));
|
||||
}
|
||||
return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort)));
|
||||
}
|
||||
|
||||
@OperationLog("应用排序")
|
||||
@PostMapping("/AppSort")
|
||||
public void appSort(@RequestBody List<Integer> ids,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
if (isSchoolLevel()) {
|
||||
// 学校:写覆盖表 sort_no(整校快照,仅该校生效)
|
||||
schoolAppOverrideService.saveAppSortForSchool(ids,
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
return;
|
||||
}
|
||||
accessScopeService.requireSuper();
|
||||
applicationInfoService.sort(ids);
|
||||
}
|
||||
|
||||
@GetMapping("/AppSortList")
|
||||
public List<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
if (isSchoolLevel()) {
|
||||
// 学校:仅返回本校已开通应用(拖拽排序数据源,override 生效排序)
|
||||
return schoolAppOverrideService.schoolSortList(categoryId,
|
||||
accessScopeService.resolveCurrentScope(), schoolId);
|
||||
}
|
||||
return applicationInfoService.findListByCategoryId(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可管理学校列表(多校管理员应用管理页切换下拉用):{id, name}。<br>
|
||||
* 仅 SCHOOL 级返回数据;SUPER/BUREAU 返回空数组(前端不渲染下拉)。
|
||||
*/
|
||||
@GetMapping("/SchoolList")
|
||||
public List<Map<String, Object>> schoolList() {
|
||||
accessScopeService.requireAdmin();
|
||||
AccessScope scope = accessScopeService.resolveCurrentScope();
|
||||
if (scope.getLevel() != AccessLevelEnum.SCHOOL) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return schoolAppOverrideService.manageableSchools(scope);
|
||||
}
|
||||
|
||||
@OperationLog("新增模块")
|
||||
@PostMapping("AddModule")
|
||||
public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) {
|
||||
accessScopeService.requireSuper();
|
||||
appModuleService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("编辑模块")
|
||||
@PostMapping("EditModule/{id}")
|
||||
public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) {
|
||||
accessScopeService.requireSuper();
|
||||
appModuleService.edit(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("删除模块")
|
||||
@PostMapping("DeleteModule/{id}")
|
||||
public void deleteModule(@PathVariable Integer id) {
|
||||
accessScopeService.requireSuper();
|
||||
appModuleService.delete(id);
|
||||
}
|
||||
|
||||
@GetMapping("/ModuleList")
|
||||
public List<AppModuleVo> moduleList(AppModuleQuery query) {
|
||||
accessScopeService.requireSuper();
|
||||
return appModuleService.list(query);
|
||||
}
|
||||
|
||||
@OperationLog("保存应用推送设置")
|
||||
@PostMapping("SavePushSetting/{id}")
|
||||
public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) {
|
||||
accessScopeService.requireSuper();
|
||||
applicationInfoService.savePushSetting(id, request);
|
||||
}
|
||||
|
||||
@GetMapping("GetPushSetting/{id}")
|
||||
public PushSettingVo getPushSetting(@PathVariable Integer id) {
|
||||
accessScopeService.requireSuper();
|
||||
return applicationInfoService.getPushSetting(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
import com.yunhai.platform.apistd.ApiResponse;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.enums.*;
|
||||
import com.yunhai.userbase.model.other.UserScopeVo;
|
||||
import com.yunhai.userbase.service.resolve.AccessScope;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.*;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import lombok.var;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH +"/base/")
|
||||
public class BaseController {
|
||||
|
||||
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/**
|
||||
* 一般hashMap初始化大小
|
||||
*/
|
||||
public static final int initialCapacity = 6;
|
||||
|
||||
/**
|
||||
* 通用上传允许的子目录(与 MyWebAppConfigurer.addResourceHandlers 的资源映射一一对应)。<br>
|
||||
* 注:html 因 FileUtil 的危险后缀黑名单被禁止通过此接口上传,故未列入,避免存储可被浏览器执行的 HTML 引发 XSS。
|
||||
*/
|
||||
private static final Set<String> ALLOWED_UPLOAD_TYPES = new HashSet<>(Arrays.asList("image", "file", "icon", "word"));
|
||||
|
||||
@GetMapping("GetUserInfo")
|
||||
public Object getUserInfo(BoaUser authUserWithDept){
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户的管理端访问范围(四级访问控制)。<br>
|
||||
* 不复用 GetUserInfo 的原因:BoaUser 是 yh-utils-core 外部类不可加字段,且 scope 规则
|
||||
* (身份行 × 组类型)由后端单点计算,前端只消费结果、不重复实现判定逻辑,避免双端规则漂移。<br>
|
||||
* 在 NONE 拦截器白名单内(登录即可访问),NONE 用户靠它拿到 level=4 后前端整体渲染「暂无权限」。
|
||||
*/
|
||||
@GetMapping("GetUserScope")
|
||||
public UserScopeVo getUserScope(BoaUser authUserWithDept) {
|
||||
AccessScope scope = accessScopeService.resolveCurrentScope();
|
||||
UserScopeVo vo = new UserScopeVo();
|
||||
vo.setLevel(scope.getLevel().getKey());
|
||||
vo.setBureauGroupId(scope.getBureauGroupId());
|
||||
vo.setSchoolGroupIds(scope.schoolGroupIds().isEmpty() ? null : scope.schoolGroupIds());
|
||||
return vo;
|
||||
}
|
||||
|
||||
//返回系统枚举2
|
||||
@GetMapping("GetSysDict")
|
||||
public Map getSysDict(BoaUser authUserWithDept){
|
||||
Map<String, List> resultMap = new HashMap<>(7);
|
||||
resultMap.put("grouptype", IEnum.toSimpleMapList(GroupTypeEnum.class, "k", "v"));
|
||||
resultMap.put("gender", IEnum.toSimpleMapList(GenderEnum.class, "k", "v"));
|
||||
resultMap.put("client", IEnum.toSimpleMapList(ClientEnum.class, "k", "v"));
|
||||
resultMap.put("integ", IEnum.toSimpleMapList(IntegEnum.class, "k", "v"));
|
||||
resultMap.put("status", IEnum.toSimpleMapList(StatusEnum.class, "k", "v"));
|
||||
resultMap.put("sysrole", IEnum.toSimpleMapList(SysRoleEnum.class, "k", "v"));
|
||||
resultMap.put("mode", IEnum.toSimpleMapList(SysModeEnum.class, "k", "v"));
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动管理 Redis Hash 的示例(@Cacheable 注解无法满足的场景)。<br>
|
||||
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
|
||||
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
|
||||
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<br>
|
||||
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。<br>
|
||||
* <b>缓存统一管理后本端点仅为 Hash 结构用法演示</b>(《userbase缓存决策方案.md》存量收敛:
|
||||
* 业务缓存一律走 Spring Cache + CacheNames 注册表,RedisUtil 已 @Deprecated,勿以本端点为新缓存范本)。
|
||||
*
|
||||
* @return 本次演示各步骤的结果快照
|
||||
*/
|
||||
@GetMapping("ManualHashCacheDemo")
|
||||
public Map<String, Object> manualHashCacheDemo() {
|
||||
Map<String, Object> result = new HashMap<>(8);
|
||||
String hashKey = "demo:user:1001";
|
||||
// 1) 写入整张 hash(带 TTL):适合一次性缓存一个对象的多个属性
|
||||
Map<String, Object> profile = new HashMap<>(4);
|
||||
profile.put("name", "yunhai");
|
||||
profile.put("age", 18);
|
||||
profile.put("role", "admin");
|
||||
redisUtil.hmset(hashKey, profile, 300);
|
||||
// 2) 只更新其中一个字段,无需整体覆盖
|
||||
redisUtil.hset(hashKey, "role", "superadmin");
|
||||
// 3) 读取单个字段
|
||||
result.put("roleAfterUpdate", redisUtil.hget(hashKey, "role"));
|
||||
// 4) 读取整张 hash
|
||||
result.put("wholeHash", redisUtil.hmget(hashKey));
|
||||
// 5) 判断字段是否存在
|
||||
result.put("hasAgeField", redisUtil.hHasKey(hashKey, "age"));
|
||||
// 6) Cache-Aside(复用下方私有方法):字段不存在时回源并写回 Hash
|
||||
Object nickname = getHashFieldWithFallback(hashKey, "nickname", () -> "yunhai-nick");
|
||||
result.put("nickname", nickname);
|
||||
// 7) 删除某个字段
|
||||
redisUtil.hdel(hashKey, "age");
|
||||
result.put("afterDelAge", redisUtil.hmget(hashKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@GetMapping("RedirectTo/{id}")
|
||||
public RedirectView RedirectTo(BoaUser authUserWithDept, @PathVariable(name = "id") String id) {
|
||||
String url= OtherUtil.hexToString(id);
|
||||
return new RedirectView(url,true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件上传接口(一个接口覆盖所有类型,无需按类型拆分)。<br>
|
||||
* type 决定存储子目录,与 MyWebAppConfigurer.addResourceHandlers 的 /upload/{type}/** 映射一一对应,<br>
|
||||
* 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。<br>
|
||||
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
|
||||
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<br>
|
||||
* 返回 {@link ApiResponse}({code,msg,data},data=文件 URL):
|
||||
* String 返回类型会走 StringHttpMessageConverter,被 ApiResponseAdvice 跳过包装,
|
||||
* 前端 Upload 组件拿到裸字符串无法按统一结构取 data。
|
||||
*
|
||||
* @param file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置)
|
||||
* @param type 存储子目录类型,默认 image
|
||||
* @return 文件对外可访问的 Web URL
|
||||
*/
|
||||
@PostMapping("UploadFile")
|
||||
public ApiResponse<String> uploadFile(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "type", defaultValue = "image") String type) {
|
||||
if (!ALLOWED_UPLOAD_TYPES.contains(type)) {
|
||||
throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word");
|
||||
}
|
||||
// FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接
|
||||
return ApiResponse.success(FileUtil.upload(file, type));
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有方法(可复用):演示 Hash 结构的 Cache-Aside(缓存旁路)模式。<br>
|
||||
* 先从 Redis Hash 读字段;读不到则回源(此处用 fallback 模拟数据库查询),再把结果写回 Hash。<br>
|
||||
*
|
||||
* @param hashKey hash 键
|
||||
* @param field 字段名
|
||||
* @param fallback 回源加载器(真实场景替换为查数据库/远程调用)
|
||||
* @return 字段值
|
||||
*/
|
||||
private Object getHashFieldWithFallback(String hashKey, String field, Supplier<Object> fallback) {
|
||||
if (redisUtil.hHasKey(hashKey, field)) {
|
||||
return redisUtil.hget(hashKey, field);
|
||||
}
|
||||
Object value = fallback.get();
|
||||
if (value != null) {
|
||||
redisUtil.hset(hashKey, field, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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 com.yunhai.userbase.service.impl.IdentityService;
|
||||
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 /api/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>
|
||||
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
|
||||
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
|
||||
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</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;
|
||||
|
||||
/** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
/** 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);
|
||||
result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount());
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
|
||||
import com.yunhai.userbase.service.ICustomDeptCategoryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/10/21
|
||||
* @description 自定义部门分类管理(CUSTOM 侧,部门管理页左侧平铺列表的数据源)。<br>
|
||||
* 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式;
|
||||
* 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。<br>
|
||||
* 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/deptcategory/")
|
||||
public class CustomDeptCategoryController {
|
||||
|
||||
@Autowired
|
||||
private ICustomDeptCategoryService customDeptCategoryService;
|
||||
|
||||
@Operation(operationId = "customDeptCategoryList")
|
||||
@GetMapping("List")
|
||||
public List<CustomDeptCategoryVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
return customDeptCategoryService.list(schoolId);
|
||||
}
|
||||
|
||||
@OperationLog("新增自定义部门分类")
|
||||
@Operation(operationId = "customDeptCategoryAdd")
|
||||
@PostMapping("Add")
|
||||
public CustomDeptCategoryVo add(@RequestBody @Validated CustomDeptCategoryRequest request) {
|
||||
return customDeptCategoryService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("重命名自定义部门分类")
|
||||
@Operation(operationId = "customDeptCategoryEdit")
|
||||
@PostMapping("Edit/{id}")
|
||||
public CustomDeptCategoryVo edit(@PathVariable Integer id,
|
||||
@RequestBody @Validated CustomDeptCategoryRequest request) {
|
||||
return customDeptCategoryService.rename(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("自定义部门分类排序")
|
||||
@Operation(operationId = "customDeptCategorySort")
|
||||
@PostMapping("Sort")
|
||||
public void sort(@RequestBody @Validated CustomDeptCategorySortRequest request) {
|
||||
customDeptCategoryService.sort(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删分类:分类下仍有有效部门时明确拒绝(A14),不做任何级联删除。
|
||||
*/
|
||||
@OperationLog("删除自定义部门分类")
|
||||
@Operation(operationId = "customDeptCategoryDelete")
|
||||
@PostMapping("Delete/{id}")
|
||||
public void delete(@PathVariable Integer id) {
|
||||
customDeptCategoryService.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
|
||||
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
|
||||
import com.yunhai.userbase.service.ICustomDepartmentService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/10/21
|
||||
* @description 自定义部门管理(CUSTOM 侧:部门本体 + 启停 + 软删 + 成员)。<br>
|
||||
* 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径,
|
||||
* 也不提供删除 SYSTEM 部门的入口(任务书 D1)。<br>
|
||||
* 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/customdept/")
|
||||
public class CustomDeptController {
|
||||
|
||||
@Autowired
|
||||
private ICustomDepartmentService customDepartmentService;
|
||||
|
||||
@Operation(operationId = "customDeptList")
|
||||
@GetMapping("List")
|
||||
public List<CustomDepartmentVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId,
|
||||
@RequestParam("categoryId") Integer categoryId) {
|
||||
return customDepartmentService.list(schoolId, categoryId);
|
||||
}
|
||||
|
||||
@OperationLog("新增自定义部门")
|
||||
@Operation(operationId = "customDeptAdd")
|
||||
@PostMapping("Add")
|
||||
public CustomDepartmentVo add(@RequestBody @Validated CustomDepartmentRequest request) {
|
||||
return customDepartmentService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("重命名自定义部门")
|
||||
@Operation(operationId = "customDeptEdit")
|
||||
@PostMapping("Edit/{id}")
|
||||
public CustomDepartmentVo edit(@PathVariable Integer id,
|
||||
@RequestBody @Validated CustomDepartmentRequest request) {
|
||||
return customDepartmentService.rename(id, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动至本校其他分类:目标分类跨校直接拒绝。
|
||||
*/
|
||||
@OperationLog("移动自定义部门")
|
||||
@Operation(operationId = "customDeptMove")
|
||||
@PostMapping("Move/{id}")
|
||||
public CustomDepartmentVo move(@PathVariable Integer id,
|
||||
@RequestBody @Validated CustomDepartmentMoveRequest request) {
|
||||
return customDepartmentService.move(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("自定义部门排序")
|
||||
@Operation(operationId = "customDeptSort")
|
||||
@PostMapping("Sort")
|
||||
public void sort(@RequestBody @Validated CustomDepartmentSortRequest request) {
|
||||
customDepartmentService.sort(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用自定义部门:单列 UPDATE enabled=0。<br>
|
||||
* 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 SYSTEM 同口径。
|
||||
*/
|
||||
@OperationLog("停用自定义部门")
|
||||
@Operation(operationId = "customDeptDisable")
|
||||
@PostMapping("Disable/{id}")
|
||||
public void disable(@PathVariable Integer id) {
|
||||
customDepartmentService.changeEnabled(id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用自定义部门:单列 UPDATE enabled=1,原 id 与成员保持不变(A11)。
|
||||
*/
|
||||
@OperationLog("启用自定义部门")
|
||||
@Operation(operationId = "customDeptEnable")
|
||||
@PostMapping("Enable/{id}")
|
||||
public void enable(@PathVariable Integer id) {
|
||||
customDepartmentService.changeEnabled(id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删自定义部门:默认列表不再出现,历史引用仍可解析(A12);成员关系保留在库、由查询侧过滤。
|
||||
*/
|
||||
@OperationLog("删除自定义部门")
|
||||
@Operation(operationId = "customDeptDelete")
|
||||
@PostMapping("Delete/{id}")
|
||||
public void delete(@PathVariable Integer id) {
|
||||
customDepartmentService.softDelete(id);
|
||||
}
|
||||
|
||||
@Operation(operationId = "customDeptMembers")
|
||||
@GetMapping("Members/{id}")
|
||||
public List<CustomDepartmentMemberVo> members(@PathVariable Integer id) {
|
||||
return customDepartmentService.members(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。<br>
|
||||
* 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。
|
||||
*/
|
||||
@Operation(operationId = "customDeptCandidateUsers")
|
||||
@GetMapping("CandidateUsers/{id}")
|
||||
public List<CustomDepartmentMemberVo> candidateUsers(@PathVariable Integer id,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
return customDepartmentService.candidateUsers(id, keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,重复添加按幂等跳过(A07/A08)。
|
||||
*/
|
||||
@OperationLog("添加自定义部门成员")
|
||||
@Operation(operationId = "customDeptAddMembers")
|
||||
@PostMapping("AddMembers/{id}")
|
||||
public void addMembers(@PathVariable Integer id,
|
||||
@RequestBody @Validated CustomDepartmentMemberRequest request) {
|
||||
customDepartmentService.addMembers(id, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除成员:物理删关系行,不残留有效成员。
|
||||
*/
|
||||
@OperationLog("移除自定义部门成员")
|
||||
@Operation(operationId = "customDeptRemoveMembers")
|
||||
@PostMapping("RemoveMembers/{id}")
|
||||
public void removeMembers(@PathVariable Integer id,
|
||||
@RequestBody @Validated CustomDepartmentMemberRequest request) {
|
||||
customDepartmentService.removeMembers(id, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupAppVo;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupAuthzCategoryVo;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem;
|
||||
import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest;
|
||||
import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest;
|
||||
import com.yunhai.userbase.model.groupauthz.UserGroupNodeVo;
|
||||
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo;
|
||||
import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo;
|
||||
import com.yunhai.userbase.model.groupauthz.UserRoleVo;
|
||||
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import javax.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/23
|
||||
* @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
|
||||
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
|
||||
* 访问控制:应用开通类 = 超管+局端;组授权/权限一览 = 管理员(数据范围内组);
|
||||
* HasModulePerm 保留登录即可(判定接口,自身已有超管直通)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/groupauthz/")
|
||||
public class GroupAuthzController {
|
||||
|
||||
@Autowired
|
||||
private IGroupAuthzService groupAuthzService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/**
|
||||
* 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。
|
||||
*/
|
||||
@GetMapping("/GroupAppList")
|
||||
public List<GroupAppVo> groupAppList(@RequestParam Integer groupId) {
|
||||
// 应用开通动作仅超管+局端(学校无此菜单);局端目标组须在本区域范围内
|
||||
accessScopeService.requireSuperOrBureau();
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.listGroupApps(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能1保存:全量覆盖式更新该组的应用开通记录。
|
||||
*/
|
||||
@OperationLog("保存组应用开通")
|
||||
@PostMapping("AssignApps")
|
||||
public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) {
|
||||
// 局端目标组须在本区域范围内(验收矩阵:局端仅可开通本区域学校)
|
||||
accessScopeService.requireSuperOrBureau();
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
groupAuthzService.assignAppsToGroup(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能2数据加载:返回该组已开通的应用及其模块权限树(含勾选与子组继承回显)。
|
||||
*/
|
||||
@GetMapping("/GroupModuleTree")
|
||||
public List<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.findGroupAppsWithModules(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能2数据加载(按应用分类分组):供组授权页面按分类面板展示,<br>
|
||||
* 返回全部分类及该组已开通的应用模块权限树。
|
||||
*/
|
||||
@GetMapping("/GroupModuleTreeByCategory")
|
||||
public List<GroupAuthzCategoryVo> groupModuleTreeByCategory(@RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.findGroupAppsWithModulesByCategory(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能2保存:全量覆盖式更新该组的模块权限(含子组继承开关)。
|
||||
*/
|
||||
@OperationLog("保存组模块权限")
|
||||
@PostMapping("SaveModulePerms")
|
||||
public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
groupAuthzService.saveModulePermissions(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限判定:判断用户对某模块是否有权限(供登录/拦截器/联调验证使用)。
|
||||
*/
|
||||
@GetMapping("/HasModulePerm")
|
||||
public boolean hasModulePerm(@RequestParam Integer userId, @RequestParam Integer moduleId) {
|
||||
return groupAuthzService.hasModulePermission(userId, moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:根据组ID获取该组下的直接用户列表(不含子组,不分页)。
|
||||
*/
|
||||
@GetMapping("/GroupUsers")
|
||||
public List<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.findGroupUsers(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
|
||||
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。<br>
|
||||
* 非超管结果按数据范围过滤:仅返回归属组与范围有交集的用户。
|
||||
*/
|
||||
@GetMapping("/SearchUsers")
|
||||
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
|
||||
return groupAuthzService.searchUsers(keyword, accessScopeService.scopeGroupIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
|
||||
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
|
||||
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。<br>
|
||||
* 非超管:目标用户归属组与数据范围无交集时返回空集合(只看得到本校/本区域用户)。
|
||||
*/
|
||||
@GetMapping("/UserPermissionTree")
|
||||
public List<UserPermissionCategoryVo> userPermissionTree(@RequestParam Integer userId,
|
||||
@RequestParam(required = false) Integer groupId) {
|
||||
// 组上下文模式:显式传入的 groupId 也须在数据范围内(防借在范围用户推演范围外组的权限数据)
|
||||
if (groupId != null) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
}
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()
|
||||
&& !accessScopeService.userIntersectsScope(userId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserPermissionTree(groupId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
|
||||
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。<br>
|
||||
* groupId 可选:传入时只返回该组下的绑定(编辑弹窗按所在组回显角色用);为空返回全部绑定聚合去重。
|
||||
*/
|
||||
@GetMapping("/UserRoles")
|
||||
public List<UserRoleVo> userRoles(@RequestParam Integer userId,
|
||||
@RequestParam(required = false) Integer groupId) {
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()
|
||||
&& !accessScopeService.userIntersectsScope(userId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserRoles(userId, groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
|
||||
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
|
||||
* groupId 可选(上下文组):传入且用户确属该组时只画该组的祖先链——中间列表每行是「用户×组」对,
|
||||
* 所属部门只展示行所在组的链路,而非用户全部归属组。<br>
|
||||
* 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝——
|
||||
* 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。
|
||||
*/
|
||||
@GetMapping("/UserGroupTree")
|
||||
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId,
|
||||
@RequestParam(required = false) Integer groupId) {
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()
|
||||
&& !accessScopeService.userIntersectsScope(userId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds(), groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:查询<b>组本身</b>的有效已授权应用模块树(点击「所属部门」树节点联动右侧「已授权应用」面板)。<br>
|
||||
* 判定口径与组授权/权限一览的组侧一致(开通池=本组+祖先并集、就近原则、子组继承闸门、学校截止),
|
||||
* 不并入用户角色授权;目标组须在当前管理端数据范围内。
|
||||
*/
|
||||
@GetMapping("/GroupPermissionTree")
|
||||
public List<UserPermissionCategoryVo> groupPermissionTree(@RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.findGroupPermissionTree(groupId);
|
||||
}
|
||||
|
||||
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
|
||||
|
||||
/**
|
||||
* 功能3回显:点击左侧树节点后,返回该组「已开通的应用ID列表」,供右侧 AppSettings.appAuthzList 回显勾选状态。<br>
|
||||
* <b>交互</b>:左树节点 → 右 appAuthzList(全量应用)→ 本接口拿到已选 id → 前端把对应多选框置为勾选。<br>
|
||||
* <b>实现</b>:应用开通不继承,直接查 sys_group_app_rel(group_id=nodeId) 返回 app_id 列表(详见 service.findAuthorizedAppIds)。<br>
|
||||
* 与 GroupAppList(返回全量应用+checked)互为补充:本接口更轻量,只给 id 集合。
|
||||
*/
|
||||
@GetMapping("/GetSchoolAppId")
|
||||
public List<Integer> getSchoolAppId(@RequestParam Integer nodeId) {
|
||||
// 应用开通动作仅超管+局端;局端目标节点须在本区域范围内
|
||||
accessScopeService.requireSuperOrBureau();
|
||||
accessScopeService.requireGroupInScope(nodeId);
|
||||
return groupAuthzService.findAuthorizedAppIds(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能3保存(应用维):点击应用多选框触发,对该组做应用开通/取消。<br>
|
||||
* <b>入参(请求体,JSON)</b>:{@link SchoolAppAuthzRequest} —— schid=组ID(nodeId);appid=单个应用ID(一次只传一个,逐次点击);mode=操作模式。<br>
|
||||
* <b>mode 语义(SysModeEnum)</b>:0=追加(开通应用) / 2=删除(取消开通);1=覆盖 不适用,会被拒绝。<br>
|
||||
* <b>实现</b>:应用开通不继承,仅操作本组;删除时同步清理该组在该应用下模块的权限残留(详见 service.setSchoolApps)。<br>
|
||||
* <b>注意</b>:appid 为单个 Integer,后端不再做多值/逗号解析;每次点击提交一次请求。
|
||||
*/
|
||||
@OperationLog("设置组应用开通")
|
||||
@PostMapping("SetSchoolApp")
|
||||
public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) {
|
||||
// 应用开通动作仅超管+局端;局端目标学校须在本区域范围内
|
||||
accessScopeService.requireSuperOrBureau();
|
||||
accessScopeService.requireGroupInScope(request.getSchid());
|
||||
groupAuthzService.setSchoolApps(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能4保存(模块维):前端「组授权」面板逐条勾选/取消时提交,对 (组,应用,模块) 做增量授权/取消。<br>
|
||||
* <b>入参</b>:请求体为数组,元素见 {@link GroupModuleAuthzItem}:{groupId, appId, moduleId, mode, isAll}。<br>
|
||||
* <b>mode</b>:0=追加 / 2=删除(1=覆盖不适用)。<br>
|
||||
* <b>isAll</b>:是否对子组继承,映射 sys_group_module_perm.inherit_flag(1=子孙组自动继承 / 0=仅本组)。<br>
|
||||
* <b>moduleId=0</b>:表示「该应用下全部模块」,后端展开为应用下每个真实模块各写/删一条。<br>
|
||||
* <b>实现</b>:逐条处理、顺序即执行顺序;追加前要求该组已开通该应用;越权写别的应用模块会被拒绝(详见 service.setGroupModulePerms)。<br>
|
||||
* 与现有 SaveModulePerms(全量覆盖)共存:本接口是单条增量,二者操作同一张表、互不冲突。
|
||||
*/
|
||||
@OperationLog("设置组模块权限")
|
||||
@PostMapping("SetGroupModule")
|
||||
public void setGroupModule(@RequestBody @Valid List<GroupModuleAuthzItem> items) {
|
||||
// 增量授权逐条校验目标组均在数据范围内
|
||||
accessScopeService.requireGroupsInScope(
|
||||
items == null ? Collections.emptyList()
|
||||
: items.stream().map(GroupModuleAuthzItem::getGroupId).collect(Collectors.toList()));
|
||||
groupAuthzService.setGroupModulePerms(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.push.PushRecordListVo;
|
||||
import com.yunhai.userbase.model.push.PushRecordQuery;
|
||||
import com.yunhai.userbase.model.push.PushTestRequest;
|
||||
import com.yunhai.userbase.service.push.PushDispatcher;
|
||||
import com.yunhai.userbase.service.push.PushRecordService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.SortDefault;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 推送订阅控制器:手动触发推送(组/用户全量同步)+ 推送日志记录 / 重试。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/14
|
||||
*/
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping(value = Config.API_PATH + "/push/")
|
||||
public class PushSubscriptionController {
|
||||
|
||||
@Autowired
|
||||
private PushDispatcher pushDispatcher;
|
||||
@Autowired
|
||||
private PushRecordService pushRecordService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/** 手动触发一次推送(投递 MQ),供应用推送设置的组/用户全量同步等场景调用。 */
|
||||
@PostMapping("Test")
|
||||
public Map<String, Object> test(@RequestBody PushTestRequest request) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("sent", true);
|
||||
result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 推送日志列表(不含 payload,避免全量推送数据量过大;payload 经 Download 接口单独获取)。 */
|
||||
@GetMapping("RecordList")
|
||||
public BasePageResult<PushRecordListVo> recordList(PushRecordQuery query, BasePageQuery pageQuery,
|
||||
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort)));
|
||||
}
|
||||
|
||||
/** 手动重试一条失败的推送记录。 */
|
||||
@PostMapping("Retry/{id}")
|
||||
public void retry(@PathVariable Long id) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
pushRecordService.retry(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载某条推送记录的请求体原文(payload 文本文件)。<br>
|
||||
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
|
||||
* 若该记录未记录 payload,返回一段说明文本文件而非 404,便于排查。
|
||||
*/
|
||||
@GetMapping("/Download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
String data = pushRecordService.getPayload(id);
|
||||
if (data == null) {
|
||||
data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。";
|
||||
}
|
||||
try {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=" + new String(("payload-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||
headers.add("Pragma", "no-cache");
|
||||
headers.add("Expires", "0");
|
||||
return ResponseEntity.ok().headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
} catch (Exception e) {
|
||||
log.error("推送日志请求体下载失败, id={}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupIncludeChildrenRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:00
|
||||
* @description 系统组管理(组织架构树)——管理端四级访问控制 guard 层(S+B+C)。<br>
|
||||
* 范围裁剪与越权拦截在此层完成,Service 保持与 oapi 共享不受影响;SUPER 行为与改造前完全一致。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/sysgroup/")
|
||||
public class SysGroupController {
|
||||
|
||||
@Autowired
|
||||
private ISysGroupService sysGroupService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
@OperationLog("新增组")
|
||||
@Operation(operationId = "sysGroupAdd")
|
||||
@PostMapping("Add")
|
||||
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) {
|
||||
// pid 为 null/0 表示挂根下(仅超管);其余必须落在数据范围内(学校不可跳出本校建组)
|
||||
accessScopeService.requireCreatePointInScope(request.getParentId());
|
||||
sysGroupService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("编辑组")
|
||||
@Operation(operationId = "sysGroupEdit")
|
||||
@PostMapping("Edit/{id}")
|
||||
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) {
|
||||
accessScopeService.requireGroupInScope(id);
|
||||
// SCHOOL 禁止编辑学校组本身(防改名/改类型拆校);BUREAU 禁止编辑区域组本身
|
||||
accessScopeService.requireGroupModifiable(id);
|
||||
sysGroupService.edit(id, request);
|
||||
}
|
||||
|
||||
@OperationLog("删除组")
|
||||
@Operation(operationId = "sysGroupRemove")
|
||||
@PostMapping("Delete/{id}")
|
||||
public void delete(@PathVariable Integer id) {
|
||||
accessScopeService.requireGroupInScope(id);
|
||||
// 学校组/区域组本身不可删,子组可管理
|
||||
accessScopeService.requireGroupModifiable(id);
|
||||
sysGroupService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。<br>
|
||||
* 不判组类型(status 当前无消费方);删除行为不动。范式对齐 SysUserController.Disable。
|
||||
*/
|
||||
@OperationLog("停用组")
|
||||
@Operation(operationId = "sysGroupDisable")
|
||||
@PostMapping("Disable/{id}")
|
||||
public void disable(@PathVariable Integer id) {
|
||||
accessScopeService.requireGroupInScope(id);
|
||||
accessScopeService.requireGroupModifiable(id);
|
||||
sysGroupService.changeStatus(id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=0。
|
||||
*/
|
||||
@OperationLog("启用组")
|
||||
@Operation(operationId = "sysGroupEnable")
|
||||
@PostMapping("Enable/{id}")
|
||||
public void enable(@PathVariable Integer id) {
|
||||
accessScopeService.requireGroupInScope(id);
|
||||
accessScopeService.requireGroupModifiable(id);
|
||||
sysGroupService.changeStatus(id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置「包含子组」(2026-10-21 部门线):该学校在部门管理页是否连同下级部门一起列出。<br>
|
||||
* 单列 UPDATE sys_group.include_children,不碰其他列与表。<br>
|
||||
* 权限<b>只用</b> {@code requireGroupInScope}:不能加 {@code requireGroupModifiable}——
|
||||
* 后者禁止 SCHOOL 级操作学校组本身,而本属性恰恰是学校管理员必须能改的本校属性。
|
||||
*/
|
||||
@OperationLog("设置包含子组")
|
||||
@Operation(operationId = "sysGroupSetIncludeChildren")
|
||||
@PostMapping("SetIncludeChildren/{id}")
|
||||
public void setIncludeChildren(@PathVariable Integer id,
|
||||
@RequestBody @Validated SysGroupIncludeChildrenRequest request) {
|
||||
accessScopeService.requireGroupInScope(id);
|
||||
sysGroupService.changeIncludeChildren(id, Boolean.TRUE.equals(request.getIncludeChildren()));
|
||||
}
|
||||
|
||||
@Operation(operationId = "sysGroupList")
|
||||
@GetMapping("/List")
|
||||
public List<SysGroupVo> list(SysGroupQuery 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)。<br>
|
||||
* 按访问级别裁剪:SUPER 全量;BUREAU 以区域组为根的子树;SCHOOL 各学校子树(多校 = 多根数组)。
|
||||
*/
|
||||
@GetMapping("GetTree")
|
||||
public List<SysGroupVo> getTree() {
|
||||
return sysGroupService.getTree(accessScopeService.resolveCurrentScope());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
|
||||
* 子树中无学校组的分支整体剪掉;根节点始终保留。<br>
|
||||
* BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
|
||||
*/
|
||||
@GetMapping("GetSchoolTree")
|
||||
public List<SysGroupVo> getSchoolTree() {
|
||||
return sysGroupService.getSchoolTree(accessScopeService.resolveCurrentScope());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量新增子组:请求体 { pid, type, items:[{name},...] }(JSON)。
|
||||
*/
|
||||
@OperationLog("批量新增子组")
|
||||
@PostMapping("BatchAdd")
|
||||
public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) {
|
||||
accessScopeService.requireCreatePointInScope(request.getPid());
|
||||
sysGroupService.batchAdd(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子组排序:传入 pid 与有序 ids(必须是 pid 的直接子节点),按传入顺序落库。
|
||||
*/
|
||||
@OperationLog("子组排序")
|
||||
@PostMapping("SortChildren")
|
||||
public void sortChildren(@Validated SysGroupSortRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getPid());
|
||||
sysGroupService.sortChildren(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。<br>
|
||||
* moveId 与目标 pid 均须落在数据范围内(SCHOOL 级天然禁止跨校移动,与 oapi move 语义一致)。
|
||||
*/
|
||||
@OperationLog("移动组")
|
||||
@PostMapping("Move")
|
||||
public void move(@Validated SysGroupMoveRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getMoveId());
|
||||
accessScopeService.requireCreatePointInScope(request.getPid());
|
||||
sysGroupService.move(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 开放接口日志查询与下载接口。<br>
|
||||
* 本控制器位于 {@code controller.api} 包,不会被 OapiLogAspect(切 oapi 包)拦截。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/oapilog/")
|
||||
public class SysOapiLogController {
|
||||
|
||||
@Autowired
|
||||
private ISysOapiLogService sysOapiLogService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/**
|
||||
* 分页查询开放接口日志列表(不含响应结果)。
|
||||
*/
|
||||
@Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList")
|
||||
@GetMapping("/List")
|
||||
public BasePageResult<SysOapiLogListVo> list(SysOapiLogQuery query, BasePageQuery pageQuery) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
return sysOapiLogService.pageQuery(query, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。<br>
|
||||
* 与 {@code SysOperationLogController.Download} 一致的 octet-stream + attachment 写法。<br>
|
||||
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
|
||||
*/
|
||||
@Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload")
|
||||
@GetMapping("/Download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
String data = sysOapiLogService.getResponseData(id);
|
||||
if (data == null) {
|
||||
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
|
||||
}
|
||||
try {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||
headers.add("Pragma", "no-cache");
|
||||
headers.add("Expires", "0");
|
||||
return ResponseEntity.ok().headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
} catch (Exception e) {
|
||||
log.error("开放接口日志响应结果下载失败, id={}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.oplog.SysOperationLogListVo;
|
||||
import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
|
||||
import com.yunhai.userbase.service.ISysOperationLogService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 操作日志查询与下载接口。<br>
|
||||
* 本控制器自身不标注 @OperationLog,避免记录日志查看行为。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/oplog/")
|
||||
public class SysOperationLogController {
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/**
|
||||
* 分页查询操作日志列表(不含响应结果)。
|
||||
*/
|
||||
@Operation(summary = "操作日志分页查询", operationId = "operationLogList")
|
||||
@GetMapping("/List")
|
||||
public BasePageResult<SysOperationLogListVo> list(SysOperationLogQuery query, BasePageQuery pageQuery) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
return sysOperationLogService.pageQuery(query, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载某条日志的响应结果(GZIP 解压后原文, 文本文件)。<br>
|
||||
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。<br>
|
||||
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
|
||||
*/
|
||||
@Operation(summary = "下载日志响应原文", operationId = "operationLogDownload")
|
||||
@GetMapping("/Download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
String data = sysOperationLogService.getResponseData(id);
|
||||
if (data == null) {
|
||||
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
|
||||
}
|
||||
try {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||
headers.add("Pragma", "no-cache");
|
||||
headers.add("Expires", "0");
|
||||
return ResponseEntity.ok().headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
} catch (Exception e) {
|
||||
log.error("操作日志响应结果下载失败, id={}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.BatchDeleteRequest;
|
||||
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
||||
import com.yunhai.userbase.model.sysuser.ChangeGroupsRequest;
|
||||
import com.yunhai.userbase.model.sysuser.ChangeSortNoRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import com.yunhai.userbase.model.sysuser.UserIdentityVo;
|
||||
import com.yunhai.userbase.service.ISysUserService;
|
||||
import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:14
|
||||
* @description 组用户管理
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/sysuser/")
|
||||
public class SysUserController {
|
||||
|
||||
@Autowired
|
||||
private ISysUserService sysUserService;
|
||||
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/** 非超管必须携带 groupId(null = 全平台导出/列表),且该组须落在数据范围内 */
|
||||
private void requireQueryGroupInScope(Integer groupId) {
|
||||
if (accessScopeService.resolveCurrentScope().isSuper()) {
|
||||
return;
|
||||
}
|
||||
if (groupId == null) {
|
||||
throw new DException("请先选择组织节点");
|
||||
}
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
}
|
||||
|
||||
@OperationLog("新增用户")
|
||||
@PostMapping("Add")
|
||||
public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
return sysUserService.add(request);
|
||||
}
|
||||
|
||||
@OperationLog("编辑用户")
|
||||
@PostMapping("Edit/{id}")
|
||||
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||
// 关键防线:目标用户本身必须与数据范围有交集。伪造任意 id 可跨校改他人资料/重置密码(账号接管),
|
||||
// 由 requireUserInScope(id) 拦截——它与 request.groupId 无关,是编辑越权的唯一有效锚点。
|
||||
accessScopeService.requireUserInScope(id);
|
||||
// groupId 对编辑无业务语义(SysUserService.edit 不读取它,仅按 schoolId+roleIds 落身份),
|
||||
// 且 DTO 与前端契约均声明「groupId 仅 Create 校验、编辑不提交」。故为空时不再拦截(修复「组ID不能为空」);
|
||||
// 仅当前端确实带了组上下文时,才二次校验该组落在数据范围内,保留防御纵深。
|
||||
if (request.getGroupId() != null) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
}
|
||||
sysUserService.edit(id, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。
|
||||
*/
|
||||
@OperationLog("从组中删除用户")
|
||||
@PostMapping("DeleteFromGroup/{userId}/{groupId}")
|
||||
public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
sysUserService.deleteFromGroup(userId, groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量从指定组中移除用户;无其它组关联者级联删除用户。
|
||||
*/
|
||||
@OperationLog("批量从组中删除用户")
|
||||
@PostMapping("BatchDelete")
|
||||
public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId());
|
||||
}
|
||||
|
||||
/** 禁用用户(disabled=true) */
|
||||
@OperationLog("禁用用户")
|
||||
@PostMapping("Disable/{id}")
|
||||
public void disable(@PathVariable Integer id) {
|
||||
// 入参只有 userId:校验目标用户归属组与数据范围有交集,防止跨校禁用他人账号
|
||||
accessScopeService.requireUserInScope(id);
|
||||
sysUserService.changeStatus(id, true);
|
||||
}
|
||||
|
||||
/** 启用用户(disabled=false) */
|
||||
@OperationLog("启用用户")
|
||||
@PostMapping("Enable/{id}")
|
||||
public void enable(@PathVariable Integer id) {
|
||||
accessScopeService.requireUserInScope(id);
|
||||
sysUserService.changeStatus(id, false);
|
||||
}
|
||||
|
||||
/** 批量设置用户状态 */
|
||||
@OperationLog("批量设置用户状态")
|
||||
@PostMapping("BatchChangeStatus")
|
||||
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
|
||||
accessScopeService.requireUsersInScope(request.getUserIds());
|
||||
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
|
||||
}
|
||||
|
||||
@GetMapping("/List")
|
||||
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) {
|
||||
// 前端点树节点必带 groupId;非超管 null groupId(树未选中时的首载请求)返回空页而非报错,
|
||||
// 越界组拒绝(null = 全平台用户,仅超管语义)
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()) {
|
||||
if (query.getGroupId() == null) {
|
||||
return PageUtils.toResult(new PageImpl<SysUserVo>(Collections.emptyList(),
|
||||
PageUtils.toPageable(pageQuery, null), 0));
|
||||
}
|
||||
accessScopeService.requireGroupInScope(query.getGroupId());
|
||||
}
|
||||
// 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制
|
||||
// (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。
|
||||
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null)));
|
||||
}
|
||||
|
||||
/** 调整用户在某组内的排序号(sort_no):1~999,可空表示清除 */
|
||||
@OperationLog("设置用户组内排序号")
|
||||
@PostMapping("ChangeSortNo/{userId}")
|
||||
public void changeSortNo(@PathVariable Integer userId,
|
||||
@RequestBody @Validated ChangeSortNoRequest request) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo());
|
||||
}
|
||||
|
||||
@OperationLog("调整组")
|
||||
@PostMapping("ChangeGroups")
|
||||
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) {
|
||||
// 目标组全部须落在数据范围内(学校不可把用户调出本校);
|
||||
// 被移动用户本身也须在范围内(防把外校用户拉进本校)
|
||||
accessScopeService.requireUsersInScope(request.getUserIds());
|
||||
accessScopeService.requireGroupsInScope(request.getGroupIds());
|
||||
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds(), request.getMainGroupId());
|
||||
}
|
||||
|
||||
@GetMapping("Groups/{userId}")
|
||||
public List<Integer> getUserGroups(@PathVariable Integer userId) {
|
||||
return sysUserService.findGroupIdsByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
|
||||
* 「用户身份」回显唯一来源(数据源=身份行 sys_user_role_rel;roles 列已停写、不再读取);
|
||||
* 编辑请求提交时原样携带 {schoolId, roleIds}。
|
||||
*/
|
||||
@GetMapping("UserIdentity")
|
||||
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return sysUserService.getUserIdentity(userId, groupId);
|
||||
}
|
||||
|
||||
@OperationLog("批量导入用户")
|
||||
@PostMapping("BatchImport")
|
||||
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
|
||||
// 涉及的全部组 id ∈ scope 的校验在 Service 解析 Excel 后逐行执行(越界行按行失败上报)
|
||||
return sysUserService.batchImport(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载用户批量导入模板(真实 .xlsx):平铺当前组及子孙组,仅填充组名(路径)与组id;其余列留空供填写。
|
||||
*/
|
||||
@GetMapping("DownloadTemplate")
|
||||
public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId,
|
||||
HttpServletResponse response) throws IOException {
|
||||
requireQueryGroupInScope(groupId);
|
||||
sysUserService.downloadImportTemplate(groupId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出组下所有用户(含子组)为 Excel;与导入模板同构,末尾追加用户id列。
|
||||
* 前端直接跳转此地址触发浏览器下载。
|
||||
*/
|
||||
@GetMapping("Export")
|
||||
public void export(@RequestParam(value = "groupId", required = false) Integer groupId,
|
||||
@RequestParam(value = "withSub", defaultValue = "1") Integer withSub,
|
||||
@RequestParam(value = "name", required = false) String name,
|
||||
HttpServletResponse response) throws IOException {
|
||||
requireQueryGroupInScope(groupId);
|
||||
sysUserService.exportUsers(groupId, withSub, name, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id 批量取用户展示信息(id/账号/姓名),管理面聚合页(消息/待办追溯台收件明细)回显名称用。<br>
|
||||
* 只读窄投影:不透出手机号/身份证等敏感列;用户目录属平台主数据,仅超管。
|
||||
*/
|
||||
@GetMapping("ByIds")
|
||||
public List<Map<String, Object>> byIds(@RequestParam("ids") List<Integer> ids) {
|
||||
accessScopeService.requireSuper();
|
||||
return sysUserService.listByIds(ids).stream()
|
||||
.map(u -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", u.getId());
|
||||
item.put("account", u.getAccount());
|
||||
item.put("realName", u.getRealName());
|
||||
return item;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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.AppActivatedVo;
|
||||
import com.yunhai.userbase.model.application.AppIconMapVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
import com.yunhai.userbase.service.IAppCategoryService;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
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;
|
||||
|
||||
@Autowired
|
||||
private IGroupAuthzService groupAuthzService;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
|
||||
|
||||
/**
|
||||
* 按主键 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) {
|
||||
return toVo(applicationInfoService.getById(appId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按技术标识查询应用信息(跳转查询键主用端点)。<br>
|
||||
* app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;<br>
|
||||
* 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。
|
||||
*
|
||||
* @param appCode 技术标识(必填)
|
||||
* @return 应用信息视图;不存在时返回 null
|
||||
*/
|
||||
@GetMapping("by-code/{appCode}")
|
||||
public ApplicationInfoVo byCode(@PathVariable String appCode) {
|
||||
return toVo(applicationInfoService.getByAppCode(appCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。<br>
|
||||
* ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等,
|
||||
* 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。<br>
|
||||
* 查不到返回 null;返回视图清空 appSecret。
|
||||
*
|
||||
* @param appKey 应用标识(必填)
|
||||
* @return 应用信息视图;不存在时返回 null
|
||||
*/
|
||||
@GetMapping("by-key/{appKey}")
|
||||
public ApplicationInfoVo byKey(@PathVariable Integer appKey) {
|
||||
return toVo(applicationInfoService.getByAppKey(appKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
|
||||
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛:
|
||||
* 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部),
|
||||
* 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
|
||||
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
|
||||
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
|
||||
*
|
||||
* @param userId 用户ID(必填)
|
||||
* @return 有权应用的 app_code 集合(可能为空,不为 null)
|
||||
*/
|
||||
@GetMapping("authorized-codes")
|
||||
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
|
||||
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId, null));
|
||||
}
|
||||
|
||||
private ApplicationInfoVo toVo(ApplicationInfo info) {
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
ApplicationInfoVo vo = new ApplicationInfoVo();
|
||||
BeanUtils.copyProperties(info, vo);
|
||||
// 不透出签名密钥(与 list 契约一致)
|
||||
vo.setAppSecret(null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
|
||||
* 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
|
||||
* ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
|
||||
* 不透出 appSecret。<br>
|
||||
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
|
||||
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
|
||||
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
|
||||
*
|
||||
* @param userId 用户ID(必填)
|
||||
* @param clientType 客户端类型 key(可选)
|
||||
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
|
||||
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
|
||||
*/
|
||||
@GetMapping("list")
|
||||
public List<BoaAppType> list(@RequestParam("userId") Integer userId,
|
||||
@RequestParam(value = "clientType", required = false) Integer clientType,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
ClientEnum clientEnum = (clientType == null) ? null
|
||||
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。<br>
|
||||
* 开通口径 = 学校组<b>单点</b>已开通(sys_group_app_rel where group_id=schoolId;开通不继承,
|
||||
* 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。<br>
|
||||
* 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。<br>
|
||||
* schoolId 必填且须为合法学校组锚点,否则 code=5。<br>
|
||||
* 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。
|
||||
*
|
||||
* @param schoolId 学校组ID(必填)
|
||||
* @param clientType 客户端类型 key(可选;null=不按端过滤)
|
||||
* @return 应用类型分组列表;未开通应用的类型 apps 为空数组
|
||||
*/
|
||||
@GetMapping("list-by-school")
|
||||
public List<BoaAppType> listBySchool(@RequestParam("schoolId") Integer schoolId,
|
||||
@RequestParam(value = "clientType", required = false) Integer clientType) {
|
||||
ClientEnum clientEnum = (clientType == null) ? null
|
||||
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。<br>
|
||||
* 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
|
||||
* UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。<br>
|
||||
* 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
|
||||
*/
|
||||
@GetMapping("icon-map")
|
||||
public List<AppIconMapVo> iconMap() {
|
||||
return applicationInfoService.listIconMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。<br>
|
||||
* 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块),
|
||||
* 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定——
|
||||
* form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明
|
||||
* (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。<br>
|
||||
* 应用不存在(含已注销)返回 activated=false,不区分"没开通"与"没这应用",
|
||||
* 避免把应用存在性做成探测面;schoolId 非法(非学校组锚点)按既有 {@code assertSchoolId}
|
||||
* 口径抛业务错误(code=5)。只读、无敏感字段。
|
||||
*
|
||||
* @param appId 应用主键 ID(必填;稳定主体,非 appKey 凭据索引)
|
||||
* @param schoolId 学校组 ID(必填)
|
||||
*/
|
||||
@GetMapping("activated")
|
||||
public AppActivatedVo activated(@RequestParam("appId") Integer appId,
|
||||
@RequestParam("schoolId") Integer schoolId) {
|
||||
if (appId == null || schoolId == null) {
|
||||
throw new DException("appId 与 schoolId 必填");
|
||||
}
|
||||
sysGroupService.assertSchoolId(schoolId);
|
||||
boolean appExists = applicationInfoService.getById(appId) != null;
|
||||
boolean activated = appExists && groupAuthzService.findAuthorizedAppIds(schoolId).contains(appId);
|
||||
return new AppActivatedVo(appId, schoolId, activated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
/** Legacy paths retained; naked phone/account/openid authority is retired. */
|
||||
@RestController @RequestMapping("/oapi/channel")
|
||||
public class ChannelBindController {
|
||||
private final ObjectProvider<MobileIdentityService> provider;
|
||||
public ChannelBindController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||
public static class Bind {public String channelProof,userProof,operationId;}
|
||||
public static class Unbind {public String userActionProof,reauthProof,operationId;}
|
||||
@GetMapping("/user") public Map<String,Object> user(@RequestHeader("X-YH-Proof") String proof){return service().resolve(proof);}
|
||||
@PostMapping("/bind") public Map<String,Object> bind(@RequestBody Bind b){return service().bind(b.channelProof,b.userProof,b.operationId);}
|
||||
@PostMapping("/bindByPhone") public void phone(){Contexts.requireCaller("channel.bind.phone");throw new AuthFailure(403,"METHOD_DISABLED");}
|
||||
@PostMapping("/unbind") public Map<String,Object> unbind(@RequestBody Unbind b){return service().unbind(b.userActionProof,b.reauthProof,b.operationId);}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.enums.DepartmentTypeEnum;
|
||||
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
|
||||
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
|
||||
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
|
||||
import com.yunhai.userbase.service.IDepartmentDirectoryService;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通用部门目录开放接口({@code /oapi/departments/...},规格书 UB-2)。<br>
|
||||
* <br>
|
||||
* 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的
|
||||
* {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签;
|
||||
* 但<b>验签只证明「是哪个应用」</b>,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定,
|
||||
* 越校读取抛业务异常(A15)。<br>
|
||||
* 引用一律 {@code schoolId + departmentType + departmentId} 三件套,禁止以名称做键。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "departments")
|
||||
public class DepartmentController {
|
||||
|
||||
/** 成员解析上下文:供新的指派/绑定(严档,也是缺省)。 */
|
||||
private static final String SCOPE_SELECT = "select";
|
||||
|
||||
/** 成员解析上下文:已有实例的历史解析(宽档,停用/软删照发)。 */
|
||||
private static final String SCOPE_HISTORY = "history";
|
||||
|
||||
@Autowired
|
||||
private IDepartmentDirectoryService departmentDirectoryService;
|
||||
|
||||
@Autowired
|
||||
private IApplicationInfoService applicationInfoService;
|
||||
|
||||
/**
|
||||
* 可选部门目录:默认只返回启用且未软删的条目。<br>
|
||||
* {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效;
|
||||
* {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。
|
||||
*/
|
||||
@GetMapping
|
||||
public List<DepartmentEntryVo> list(@RequestParam("schoolId") Integer schoolId,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "categoryId", required = false) Integer categoryId,
|
||||
@RequestParam(value = "withChildren", required = false) Boolean withChildren,
|
||||
HttpServletRequest request) {
|
||||
DepartmentTypeEnum parsed = parseType(type, true);
|
||||
return departmentDirectoryService.list(resolveCallerAppId(request), schoolId, parsed,
|
||||
categoryId, Boolean.TRUE.equals(withChildren));
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 分类目录(学校通用,不按调用方应用隔离;分类只是容器,不能指派人员)。
|
||||
*/
|
||||
@GetMapping("categories")
|
||||
public List<CustomDeptCategoryVo> categories(@RequestParam("schoolId") Integer schoolId,
|
||||
HttpServletRequest request) {
|
||||
return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析引用(<b>唯一</b>允许返回停用/软删部门的读口,A12)。<br>
|
||||
* 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。
|
||||
*/
|
||||
@PostMapping("resolve-batch")
|
||||
public List<DepartmentEntryVo> resolveBatch(@RequestBody @Validated List<DepartmentRefRequest> refs,
|
||||
HttpServletRequest request) {
|
||||
return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;<br>
|
||||
* {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。<br>
|
||||
* 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。
|
||||
*/
|
||||
@GetMapping("{type}/{id}/members")
|
||||
public List<BoaUser> members(@PathVariable String type,
|
||||
@PathVariable Integer id,
|
||||
@RequestParam(value = "scope", required = false) String scope,
|
||||
HttpServletRequest request) {
|
||||
DepartmentTypeEnum parsed = parseType(type, false);
|
||||
return departmentDirectoryService.members(resolveCallerAppId(request), parsed, id, parseScope(scope));
|
||||
}
|
||||
|
||||
// ===================== 私有 =====================
|
||||
|
||||
/**
|
||||
* 解析成员上下文:缺省与 {@code select} 都按「供新的指派」处理(更严的一档做默认),
|
||||
* 只有明确 {@code history} 才放行停用/软删;其余取值直接拒绝——写错的 scope 不能静默变成宽口径。
|
||||
*/
|
||||
private boolean parseScope(String scope) {
|
||||
String normalized = scope == null ? "" : scope.trim().toLowerCase();
|
||||
if (normalized.isEmpty() || SCOPE_SELECT.equals(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (SCOPE_HISTORY.equals(normalized)) {
|
||||
return false;
|
||||
}
|
||||
throw new DException("scope 只允许 select / history");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析来源类型:路径段必解析(非法即拒),查询参数缺省表示「两类都要」。
|
||||
*/
|
||||
private DepartmentTypeEnum parseType(String raw, boolean allowMissing) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
if (allowMissing) {
|
||||
return null;
|
||||
}
|
||||
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
|
||||
}
|
||||
DepartmentTypeEnum parsed = DepartmentTypeEnum.of(raw);
|
||||
if (parsed == null) {
|
||||
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从验签属性取 appKey,再映射为 application_info 主键(与 {@code oapi/user} 的 {@code modules} 同一链路)。<br>
|
||||
* 解析不出应用 → 传 null 给 Service,由其按「无法识别调用方」拒绝:部门目录不能匿名兜底成「全量可读」。
|
||||
*/
|
||||
private Integer resolveCallerAppId(HttpServletRequest request) {
|
||||
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||
if (attr == null) {
|
||||
log.warn("[oapi:departments] 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:departments] AppAuth attr is blank, uri={}", request.getRequestURI());
|
||||
return null;
|
||||
}
|
||||
Integer appKey;
|
||||
try {
|
||||
appKey = Integer.parseInt(appKeyStr.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[oapi:departments] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
|
||||
return null;
|
||||
}
|
||||
Integer appId = applicationInfoService.findIdByAppKey(appKey);
|
||||
if (appId == null) {
|
||||
log.warn("[oapi:departments] appKey={} not found in application_info, uri={}", appKey,
|
||||
request.getRequestURI());
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 组织架构开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/group/...})。<br>
|
||||
* <br>
|
||||
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient}
|
||||
* 硬编码的契约严格一致:
|
||||
* {@code GET /oapi/group/tree}、{@code /byId?groupId=&withSub=}、{@code /list?ids=&withSub=}。<br>
|
||||
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
|
||||
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "group/")
|
||||
public class GroupController {
|
||||
|
||||
@Autowired
|
||||
private ISysGroupService sysGroupService;
|
||||
|
||||
/**
|
||||
* 获取整个平台的组树(从根节点开始,children 递归嵌套全部后代)。
|
||||
*/
|
||||
@GetMapping("tree")
|
||||
public BoaGroup tree() {
|
||||
return sysGroupService.getGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。
|
||||
*
|
||||
* @param groupId 组ID(必填)
|
||||
* @param withSub 是否递归加上子节点,缺省 false
|
||||
* @return 组节点;不存在返回 null
|
||||
*/
|
||||
@GetMapping("byId")
|
||||
public BoaGroup byId(@RequestParam("groupId") Integer groupId,
|
||||
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
||||
return sysGroupService.getGroupById(groupId, withSub);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的跳过。
|
||||
*
|
||||
* @param ids 组ID列表(逗号/空格分隔)
|
||||
* @param withSub 是否展开子节点,缺省 false
|
||||
* @return 组节点列表(按入参顺序;不存在的跳过)
|
||||
*/
|
||||
@GetMapping("list")
|
||||
public List<BoaGroup> list(@RequestParam("ids") String ids,
|
||||
@RequestParam(value = "withSub", required = false) Boolean withSub) {
|
||||
return sysGroupService.getGroupList(ids, withSub);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组内用户(不分页,一次返回全部):<br>
|
||||
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);<br>
|
||||
* roleIds 逗号分隔(复用 SysRoleEnum 键),按身份行 {@code sys_user_role_rel}(正常态)过滤,缺省不过滤。<br>
|
||||
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。<br>
|
||||
* 注意:轻量 BoaUser 不填 {@code status}/组内转出标记,需判禁用或转出请走 {@code /oapi/user/byId}。
|
||||
*
|
||||
* @param groupId 组ID(必填)
|
||||
* @param withSub 是否递归子组,缺省 false
|
||||
* @param roleIds 角色过滤(逗号分隔),缺省不过滤
|
||||
* @return 组内用户列表(按用户去重;无匹配返回空列表)
|
||||
*/
|
||||
@GetMapping("users")
|
||||
public List<BoaUser> groupUsers(@RequestParam("groupId") Integer groupId,
|
||||
@RequestParam(value = "withSub", required = false) Boolean withSub,
|
||||
@RequestParam(value = "roleIds", required = false) String roleIds) {
|
||||
return sysGroupService.listGroupUsers(groupId, withSub, roleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增组:返回创建后的组节点(含 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,16 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
@RestController @RequestMapping("/oapi/identity")
|
||||
public class IdentityController {
|
||||
private final ObjectProvider<MobileIdentityService> provider;
|
||||
public IdentityController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||
@GetMapping("/current") public Map<String,Object> current(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,false);}
|
||||
@GetMapping("/available") public Map<String,Object> available(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,true);}
|
||||
public static class Switch {public String userActionProof,operationId;public int roleId,schoolId;public long expectedIdentityVersion;}
|
||||
@PostMapping("/switch") public Map<String,Object> change(@RequestBody Switch b){return service().switchIdentity(b.userActionProof,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import javax.servlet.http.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Existing app-sign authentication; explicit portal allowlist. Never log request/credentials. */
|
||||
@RestController
|
||||
@RequestMapping("/oapi/user")
|
||||
public class PasswordVerificationController {
|
||||
private final ObjectProvider<MobileIdentityService> services;
|
||||
private final String env; private final Set<String> portals;
|
||||
public PasswordVerificationController(ObjectProvider<MobileIdentityService> services,
|
||||
@Value("${yh.mobile.env:}") String env,
|
||||
@Value("${yh.mobile.password-allowed-app-keys:}") String allowed) {
|
||||
this.services=services;this.env=env;this.portals=new HashSet<>(Arrays.asList(allowed.replace(" ","").split(",")));
|
||||
}
|
||||
public static class Credentials { public String account,password; }
|
||||
@PostMapping("/verifyPassword")
|
||||
public SessionAuthenticator.Snapshot verify(@RequestBody Credentials b,HttpServletRequest r,HttpServletResponse response) {
|
||||
response.setHeader("Cache-Control","no-store");
|
||||
Object app=r.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||
AuthFailure.require(app!=null,401,"APP_SIGNATURE_REQUIRED");
|
||||
AuthFailure.require(portals.contains(String.valueOf(app)),403,"PORTAL_CALLER_REQUIRED");
|
||||
MobileIdentityService service=services.getIfAvailable();AuthFailure.require(service!=null,503,"AUTHORITY_UNAVAILABLE");
|
||||
return service.verifyPortalPassword(env,b.account,b.password);
|
||||
}
|
||||
@ExceptionHandler(AuthFailure.class)
|
||||
public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
|
||||
Map<String,Object> result=new LinkedHashMap<>();result.put("code",e.status==401?40101:e.status==403?40301:50301);result.put("msg",e.status==401?"账号或密码错误":e.code);
|
||||
return ResponseEntity.status("APP_SIGNATURE_REQUIRED".equals(e.code)||"PORTAL_CALLER_REQUIRED".equals(e.code)?e.status:200).header("Cache-Control","no-store").body(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.platform.apistd.ApiResponse;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.common.annotation.OapiLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
|
||||
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.ISysUserService;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/user/...})。<br>
|
||||
* <br>
|
||||
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient}
|
||||
* 硬编码的契约严格一致:
|
||||
* {@code GET /oapi/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、
|
||||
* {@code /oapi/user/modules?userId=&schId=}。<br>
|
||||
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求,
|
||||
* 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "user/")
|
||||
public class UserController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserController.class);
|
||||
|
||||
@Autowired
|
||||
private UserCenterService userCenterService;
|
||||
|
||||
@Autowired
|
||||
private ISysUserService sysUserService;
|
||||
|
||||
@Autowired
|
||||
private IGroupAuthzService groupAuthzService;
|
||||
|
||||
@Autowired
|
||||
private IApplicationInfoService applicationInfoService;
|
||||
|
||||
@GetMapping("byId")
|
||||
public BoaUser byId(@RequestParam("id") Integer id) {
|
||||
return userCenterService.getUserInfoWithDeptById(id);
|
||||
}
|
||||
|
||||
@GetMapping("byLoginName")
|
||||
public BoaUser byLoginName(@RequestParam("loginName") String loginName) {
|
||||
if (loginName == null || loginName.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return userCenterService.getUserInfoWithDeptByAccount(loginName);
|
||||
}
|
||||
|
||||
@GetMapping("byPhone")
|
||||
public BoaUser byPhone(@RequestParam("phone") String phone) {
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return userCenterService.getUserInfoWithDeptByPhone(phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||
* <br>
|
||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
|
||||
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
|
||||
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
|
||||
* 口径同现有兜底);<br>
|
||||
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
|
||||
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
|
||||
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
|
||||
* <br>
|
||||
* 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
|
||||
* 返回其他应用的模块)。<br>
|
||||
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
|
||||
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
|
||||
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
|
||||
*
|
||||
* @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
|
||||
* @param userId 用户ID(必填)
|
||||
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
|
||||
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
||||
*/
|
||||
@GetMapping("modules")
|
||||
public List<BoaModule> modules(HttpServletRequest request,
|
||||
@RequestParam("userId") Integer userId,
|
||||
@RequestParam(value = "schId", required = false) Integer schId,
|
||||
@RequestParam(value = "appCode", required = false) String appCode) {
|
||||
if (userId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer appId;
|
||||
if (appCode != null && !appCode.trim().isEmpty()) {
|
||||
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
|
||||
if (app == null) {
|
||||
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
|
||||
appCode, request.getRequestURI());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
appId = app.getId();
|
||||
} else {
|
||||
appId = resolveCallerAppId(request);
|
||||
}
|
||||
if (appId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserModulesForApp(appId, schId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求 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 与组织链)。<br>
|
||||
* 用户身份按<b>追加语义</b>写({@code roleIds} 已存在则不变、不存在则补,绝不删除):开放接口调用方
|
||||
* 只能表达"该用户应有某身份"这层意图,覆盖语义会误删该校其它身份;学校范围取 {@code schoolId},
|
||||
* 缺省按 {@code groupId} 解析。管理端编辑仍是该校全量覆盖(见 {@code /sysuser/Edit/{id}})。
|
||||
*/
|
||||
@PostMapping("edit/{id}")
|
||||
public BoaUser edit(@PathVariable Integer id,
|
||||
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||
sysUserService.editWithIdentityAppend(id, request);
|
||||
return userCenterService.getUserInfoWithDeptById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;<br>
|
||||
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。<br>
|
||||
* 这样多校用户只会被退出指定学校,而不会被整体误删。<br>
|
||||
* 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。
|
||||
*/
|
||||
@PostMapping("delete/{id}/{groupId}/{schoolId}")
|
||||
public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) {
|
||||
sysUserService.deleteFromGroup(id, groupId, schoolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);<br>
|
||||
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。
|
||||
*/
|
||||
@PostMapping("deleteUsers")
|
||||
public void deleteUsers(@RequestBody List<SysUserGroupDeleteItem> items) {
|
||||
sysUserService.deleteUsers(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword);
|
||||
* 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。
|
||||
*/
|
||||
@PostMapping("batchAdd")
|
||||
public List<BoaUser> batchAdd(@RequestBody List<SysUserRequest> requests) {
|
||||
List<Integer> ids = sysUserService.batchAdd(requests);
|
||||
List<BoaUser> result = new ArrayList<>();
|
||||
for (Integer id : ids) {
|
||||
result.add(userCenterService.getUserInfoWithDeptById(id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内,
|
||||
* 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。<br>
|
||||
* 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。
|
||||
*/
|
||||
@PostMapping("moveToGroup")
|
||||
public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) {
|
||||
sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId());
|
||||
return userCenterService.getUserInfoWithDeptById(request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。<br>
|
||||
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
|
||||
*/
|
||||
@GetMapping("roles")
|
||||
public List<BoaRoleInfo> roles(@RequestParam("userId") Integer userId) {
|
||||
return sysUserService.findUserRoles(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
|
||||
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
|
||||
*/
|
||||
@PostMapping("password")
|
||||
public void password(@RequestBody @Validated SysUserPasswordRequest request) {
|
||||
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
* 本人联系方式修改(手机号/邮箱):body {userId, phone, email},phone/email 至少一项非空,
|
||||
* 空白=不修改该字段;只更新 sys_user 的 phone/email 两字段(其余一律不动),
|
||||
* 手机号/邮箱格式与长度校验、手机号唯一查重在 Service 层。返回更新后的用户
|
||||
* (getUserInfoWithDeptById,事务提交后缓存已被 UserChangedEvent 驱逐,回读即新值)。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("self/contact")
|
||||
public BoaUser selfContact(@RequestBody @Validated SysUserContactRequest request) {
|
||||
sysUserService.updateSelfContact(request.getUserId(), request.getPhone(), request.getEmail());
|
||||
return userCenterService.getUserInfoWithDeptById(request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
|
||||
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
||||
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。<br>
|
||||
* <b>注意必须显式返回 {@link ApiResponse} 信封</b>:若直接返回 String,
|
||||
* ApiResponseAdvice 会跳过包装(String 走 StringHttpMessageConverter 无法套壳),
|
||||
* 响应变成裸文本 URL,调用方(yh-openapi-client)按 JSON 解析会报 deserialize failed。
|
||||
*/
|
||||
@PostMapping("profilePicture")
|
||||
public ApiResponse<String> profilePicture(@RequestParam("userId") Integer userId,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.success(sysUserService.changeProfilePicture(userId, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户在指定组上的状态(转出/恢复)。<br>
|
||||
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。
|
||||
* 仅允许在同一学校(组)范围内操作——groupId 必须属于 schoolId 子树且不等于 schoolId。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("groupState")
|
||||
public void groupState(@RequestBody @Validated SysUserGroupStateRequest request) {
|
||||
sysUserService.changeUserGroupState(request.getUserId(), request.getGroupId(),
|
||||
request.getSchoolId(), request.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在指定组上的状态:0=正常,1=转出。<br>
|
||||
* 边界与设置接口一致(groupId 必须属于 schoolId 子树且不等于 schoolId);
|
||||
* 用户不在此组时返回业务错误(code=5)。
|
||||
*/
|
||||
@OapiLog(value = "查询用户组状态", saveResponse = false)
|
||||
@GetMapping("groupState")
|
||||
public Integer groupState(@RequestParam("userId") Integer userId,
|
||||
@RequestParam("groupId") Integer groupId,
|
||||
@RequestParam("schoolId") Integer schoolId) {
|
||||
return sysUserService.getUserGroupState(userId, groupId, schoolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。<br>
|
||||
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);<br>
|
||||
* 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。<br>
|
||||
* 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留);
|
||||
* 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("transferToSchool")
|
||||
public BoaUser transferToSchool(@RequestBody @Validated SysUserTransferSchoolRequest request) {
|
||||
sysUserService.transferUserToSchool(request.getUserId(), request.getFromSchoolId(),
|
||||
request.getSchoolId(), request.getGroupId());
|
||||
return userCenterService.getUserInfoWithDeptById(request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单个用户状态(启用/禁用账号,语义同管理端 sysuser/Enable、sysuser/Disable 的合并版)。<br>
|
||||
* disabled=true 禁用、false 启用;变更后自动推送「编辑用户」订阅。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("changeStatus")
|
||||
public void changeStatus(@RequestBody @Validated SysUserChangeStatusRequest request) {
|
||||
sysUserService.changeStatus(request.getUserId(), request.getDisabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置用户状态(启用/禁用,语义同管理端 sysuser/BatchChangeStatus)。<br>
|
||||
* 任一 userId 无效则整体拒绝;变更后自动推送「编辑用户」订阅。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("batchChangeStatus")
|
||||
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
|
||||
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 15:53
|
||||
* @description
|
||||
*/
|
||||
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "app_category")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class AppCategory extends BaseEntity {
|
||||
//类别名称
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/22
|
||||
* @description 应用模块(树形,逻辑外键,最深5层)<br>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "app_module",
|
||||
indexes = {
|
||||
@Index(name = "idx_app_module_app", columnList = "app_id"),
|
||||
@Index(name = "idx_app_module_parent", columnList = "parent_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_app_module_custom", columnNames = {"app_id", "custom_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class AppModule extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 所属应用ID(逻辑外键 → ApplicationInfo.id),创建后不可改。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "app_id", nullable = false, updatable = false)
|
||||
private Integer appId;
|
||||
|
||||
/**
|
||||
* 父模块ID(逻辑外键 → AppModule.id);根模块为 null。<br>
|
||||
*/
|
||||
@Column(name = "parent_id")
|
||||
private Integer parentId;
|
||||
|
||||
/**
|
||||
* 模块名称。<br>
|
||||
*/
|
||||
@NotBlank
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
|
||||
*/
|
||||
@Column(name = "custom_id", nullable = true, length = 64)
|
||||
private String customId;
|
||||
|
||||
/**
|
||||
* 模块路径(前端路由/资源路径,如 "/system/user")。<br>
|
||||
*/
|
||||
@Column(name = "path", length = 255)
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 模块图标(YschIcons 图标名,如 "notice-list")。非必填;<br>
|
||||
* 为空时开放接口 {@code BoaModule.icon} 即为 null(不回退应用图标,由消费方自行兜底)。<br>
|
||||
*/
|
||||
@Column(name = "icon", length = 128)
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "level", nullable = false)
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 排序号(沿用 AppCategory 约定)。<br>
|
||||
*/
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Convert;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 15:53
|
||||
* @description
|
||||
*/
|
||||
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "application_info")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class ApplicationInfo extends BaseEntity {
|
||||
|
||||
/** Immutable application incarnation; never derive it from reusable numeric IDs or credentials. */
|
||||
@Column(name = "source_principal_id", unique = true, updatable = false, length = 36)
|
||||
private String sourcePrincipalId;
|
||||
|
||||
@javax.persistence.PrePersist
|
||||
void assignSourcePrincipal() {
|
||||
// Always generate server-side, including when an entity supplied a value.
|
||||
sourcePrincipalId = java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用名称(页面*必填)
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 应用类型ID
|
||||
*/
|
||||
private Integer categoryId;
|
||||
|
||||
/**
|
||||
* 排序号,仅在同一个应用类型内生效;默认为 null(新增时不赋值)。<br>
|
||||
*/
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
|
||||
/**
|
||||
* 应用描述
|
||||
*/
|
||||
@Column(name = "app_desc", length = 500)
|
||||
private String desc;
|
||||
|
||||
/**
|
||||
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "app_key", unique = true, nullable = false, updatable = false)
|
||||
private Integer appKey;
|
||||
|
||||
/**
|
||||
* appSecret 密钥,支持重设
|
||||
*/
|
||||
@NotBlank
|
||||
@Column(name = "app_secret", nullable = false, length = 128)
|
||||
private String appSecret;
|
||||
|
||||
/**
|
||||
* 技术标识 = projman app.name(如 notice/flow-center),跨系统稳定引用键。<br>
|
||||
* 跳转查询键(sys_message.jump_app 值域);引用方存的是字符串,改名须盘点引用。
|
||||
*/
|
||||
@Column(name = "app_code", length = 32)
|
||||
private String appCode;
|
||||
|
||||
/**
|
||||
* 应用性质:BASE=基石 / BUSINESS=业务(判定口径单点见 {@link com.yunhai.userbase.constant.AppNature})。
|
||||
*/
|
||||
@Column(name = "app_nature", length = 16)
|
||||
private String appNature;
|
||||
|
||||
//应用前端类型
|
||||
@Convert(converter = ListIntegerJsonConverter.class)
|
||||
@Column(name = "clients", columnDefinition = "json")
|
||||
private List<Integer> clients;
|
||||
|
||||
/**
|
||||
* PC应用访问地址
|
||||
*/
|
||||
@Column(name = "pc_url")
|
||||
private String pcUrl;
|
||||
|
||||
/**
|
||||
* PC应用图标 图片URL
|
||||
*/
|
||||
@Column(name = "pc_icon")
|
||||
private String pcIcon;
|
||||
|
||||
/**
|
||||
* App访问地址(移动端主地址)
|
||||
*/
|
||||
@Column(name = "app_url")
|
||||
private String appUrl;
|
||||
|
||||
/**
|
||||
* App应用图标 图片URL
|
||||
*/
|
||||
@Column(name = "app_icon")
|
||||
private String appIcon;
|
||||
|
||||
/**
|
||||
* 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。<br>
|
||||
* 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
|
||||
*/
|
||||
@Column(name = "icon_key", length = 64)
|
||||
private String iconKey;
|
||||
|
||||
/**
|
||||
* 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
|
||||
*/
|
||||
@Column(name = "icon_color", length = 16)
|
||||
private String iconColor;
|
||||
|
||||
/**
|
||||
* 公众号应用地址
|
||||
*/
|
||||
@Column(name = "official_account_url")
|
||||
private String officialAccountUrl;
|
||||
|
||||
/**
|
||||
* 微信小程序应用地址
|
||||
*/
|
||||
@Column(name = "miniprogram_url")
|
||||
private String miniprogramUrl;
|
||||
|
||||
/**
|
||||
* 钉钉应用地址
|
||||
*/
|
||||
@Column(name = "dingtalk_url")
|
||||
private String dingtalkUrl;
|
||||
|
||||
// ===================== 推送订阅配置(文档《弘育基础平台数据同步接口 2》) =====================
|
||||
|
||||
/**
|
||||
* 推送外部接口地址(不含固定后缀),如 http://host/notice/api/base/ 。
|
||||
* 完整调用地址 = pushBaseUrl + 接口路径(如 group/AddGroup)。
|
||||
*/
|
||||
@Column(name = "push_base_url")
|
||||
private String pushBaseUrl;
|
||||
|
||||
/**
|
||||
* 推送总开关:0关 1开(默认 0,自动推送默认不触发)。
|
||||
*/
|
||||
@Column(name = "push_enabled", nullable = false)
|
||||
private Integer pushEnabled = 0;
|
||||
|
||||
/**
|
||||
* 启用的接口路径,逗号分隔(如 group/AddGroup,user/AddUser);空=不推送任何接口。
|
||||
*/
|
||||
@Column(name = "push_event_types")
|
||||
private String pushEventTypes;
|
||||
|
||||
/**
|
||||
* 关联学校组 id,逗号分隔;空=全局(监听所有学校变化)。
|
||||
*/
|
||||
@Column(name = "push_school_ids")
|
||||
private String pushSchoolIds;
|
||||
|
||||
/**
|
||||
* 推送角色 key,逗号分隔;空=全部角色。
|
||||
*/
|
||||
@Column(name = "push_role_ids")
|
||||
private String pushRoleIds;
|
||||
|
||||
/**
|
||||
* 推送签名密钥;空则复用 appSecret。
|
||||
*/
|
||||
@Column(name = "push_secret")
|
||||
private String pushSecret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
import javax.persistence.*;
|
||||
|
||||
/** Permanent credential-key tombstone. Application deletion must never delete this row. */
|
||||
@Entity @Table(name="application_key_reservation")
|
||||
public class ApplicationKeyReservation {
|
||||
/**
|
||||
* 历史身份审计未完成的机械门禁哨兵(sql/20261026_application_source_principal_draft.sql §Gate):
|
||||
* 该行存在=禁止发放任何新 appKey。取 -1 而非 0:默认 sql_mode 下字面 0 会被
|
||||
* AUTO_INCREMENT 改写为下一个生成值,-1 按字面落库且低于保留段,即使被返回也会被
|
||||
* allocateAppKey 的 key<100000 熔断拒绝。
|
||||
*/
|
||||
public static final Integer GATE_CLOSED_SENTINEL = -1;
|
||||
@Id @GeneratedValue(strategy=GenerationType.IDENTITY)
|
||||
@Column(name="app_key")
|
||||
private Integer appKey;
|
||||
public Integer getAppKey(){return appKey;}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
|
||||
import javax.persistence.*;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
import com.yunhai.userbase.common.listener.UserAuditListener;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/14 10:46
|
||||
* @description
|
||||
*/
|
||||
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(UserAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = ValidationGroups.Update.class)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* 创建人ID
|
||||
*/
|
||||
@Column(name = "create_user", nullable = false, updatable = false)
|
||||
private Integer createUser;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@CreationTimestamp
|
||||
@Column(name = "create_time", nullable = false, updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新人ID
|
||||
*/
|
||||
@Column(name = "update_user")
|
||||
private Integer updateUser;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@UpdateTimestamp
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 应用业务角色。<br>
|
||||
* 普通角色:allAppFlag=false 且 appId 必填;<br>
|
||||
* 全应用角色:allAppFlag=true 且 appId 为空;<br>
|
||||
* 注意:这里的“业务角色”不等同于 {@code sys_user.roles} 对应的系统身份枚举。<br>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_app_role", indexes = {
|
||||
@Index(name = "idx_app_role_app", columnList = "app_id"),
|
||||
@Index(name = "idx_app_role_all_app", columnList = "all_app_flag"),
|
||||
@Index(name = "idx_app_role_name", columnList = "name")
|
||||
})
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysAppRole extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 角色名称。<br>
|
||||
*/
|
||||
@NotBlank
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 所属应用ID(逻辑外键 → ApplicationInfo.id)。<br>
|
||||
* 普通角色必填;全应用角色为空。<br>
|
||||
*/
|
||||
@Column(name = "app_id")
|
||||
private Integer appId;
|
||||
|
||||
/**
|
||||
* 是否全应用角色。<br>
|
||||
* true=可跨应用勾选模块;false=仅能授权所属应用下的模块。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "all_app_flag", nullable = false)
|
||||
private Boolean allAppFlag = Boolean.FALSE;
|
||||
|
||||
/**
|
||||
* 角色备注。<br>
|
||||
*/
|
||||
@Column(name = "remark", length = 500)
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 是否禁用。<br>
|
||||
* 禁用后角色关系可保留,但权限计算时应忽略该角色。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "disabled", nullable = false)
|
||||
private Boolean disabled = Boolean.FALSE;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 业务角色-模块权限关系表(逻辑外键,无物理 FK)。<br>
|
||||
* 存在记录即代表该角色显式拥有该模块。<br>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_app_role_module_rel",
|
||||
indexes = {
|
||||
@Index(name = "idx_app_role_module_role", columnList = "role_id"),
|
||||
@Index(name = "idx_app_role_module_module", columnList = "module_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_app_role_module", columnNames = {"role_id", "module_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysAppRoleModuleRel extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "role_id", nullable = false)
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 模块ID(逻辑外键 → AppModule.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "module_id", nullable = false)
|
||||
private Integer moduleId;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import com.yunhai.userbase.common.converter.GroupTypeEnumConverter;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/13 22:48
|
||||
* @description 系统节点(组)表
|
||||
*/
|
||||
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_group")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysGroup extends BaseEntity {
|
||||
|
||||
private String name;
|
||||
|
||||
@Column(name = "parent_id", nullable = false)
|
||||
private Integer parentId;
|
||||
|
||||
@Column(name = "type")
|
||||
@Convert(converter = GroupTypeEnumConverter.class)
|
||||
private GroupTypeEnum type;
|
||||
|
||||
private Integer sort;
|
||||
|
||||
/**
|
||||
* 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。<br>
|
||||
* 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析);
|
||||
* 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。
|
||||
*/
|
||||
@Column(name = "status")
|
||||
private Integer status = 0;
|
||||
|
||||
/**
|
||||
* 包含子组(2026-10-21 部门线):1=部门列表含下级部门 / 0=仅一级部门,仅学校节点使用。<br>
|
||||
* 与 status 同为节点自身属性,翻转只允许走 {@code updateIncludeChildren} 单列 UPDATE。
|
||||
*/
|
||||
@Column(name = "include_children")
|
||||
private Integer includeChildren = 1;
|
||||
|
||||
|
||||
// 一对多反向关联中间表
|
||||
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "groupId", insertable = false, updatable = false)
|
||||
private List<SysUserGroupRel> userGroupRelList;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/23
|
||||
* @description 组应用开通表(逻辑外键,无物理 FK)。<br>
|
||||
* 记录某个组显式开通了哪些应用;仅当本表存在记录时,「组授权」界面右侧才显示该应用。<br>
|
||||
* 开通不继承:子组要使用应用必须自己开通。<br>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_group_app_rel",
|
||||
indexes = {
|
||||
@Index(name = "idx_group_app_rel_app", columnList = "app_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_group_app", columnNames = {"group_id", "app_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysGroupAppRel extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 组ID(逻辑外键 → SysGroup.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "group_id", nullable = false)
|
||||
private Integer groupId;
|
||||
|
||||
/**
|
||||
* 应用ID(逻辑外键 → ApplicationInfo.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "app_id", nullable = false)
|
||||
private Integer appId;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/23
|
||||
* @description 组模块权限表(逻辑外键,无物理 FK)。<br>
|
||||
* 存在记录即代表该组勾选了该模块;inheritFlag=true 时当前组及其所有子孙组自动继承该模块权限。<br>
|
||||
* 取消勾选时物理删除记录。<br>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_group_module_perm",
|
||||
indexes = {
|
||||
@Index(name = "idx_group_module_perm_module", columnList = "module_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_group_module", columnNames = {"group_id", "module_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysGroupModulePerm extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 组ID(逻辑外键 → SysGroup.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "group_id", nullable = false)
|
||||
private Integer groupId;
|
||||
|
||||
/**
|
||||
* 模块ID(逻辑外键 → AppModule.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "module_id", nullable = false)
|
||||
private Integer moduleId;
|
||||
|
||||
/**
|
||||
* 是否子组继承(false=仅本组生效,true=子孙组自动继承),对应 UI「子组继承」勾选框。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "inherit_flag", nullable = false)
|
||||
private Boolean inheritFlag = Boolean.FALSE;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
|
||||
*/
|
||||
@Column(name = "response_size")
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* 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,146 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
/**
|
||||
* 系统操作日志实体。<br>
|
||||
* 记录用户的关键操作(增删改查),便于审计与问题排查。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_operation_log", indexes = {
|
||||
@Index(name = "idx_create_time", columnList = "create_time"),
|
||||
@Index(name = "idx_user_time", columnList = "user_id, create_time"),
|
||||
@Index(name = "idx_uri_time", columnList = "request_uri, create_time"),
|
||||
@Index(name = "idx_level_time", columnList = "log_level, create_time"),
|
||||
@Index(name = "idx_ip_time", columnList = "request_ip, create_time")
|
||||
})
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysOperationLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@EqualsAndHashCode.Include
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)
|
||||
*/
|
||||
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer logLevel;
|
||||
|
||||
/**
|
||||
* 操作用户ID
|
||||
*/
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 操作用户名
|
||||
*/
|
||||
@Column(name = "username", nullable = false, length = 64)
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 请求IP
|
||||
*/
|
||||
@Column(name = "request_ip", nullable = false, length = 64)
|
||||
private String requestIp;
|
||||
|
||||
/**
|
||||
* 请求路径
|
||||
*/
|
||||
@Column(name = "request_uri", nullable = false, length = 512)
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* 请求方法 GET/POST/PUT/DELETE
|
||||
*/
|
||||
@Column(name = "request_method", nullable = false, length = 16)
|
||||
private String requestMethod;
|
||||
|
||||
/**
|
||||
* 操作类型 DELETE/CREATE/UPDATE/QUERY
|
||||
*/
|
||||
@Column(name = "operation_type", nullable = false, length = 32)
|
||||
private String operationType;
|
||||
|
||||
/**
|
||||
* 类名.方法名
|
||||
*/
|
||||
@Column(name = "class_method", nullable = false, length = 512)
|
||||
private String classMethod;
|
||||
|
||||
/**
|
||||
* 请求参数JSON
|
||||
*/
|
||||
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||
private String requestParams;
|
||||
|
||||
/**
|
||||
* 响应结果(GZIP压缩)
|
||||
*/
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData;
|
||||
|
||||
/**
|
||||
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
|
||||
*/
|
||||
@Column(name = "response_size")
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.yunhai.userbase.common.converter.ListIntegerJsonConverter;
|
||||
import com.yunhai.userbase.common.converter.StatusEnumConverter;
|
||||
import com.yunhai.userbase.common.converter.UserTypeEnumConverter;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/13 23:30
|
||||
* @description 系统用户表
|
||||
*/
|
||||
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_user")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysUser extends BaseEntity {
|
||||
|
||||
@Column(name = "username", nullable = false, unique = true)
|
||||
private String account;
|
||||
private String password;
|
||||
//出生日期
|
||||
private LocalDate birthday;
|
||||
|
||||
@JsonProperty("name")
|
||||
private String realName;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String idcard;
|
||||
|
||||
private String email;
|
||||
|
||||
private Integer gender;
|
||||
|
||||
//头像
|
||||
private String profilePicture;
|
||||
|
||||
/**
|
||||
* 角色列表,存储为 MySQL JSON 类型,读取时自动映射为 List<Long>
|
||||
* 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3]
|
||||
*/
|
||||
@Convert(converter = ListIntegerJsonConverter.class)
|
||||
@Column(name = "roles", columnDefinition = "json")
|
||||
private List<Integer> roles;
|
||||
|
||||
// 一对多:关联用户分组中间表,实现多对多业务
|
||||
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "userId", insertable = false, updatable = false)
|
||||
private List<SysUserGroupRel> userGroupRelList;
|
||||
|
||||
// 是否禁用:存储为 StatusEnum(0=正常/未禁用,1=禁用),与 GroupTypeEnum 同机制;默认启用
|
||||
@Convert(converter = StatusEnumConverter.class)
|
||||
private StatusEnum disabled;
|
||||
//是否过期
|
||||
private Boolean expired=false;
|
||||
|
||||
/**
|
||||
* 用户类型:0=普通用户,1=超级管理员,2=学校管理员(见 UserTypeEnum)。
|
||||
* 由应用层根据用户账号/角色推导:内置超级管理员账号固定为 1,其余账号含 ADMIN(7) 角色则为 2,默认 0。
|
||||
*/
|
||||
@Convert(converter = UserTypeEnumConverter.class)
|
||||
private UserTypeEnum type;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。<br>
|
||||
* 一行 = 用户 u 在组 g 下拥有角色 r;一个用户可在多个组下分别绑定角色,同一组下同一角色唯一。<br>
|
||||
* 组维度(2026-09-16):groupId = 绑定发生时所选的组(角色授权页添加用户弹窗左侧树选中组 / 用户编辑弹窗所在组),
|
||||
* NULL = 历史数据视为全局生效(代码侧兜底,不参与按组过滤的排除)。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_user_app_role_rel",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
|
||||
@Index(name = "idx_user_app_role_role", columnList = "role_id"),
|
||||
@Index(name = "idx_user_app_role_group", columnList = "group_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_user_app_role_group", columnNames = {"user_id", "role_id", "group_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysUserAppRoleRel extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 用户ID(逻辑外键 → SysUser.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 角色ID(逻辑外键 → SysAppRole.id)。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "role_id", nullable = false)
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 绑定发生时所选的组ID(逻辑外键 → SysGroup.id);NULL = 历史数据视为全局。<br>
|
||||
*/
|
||||
@Column(name = "group_id")
|
||||
private Integer groupId;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户-移动渠道绑定关系(移动端接入 步 3,方案 §3.13)。
|
||||
*
|
||||
* <p>openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经
|
||||
* {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐:
|
||||
* wxgzh / miniapp / qywx / dingtalk。</p>
|
||||
*
|
||||
* <p>⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个),
|
||||
* 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。</p>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/9/25
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_user_channel_rel",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"channel", "openid"}),
|
||||
@UniqueConstraint(columnNames = {"user_id", "channel"})
|
||||
},
|
||||
indexes = {@Index(columnList = "unionid")}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysUserChannelRel {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** sys_user.id */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** wxgzh / miniapp / qywx / dingtalk */
|
||||
@Column(nullable = false, length = 20)
|
||||
private String channel;
|
||||
|
||||
/** 渠道内用户标识 */
|
||||
@Column(nullable = false, length = 64)
|
||||
private String openid;
|
||||
|
||||
/** 同一开放平台下跨端一致;可空 */
|
||||
@Column(name = "unionid", length = 64)
|
||||
private String unionId;
|
||||
|
||||
/** 绑定时间(服务端 now() 写入) */
|
||||
@Column(name = "bound_at", nullable = false)
|
||||
private LocalDateTime boundAt;
|
||||
|
||||
/** 最近一次经该渠道登录时间(token 签发时更新,可空) */
|
||||
@Column(name = "last_login_at")
|
||||
private LocalDateTime lastLoginAt;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 15:05
|
||||
* @description 用户当前角色
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(
|
||||
name = "sys_user_current_role",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}),
|
||||
indexes = {@Index(columnList = "userId")}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysUserCurrentRole {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = ValidationGroups.Update.class)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* 用户ID,一对一唯一
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 用户切换选中的身份归属学校(恒为真实组 id:学校组/区域组/根直属=根节点 id;原「区域/学段/根直属=0或null」约定废弃)
|
||||
*/
|
||||
private Integer schoolId;
|
||||
/**
|
||||
* 用户切换后生效角色ID(SysRoleEnum.key)
|
||||
*/
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 最近一次切换时间(服务端 now() 写入)
|
||||
*/
|
||||
@Column(name = "switch_time")
|
||||
private LocalDateTime switchTime;
|
||||
|
||||
/**
|
||||
* 学校名冗余(避免每次 join sys_group 取 name)
|
||||
*/
|
||||
@Column(name = "school_name", length = 100)
|
||||
private String schoolName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import com.yunhai.userbase.common.converter.UserGroupRelStatusEnumConverter;
|
||||
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
|
||||
|
||||
import javax.persistence.*;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 15:03
|
||||
* @description 组用户多对多关联表
|
||||
*/
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "sys_user_group_rel")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysUserGroupRel {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = Update.class)
|
||||
private Integer id;
|
||||
/** 用户ID */
|
||||
private Integer userId;
|
||||
/** 分组ID */
|
||||
private Integer groupId;
|
||||
/** 是否主归属 1=本职主学校/部门 0=兼职兼任 */
|
||||
private Integer isMain;
|
||||
|
||||
/** 组内排序号:越小越靠前;null 表示未设置,列表按 user_id 兜底升序 */
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
|
||||
/**
|
||||
* 组内状态:0=正常 1=转出(存量数据 NULL 视为正常)。<br>
|
||||
* 转出仅作标记,不影响成员查询、组织链与下游推送;
|
||||
* 改组/移动经 changeGroups 覆盖式重建关系行时状态随用户继承(同组照抄旧状态,新增组在转出流程中继承转出)。
|
||||
*/
|
||||
@Convert(converter = UserGroupRelStatusEnumConverter.class)
|
||||
private UserGroupRelStatusEnum status;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
// 关联用户,仅查询,不维护外键
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "userId", referencedColumnName = "id", insertable = false, updatable = false)
|
||||
private SysUser user;
|
||||
|
||||
// 关联分组,仅查询,不维护外键
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "groupId", referencedColumnName = "id", insertable = false, updatable = false)
|
||||
private SysGroup group;
|
||||
|
||||
|
||||
public @interface Update {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,71 @@
|
||||
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.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/10/21
|
||||
* @description 学校自定义部门(CUSTOM 侧可指派对象:有成员、有启停、删除为软删)。<br>
|
||||
* 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点;
|
||||
* 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。<br>
|
||||
* 唯一性由 {@code uk_school_category_name_marker(school_id, category_id, name, del_marker)} 保证:
|
||||
* 同校同分类下未软删的部门名不重复,跨分类与 SYSTEM/CUSTOM 之间允许重名。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "ub_custom_department")
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class UbCustomDepartment extends BaseEntity {
|
||||
|
||||
/** 学校组 id(冗余自分类,写口由服务端复核,不接受前端传值做授权)。 */
|
||||
@Column(name = "school_id", nullable = false)
|
||||
private Integer schoolId;
|
||||
|
||||
/** 所属分类(必须同校,移动只允许在本校其他分类之间)。 */
|
||||
@Column(name = "category_id", nullable = false)
|
||||
private Integer categoryId;
|
||||
|
||||
/** 部门名称,学校自定;软删也不改写。 */
|
||||
@Column(name = "name", nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
@Column(name = "sort_no", nullable = false)
|
||||
private Integer sortNo = 0;
|
||||
|
||||
/**
|
||||
* 启用状态:1=启用 / 0=停用。<br>
|
||||
* ⚠ 语义与 {@code sys_group.status}(0=启用/1=停用)方向相反,严禁混用;接口层统一输出 boolean enabled。
|
||||
*/
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private Integer enabled = 1;
|
||||
|
||||
/** 软删标记:0=有效 / 1=已软删;软删后成员关系保留在库,由查询侧过滤。 */
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Integer isDeleted = 0;
|
||||
|
||||
/** 软删时置为该行 id,参与唯一键。 */
|
||||
@Column(name = "del_marker", nullable = false)
|
||||
private Long delMarker = 0L;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
@Column(name = "deleted_by")
|
||||
private Integer deletedBy;
|
||||
|
||||
public boolean isDeletedRow() {
|
||||
return isDeleted != null && isDeleted == 1;
|
||||
}
|
||||
|
||||
public boolean isEnabledRow() {
|
||||
return enabled != null && enabled == 1;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user