feat(auth): 实现统一认证模块及登录策略
- 新增 ApiResult 统一响应封装类,规范前端接口返回结构 - 添加 AuthController,支持多种登录方式及用户信息接口 - 实现 AuthService,包含登录、登出、密码修改、绑定手机号、重置密码等功能 - 集成图形验证码校验和登录失败锁定机制,增强安全性 - 支持邮箱登录和手机号验证码登录,完善多样化认证策略 - 实现 JWT 令牌签发与吊销,确保会话安全管理 - 配置不同环境 application.yml 及配置,支持多租户和灵活缓存配置 - 增加 .gitignore 规则,屏蔽构建产物、IDE 文件和敏感配置 - 引入 AuthProperties 以配置登录与验证码策略参数
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# 构建产物
|
||||
target/
|
||||
**/target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.settings/
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
logs/
|
||||
*.tmp
|
||||
|
||||
# 环境与密钥(禁止入库):config/ 只保留示例模板,真实密钥不入库
|
||||
/config/*
|
||||
!/config/iam-jwt.properties.example
|
||||
.env
|
||||
.env.local
|
||||
src/main/resources/application-local.yml
|
||||
*.pem
|
||||
*.p12
|
||||
*.jks
|
||||
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
@@ -0,0 +1,32 @@
|
||||
# ---------- 构建阶段:JDK 25 + Maven ----------
|
||||
FROM maven:3.9-eclipse-temurin-25 AS build
|
||||
WORKDIR /build
|
||||
|
||||
# 先只复制 pom,利用镜像层缓存加速依赖下载
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -ntp dependency:go-offline
|
||||
|
||||
COPY src ./src
|
||||
RUN mvn -B -ntp clean package -DskipTests
|
||||
|
||||
# ---------- 运行阶段:JRE 25 ----------
|
||||
FROM eclipse-temurin:25-jre-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/logs /app/config && chmod 755 /app/logs /app/config
|
||||
|
||||
COPY --from=build /build/target/iam-api-*.jar app.jar
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
SPRING_PROFILES_ACTIVE=prod \
|
||||
# netty 4.1 仍使用 sun.misc.Unsafe 内存访问,JDK 25 下显式放行以消除告警
|
||||
# -Duser.timezone 独立于操作系统时区库,避免 alpine 镜像里 TZ 不生效导致时间写入 UTC
|
||||
JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC --sun-misc-unsafe-memory-access=allow -Duser.timezone=Asia/Shanghai"
|
||||
|
||||
EXPOSE 8900
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8900/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
|
||||
@@ -0,0 +1,256 @@
|
||||
# iam-api · 统一身份中心
|
||||
|
||||
> 技术栈:Java 25 + Spring Boot 3.5 + MyBatis-Plus + Spring Security + Flyway + Redis
|
||||
> 定位:**open-api 与 dp-api 共用的唯一账号源**,让数据中台可以脱离开放平台独立售卖。
|
||||
|
||||
## 这个服务解决什么问题
|
||||
|
||||
此前 `dp-web` 控制台的登录直接复用 `open-api` 的账号体系,`dp-api` 没有自己的身份。
|
||||
一旦 `dp-api` 要独立对外售卖 / 部署,账号体系就成了耦合点。iam-api 把「用户 / 租户 /
|
||||
角色 / 权限 / 菜单 / 登录 / 令牌签发」抽成独立服务,`open-api` 与 `dp-api` 都只作为
|
||||
它的**资源服务**:通过 JWKS 验签,不共享用户表、不共享对称密钥。
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ iam-api │
|
||||
│ 账号/租户/角色/权限/菜单/令牌 │
|
||||
└───────────┬──────────────────┘
|
||||
RS256 令牌 + JWKS │ RS256 令牌 + JWKS
|
||||
┌─────────────────────┴─────────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ open-api │ │ dp-api │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
详细规划见 [`docs/iam-api-dev-plan.md`](docs/iam-api-dev-plan.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:零依赖演示(推荐先跑这个)
|
||||
|
||||
```bash
|
||||
# H2 内存库 + 进程内缓存,不连 MySQL / Redis
|
||||
./run.sh demo
|
||||
|
||||
# 或
|
||||
./mvnw -Pdemo spring-boot:run -Dspring-boot.run.profiles=demo
|
||||
```
|
||||
|
||||
启动后访问 `http://localhost:8900/swagger-ui.html`(演示环境已开接口文档)。
|
||||
演示 profile 默认仍开启图形验证码,联调时可临时关掉:
|
||||
|
||||
```bash
|
||||
./mvnw -Pdemo spring-boot:run -Dspring-boot.run.profiles=demo \
|
||||
-Dspring-boot.run.arguments=--config.auth.captcha-enabled=false
|
||||
```
|
||||
|
||||
### 方式二:连接真实依赖
|
||||
|
||||
```bash
|
||||
cp config/iam-jwt.properties.example config/iam-jwt.properties
|
||||
bash scripts/gen-iam-key.sh >> config/iam-jwt.properties # 生成 RS256 密钥
|
||||
|
||||
export DB_HOST=127.0.0.1 DB_USERNAME=root DB_PASSWORD=yourpass DB_NAME=iam
|
||||
export REDIS_HOST=127.0.0.1 INTERNAL_KEY=your-internal-key JWT_SECRET=your-32byte-secret
|
||||
|
||||
./run.sh # dev profile
|
||||
```
|
||||
|
||||
> 系统 `java` 若是 17,直接 `./mvnw` 会报 `release version 25 not supported`,
|
||||
> 用 `./run.sh`(内部固定 JDK 25)或自行 `export JAVA_HOME=<jdk25>`。
|
||||
|
||||
初始账号:`admin / admin123`(租户 1)。首次登录后请立刻改密码。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/main/java/com/gxwebsoft/
|
||||
├─ common/core/ 内核:返回结构、异常、分页、租户上下文、缓存抽象、安全过滤器
|
||||
│ ├─ cache/ CacheStore:RedisCacheStore / MemoryCacheStore
|
||||
│ ├─ config/ SecurityConfig / MybatisPlusConfig / CacheConfig ...
|
||||
│ ├─ security/ HS256 会话令牌(JwtUtil / JwtAuthenticationFilter / 吊销名单)
|
||||
│ └─ web/ ApiResult / PageParam / BaseController / TenantFilter
|
||||
├─ common/system/ 身份与授权:租户 / 用户 / 角色 / 菜单 / 登录审计 / 验证码
|
||||
│ ├─ controller/ AuthController、CaptchaController、User/Role/Menu/TenantController
|
||||
│ ├─ entity/ mapper/ param/ sys_tenant / sys_user / sys_role / sys_menu / sys_login_record
|
||||
│ └─ security/ SystemLoginUserProvider(登录主体 + 权限集合)
|
||||
└─ iam/ 统一令牌与接入方
|
||||
├─ config/ IamJwtConfig(RS256 + JWKS)
|
||||
├─ controller/ IamOAuthController(/api/v1/oauth/**)、IamClientController
|
||||
└─ entity/ iam_client、iam_auth_code
|
||||
```
|
||||
|
||||
## 接口一览
|
||||
|
||||
| 分组 | 接口 | 认证 |
|
||||
|---|---|---|
|
||||
| 认证 | `POST /api/login`、`POST /api/loginByEmail`、`POST /api/loginBySms` | 免登录 |
|
||||
| 认证 | `POST /api/logout`、`GET /api/me`、`POST /api/changePassword`、`POST /api/bindPhone[/captcha]` | 会话令牌 |
|
||||
| 认证 | `POST /api/resetPassword` | 免登录(凭验证码) |
|
||||
| 验证码 | `GET /api/captcha`、`POST /api/sendSmsCaptcha`、`POST /api/sendEmailCaptcha` | 免登录 |
|
||||
| 租户 | `GET/POST/PUT/DELETE /api/iam/tenant`、`POST /api/iam/tenant/open` | 权限点 `iam:tenant:*` |
|
||||
| 用户 | `GET/POST/PUT/DELETE /api/iam/user`、`POST /api/iam/user/{id}/resetPassword` | 权限点 `iam:user:*` |
|
||||
| 角色 | `GET/POST/PUT/DELETE /api/iam/role`、`PUT /api/iam/role/{id}/menus` | 权限点 `iam:role:*` |
|
||||
| 菜单 | `GET /api/iam/menu/tree`、`POST/PUT/DELETE /api/iam/menu` | 权限点 `iam:menu:*` |
|
||||
| 接入方 | `GET/POST/PUT/DELETE /api/iam/client`、`POST /api/iam/client/{id}/secret` | 权限点 `iam:client:*` |
|
||||
| 令牌 | `POST /api/v1/oauth/authorize`、`POST /api/v1/oauth/token` | 终端用户 / client_id+secret |
|
||||
| 令牌 | `GET /api/v1/oauth/jwks` | 免登录 |
|
||||
| 令牌 | `POST /api/v1/oauth/introspect`、`POST /api/v1/oauth/revoke` | `X-Internal-Key` |
|
||||
|
||||
统一响应:`{"code":0,"message":"操作成功","data":{...}}`,与既有控制台契约一致。
|
||||
|
||||
### 令牌怎么用(资源服务视角)
|
||||
|
||||
1. 启动时拉取 `http://iam-host/api/v1/oauth/jwks`,缓存公钥。
|
||||
2. 校验 `Authorization: Bearer <token>` 的签名、`iss`、`exp`、`aud`,并确认
|
||||
`token_type == access`(refresh token 不能当 access token 用)。
|
||||
3. 从 claim 里取 `tenant_id` / `user_id` / `roles` / `scope` 做业务授权。
|
||||
4. 需要感知吊销时,再用 `X-Internal-Key` 调 introspect 复核。
|
||||
|
||||
access token 默认 2 小时,refresh token 默认 30 天;**刷新即轮换**,接续方必须
|
||||
用返回的新 refreshToken 覆盖本地,否则下次刷新会失败。
|
||||
|
||||
### 内置接入方
|
||||
|
||||
迁移脚本预置了两条,`client_id` 分别是 `open-api` 与 `dp-api`,**演示密钥为
|
||||
`admin123`**(与初始管理员密码同一个 BCrypt 哈希,便于开箱联调)。
|
||||
**上线前必须在「接入方管理」里重置为随机密钥并妥善保存**,明文只在创建 / 重置时返回一次。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置 | 说明 | 默认 |
|
||||
|---|---|---|
|
||||
| `server.port` | 服务端口 | `8900` |
|
||||
| `iam.cache.type` | `redis` / `memory` | `redis`(demo/test 为 `memory`) |
|
||||
| `config.jwt.secret` | 控制台会话令牌密钥(HS256,≥32 字节) | 开发默认值,生产必填 |
|
||||
| `config.iam-jwt.private-key` | 资源服务令牌私钥(RS256,PEM) | 缺省时临时生成并告警 |
|
||||
| `config.iam-jwt.issuer` / `audience` | 令牌 `iss` / 默认 `aud` | `https://iam.websoft.top` / `websoft-platform` |
|
||||
| `config.iam-jwt.access-expire` / `refresh-expire` | 令牌有效期(秒) | `7200` / `2592000` |
|
||||
| `config.internal-key` | introspect / revoke 自证密钥 | `change-me`,生产必填 |
|
||||
| `config.auth.captcha-enabled` | 登录是否强制图形验证码 | `true` |
|
||||
| `config.auth.login-fail-threshold` / `login-lock-seconds` | 失败锁定策略 | `5` 次 / `600` 秒 |
|
||||
| `config.auth.register-enabled` | 自助注册开关(当前无注册接口,账号由平台运营开通) | `false` |
|
||||
| `config.sms.enabled` | 是否真正下发短信 | `false`(仅写日志) |
|
||||
| `config.mail.enabled` / `spring.mail.*` | 邮件验证码通道 | 未配 SMTP 时仅写日志 |
|
||||
| `spring.flyway.user` / `spring.flyway.password` | Flyway 迁移专用账号(`prod` 必填) | 无默认值,缺失即启动失败 |
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
必须提供的环境变量:`DB_PASSWORD`、`JWT_SECRET`、`INTERNAL_KEY`,以及(首次启动前)
|
||||
挂载到 `/app/config/iam-jwt.properties` 的 RS256 密钥。
|
||||
`prod` 还需要 `FLYWAY_USER` / `FLYWAY_PASSWORD`(迁移专用账号,见下方常见问题)。
|
||||
详见 [`docker-compose.yml`](docker-compose.yml)。
|
||||
|
||||
无 1Panel / 单机部署:`./mvnw clean package` 产出 `target/iam-api-0.1.0.jar`,
|
||||
`java -jar` 启动,用 `SPRING_PROFILES_ACTIVE=prod` 与上面的环境变量即可。
|
||||
|
||||
### 部署检查清单
|
||||
|
||||
| # | 检查项 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | RS256 密钥已固定 | 不配 `private-key` 会每次重启换密钥,已签发令牌全部失效 |
|
||||
| 2 | `JWT_SECRET` / `INTERNAL_KEY` 已换成随机值 | 默认值只能用于本地 |
|
||||
| 3 | 内置接入方密钥已重置 | `open-api` / `dp-api` 的演示密钥是 `admin123` |
|
||||
| 4 | `admin` 初始密码已修改 | 演示密码 `admin123` |
|
||||
| 5 | Redis 已接入并纳入健康检查 | 验证码、失败计数、令牌吊销都依赖它 |
|
||||
| 6 | 接口文档按需关闭 | 生产默认 `API_DOCS_ENABLED=false` |
|
||||
| 7 | 反向代理放行 `Authorization` 与 `X-Internal-Key` | 漏掉会导致「登录成功但接口 401」 |
|
||||
| 8 | `FLYWAY_USER` / `FLYWAY_PASSWORD` 已配好 | 迁移账号需有库 DDL 权限 + `performance_schema.user_variables_by_thread` 的 SELECT,建号脚本见 `scripts/create-flyway-user.sql` |
|
||||
|
||||
## 与另外两个服务的关系(迁移路径)
|
||||
|
||||
| 阶段 | 动作 |
|
||||
|---|---|
|
||||
| 现在 | iam-api 可独立运行,接口与数据自洽 |
|
||||
| 下一步 | open-api 改为 iam-api 的接入方:登录代理 / 跳转,`/api/me` 改读 iam-api |
|
||||
| 下一步 | dp-api 接入 JWKS 验签,用 `tenant_id` 做数据隔离 |
|
||||
| 之后 | 存量用户从 `db_open.sys_user` 迁移到 iam-api,迁移期以 iam-api 为准、禁止双向写 |
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
./mvnw test # H2 + 内存缓存,覆盖登录 / 权限 / 令牌 / JWKS / 自省 / 授权码
|
||||
./mvnw clean package # 生产包(不含 H2)
|
||||
```
|
||||
|
||||
## 当前未完成(见开发计划 M5/M6)
|
||||
|
||||
- SSO 会话表与跨域免登票据、MFA、登录设备管理。
|
||||
- open-api / dp-api 的接入改造与存量用户迁移脚本。
|
||||
- 真实的短信 / 邮件通道实现(当前预留 `SmsSender` 扩展点,默认只写日志)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 启动报 `SELECT command denied to user 'xxx'@'...' for table 'user_variables_by_thread'`
|
||||
|
||||
不是业务代码的查询,是 **Flyway 的 MySQL 支持在建立连接时固定执行的探测**:
|
||||
它会读 `performance_schema.user_variables_by_thread`(MariaDB 则是
|
||||
`information_schema.user_variables`)来记录 `foreign_key_checks` / `sql_safe_updates`,
|
||||
迁移结束后再用 `SET foreign_key_checks=?, sql_safe_updates=?` 还原。是否启用该探测
|
||||
**只按数据库版本判断(MySQL ≥5.7 就启用),不看账号权限**,所以业务账号权限被收敛后就会撞上。
|
||||
|
||||
#### 本项目的选择:方案 B —— Flyway 用独立账号
|
||||
|
||||
`application-prod.yml` 已经启用:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
flyway:
|
||||
user: ${FLYWAY_USER}
|
||||
password: ${FLYWAY_PASSWORD}
|
||||
```
|
||||
|
||||
理由:业务账号的权限是被有意收敛的(它不该有 DDL 权限),为了跑迁移去给它开
|
||||
`performance_schema` 反而放大了它的权限面。迁移用独立账号,权限各归各位。
|
||||
|
||||
落地三步:
|
||||
|
||||
```bash
|
||||
# 1) 用 root / 高权限账号建迁移账号(脚本里替换 4 个占位符后执行)
|
||||
mysql -h <host> -u root -p < scripts/create-flyway-user.sql
|
||||
|
||||
# 2) 自检:应返回一行,而不是 SELECT command denied
|
||||
mysql -h <host> -u <FLYWAY_USER> -p -e \
|
||||
"SELECT VARIABLE_VALUE FROM performance_schema.user_variables_by_thread WHERE VARIABLE_NAME='foreign_key_checks' LIMIT 1;"
|
||||
|
||||
# 3) 部署时注入变量
|
||||
export FLYWAY_USER=flyway_iam FLYWAY_PASSWORD='<强密码>'
|
||||
```
|
||||
|
||||
两个设计细节值得知道:
|
||||
|
||||
- **不需要配 `spring.flyway.url`**。只给 `user`/`password` 时,Spring Boot 会从主数据源的
|
||||
URL 与驱动派生一个只换账号密码的迁移数据源(已实测派生出的 url 与主库完全一致),
|
||||
所以 JDBC URL 只有 `datasource` 一处,不用两边同步维护。
|
||||
- **两个变量故意不给默认值**。`spring.flyway.user` 为空时 Spring 会静默回退到业务账号,
|
||||
也就是再次撞上同一个权限错误——而且是换一副面孔报出来。让它缺变量就启动失败,
|
||||
比事后排查便宜得多。
|
||||
|
||||
#### 备选方案
|
||||
|
||||
**A. 给业务账号补权限**(如果以后想少一个账号,且托管库允许)
|
||||
|
||||
```sql
|
||||
-- 主机按报错里的来源地址写;账号若建在 % 上就写 '%'
|
||||
GRANT SELECT ON performance_schema.user_variables_by_thread TO '业务账号'@'来源主机';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
**C. 关掉 Flyway,手工执行迁移**(云数据库连 `performance_schema` 都不让授权时的兜底)
|
||||
|
||||
```bash
|
||||
export SPRING_FLYWAY_ENABLED=false
|
||||
|
||||
# 用有权限的账号(通常是跳板机上的 root)执行
|
||||
DB_HOST=... DB_NAME=iam DB_USERNAME=root DB_PASSWORD=... \
|
||||
bash scripts/apply-migrations.sh
|
||||
```
|
||||
|
||||
脚本按文件名顺序执行 `src/main/resources/db/migration/*.sql`,密码走 `MYSQL_PWD`、
|
||||
不会出现在 `ps` 里。它不写 `flyway_schema_history`;之后若要交回 Flyway,
|
||||
应用的 `baseline-on-migrate=true` 会先打基线再跳过已应用的版本。
|
||||
@@ -0,0 +1,19 @@
|
||||
# RS256 统一令牌签名密钥(PEM,单行即可,换行会被去掉)。
|
||||
#
|
||||
# 使用方式:
|
||||
# cp config/iam-jwt.properties.example config/iam-jwt.properties
|
||||
# bash scripts/gen-iam-key.sh >> config/iam-jwt.properties
|
||||
#
|
||||
# 生产环境务必使用固定密钥:不配置时应用会在启动时临时生成,
|
||||
# 重启后此前签发的令牌全部失效,且多实例之间互不认账。
|
||||
# config/ 已在 .gitignore 中,密钥不会入库。
|
||||
|
||||
# 签发方:资源服务校验 iss 时必须完全一致
|
||||
issuer=https://iam.websoft.top
|
||||
# 默认 audience;接入方有自己的 audience 时以 iam_client.audience 为准
|
||||
audience=websoft-platform
|
||||
# 密钥标识,JWKS 中返回,用于密钥轮换
|
||||
key-id=iam-1
|
||||
# PKCS#8 私钥(可选公钥,留空则从私钥推导)
|
||||
# private-key=-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----
|
||||
# public-key=-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,81 @@
|
||||
# 1Panel / docker compose 部署示例。
|
||||
# 数据库与 Redis 若已有实例,删掉对应 service 并改环境变量指向外部地址即可。
|
||||
services:
|
||||
iam-api:
|
||||
build: .
|
||||
image: iam-api:0.1.0
|
||||
container_name: iam-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${IAM_API_PORT:-8900}:8900"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
DB_HOST: mysql
|
||||
DB_PORT: "3306"
|
||||
DB_NAME: ${DB_NAME:-iam}
|
||||
DB_USERNAME: ${DB_USERNAME:-iam}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?数据库密码必须设置}
|
||||
# Flyway 迁移账号:需要 iam 库的 DDL 权限 + performance_schema.user_variables_by_thread 的 SELECT
|
||||
# 不设置这两项会启动失败(application-prod.yml 里故意没有默认值)
|
||||
FLYWAY_USER: ${FLYWAY_USER:?Flyway 迁移账号必须设置}
|
||||
FLYWAY_PASSWORD: ${FLYWAY_PASSWORD:?Flyway 迁移账号密码必须设置}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
# 必填:生产没有默认值,缺失会启动失败
|
||||
JWT_SECRET: ${JWT_SECRET:?会话令牌密钥必须设置}
|
||||
INTERNAL_KEY: ${INTERNAL_KEY:?内部自证密钥必须设置}
|
||||
# 内网自证密钥轮换期可配多值(逗号分隔)由网关处理,此处仅单值
|
||||
HEALTH_REDIS_ENABLED: "true"
|
||||
volumes:
|
||||
# RS256 私钥:宿主机 ./config/iam-jwt.properties 挂进容器,禁止打进镜像
|
||||
- ./config:/app/config:ro
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: iam-mysql
|
||||
restart: unless-stopped
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MySQL root 密码必须设置}
|
||||
MYSQL_DATABASE: ${DB_NAME:-iam}
|
||||
MYSQL_USER: ${DB_USERNAME:-iam}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:?数据库密码必须设置}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- iam-mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: iam-redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then
|
||||
exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD";
|
||||
else
|
||||
exec redis-server --appendonly yes;
|
||||
fi'
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
volumes:
|
||||
- iam-redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a $REDIS_PASSWORD} ping | grep PONG"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
iam-mysql-data:
|
||||
iam-redis-data:
|
||||
@@ -0,0 +1,145 @@
|
||||
# iam-api 开发计划
|
||||
|
||||
> 统一身份中心(Identity & Access Management)。目标:把「用户 / 租户 / 角色 / 权限 /
|
||||
> 菜单 / 登录 / 令牌签发」从 `open-api` 里抽出来,成为 `open-api` 与 `dp-api` 共用的
|
||||
> 唯一账号源,使 `dp-api` 可以脱离 `open-api` 独立售卖与部署。
|
||||
|
||||
## 1. 定位与边界
|
||||
|
||||
### 1.1 iam-api 负责
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 身份(Identity) | 租户、用户、账号密码 / 邮箱 / 手机号、密码强度与找回、绑定手机 |
|
||||
| 授权(Access) | 角色、权限标识、菜单(菜单即权限载体)、用户-角色-菜单关系 |
|
||||
| 会话(Session) | 控制台登录态令牌(HS256)、登出吊销、登录失败锁定、登录审计 |
|
||||
| 令牌(Token) | 面向资源服务的 RS256 access token、refresh token、JWKS、introspect、revoke |
|
||||
| 客户端(Client) | `iam_client` 注册表:登记 open-api / dp-api 等消费方及其允许的 scope / aud |
|
||||
|
||||
### 1.2 iam-api 不负责
|
||||
|
||||
- 开放平台的业务语义:应用管理、OAuth2 授权页、scope 字典、令牌签发统计(留在 `open-api`)。
|
||||
- 数据中台的业务数据:服务器、客户、订单、合同、发票、支付(留在 `dp-api`)。
|
||||
- 通用底座:云存储、系统设置、字典、附件、消息、审计(后续放 `base-api`)。
|
||||
|
||||
> 边界一句话:**iam-api 只知道“你是谁、你能干什么”,不知道“你干的是什么业务”。**
|
||||
|
||||
### 1.3 与 open-api / dp-api 的关系
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ iam-api │
|
||||
│ 账号/租户/角色/权限/菜单/令牌 │
|
||||
└───────────┬──────────────────┘
|
||||
RS256 令牌 + JWKS │ RS256 令牌 + JWKS
|
||||
┌─────────────────────┴─────────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ open-api │ │ dp-api │
|
||||
│ 开放平台 │ │ 数据中台 │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
- 两个资源服务都只通过 JWKS 验签,不共享用户表、不共享对称密钥。
|
||||
- 同一个 `user_id` 可以在两个产品里拥有不同角色:
|
||||
`iam_user_role` 带 `client_id`(为空表示控制台全局角色),授权按消费者隔离。
|
||||
|
||||
## 2. 里程碑
|
||||
|
||||
### M1 内核与骨架(本期)
|
||||
|
||||
- [x] Maven 工程、Spring Boot 3.5 / Java 25、Flyway、MyBatis-Plus
|
||||
- [x] 统一返回结构、全局异常、分页与查询参数封装
|
||||
- [x] 多租户上下文与 MyBatis-Plus 租户插件
|
||||
- [x] 缓存抽象 `CacheStore`(Redis / 内存双实现,演示与测试免 Redis)
|
||||
|
||||
### M2 身份与授权(本期)
|
||||
|
||||
- [x] `sys_tenant` / `sys_user` / `sys_role` / `sys_menu` / 关联表 / 登录审计
|
||||
- [x] 租户、用户、角色、菜单的查询与管理接口
|
||||
- [x] 用户带角色与权限集合,供控制台做按钮级权限
|
||||
|
||||
### M3 登录与安全(本期)
|
||||
|
||||
- [x] 账号密码登录(图形验证码 + 失败锁定)
|
||||
- [x] 邮箱登录、手机号验证码登录
|
||||
- [x] 登出(令牌吊销)、`/api/me`、修改密码、绑定/换绑手机号、自助找回密码
|
||||
- [x] HS256 会话令牌 + 认证过滤器 + 401/403 统一处理
|
||||
|
||||
### M4 统一令牌与客户端(本期)
|
||||
|
||||
- [x] `iam_client` 客户端注册表与 scope 白名单
|
||||
- [x] RS256 令牌签发:`client_credentials` / `authorization_code(PKCE)` / `refresh_token`
|
||||
- [x] `/api/v1/oauth/jwks` 公钥集合、`/api/v1/oauth/introspect`、`/api/v1/oauth/revoke`
|
||||
- [x] 授权码存储与一次性核销(`iam_auth_code`)
|
||||
|
||||
### M5 迁出与联调(下一期)
|
||||
|
||||
- [ ] open-api 改为 iam-api 的客户端:登录跳转/代理、用户信息改读 iam-api
|
||||
- [ ] dp-api 接入:JWKS 验签 + `tenant_id` / 角色映射
|
||||
- [ ] 存量用户迁移脚本(`db_open.sys_user` → `iam_user`)与双写过渡
|
||||
- [ ] 平台运营端(账号开通、跨租户管理)迁到 iam-api 或独立的 iam-admin
|
||||
|
||||
### M6 单点登录与合规(远期)
|
||||
|
||||
- [ ] SSO 会话表 `iam_sso_session` 与跨域免登票据
|
||||
- [ ] MFA(TOTP)、登录设备管理、异常登录提醒
|
||||
- [ ] 审计中心:把登录审计升级为操作审计
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `sys_tenant` | 租户 |
|
||||
| `sys_user` | 用户(BCrypt 密码,邮箱/手机号可空) |
|
||||
| `sys_role` | 角色 |
|
||||
| `sys_user_role` | 用户-角色 |
|
||||
| `sys_menu` | 菜单 / 权限点 |
|
||||
| `sys_role_menu` | 角色-菜单 |
|
||||
| `sys_login_record` | 登录审计 |
|
||||
| `iam_client` | 接入方(client_id / secret / 允许 scope / 令牌有效期) |
|
||||
| `iam_auth_code` | OAuth2 授权码(一次性、短有效期) |
|
||||
|
||||
所有业务表统一:`del_flag` 逻辑删除、`create_time` / `update_time` 应用层填充。
|
||||
|
||||
## 4. 接口一览(本期)
|
||||
|
||||
| 分组 | 接口 |
|
||||
|---|---|
|
||||
| 认证 | `POST /api/login`、`POST /api/loginByEmail`、`POST /api/loginBySms`、`POST /api/logout`、`GET /api/me`、`POST /api/changePassword`、`POST /api/resetPassword`、`POST /api/bindPhone`、`POST /api/bindPhone/captcha` |
|
||||
| 验证码 | `GET /api/captcha`、`POST /api/sendSmsCaptcha`、`POST /api/sendEmailCaptcha` |
|
||||
| 租户 | `GET/POST/PUT/DELETE /api/iam/tenant`、`GET /api/iam/tenant/{id}` |
|
||||
| 用户 | `GET /api/iam/user`、`POST /api/iam/user`、`PUT /api/iam/user/{id}`、`DELETE /api/iam/user/{id}`、`POST /api/iam/user/{id}/resetPassword` |
|
||||
| 角色 | `GET /api/iam/role`、`POST /api/iam/role`、`PUT /api/iam/role/{id}`、`DELETE /api/iam/role/{id}`、`PUT /api/iam/role/{id}/menus` |
|
||||
| 菜单 | `GET /api/iam/menu/tree`、`POST /api/iam/menu`、`PUT /api/iam/menu/{id}`、`DELETE /api/iam/menu/{id}` |
|
||||
| 客户端 | `GET /api/iam/client`、`POST /api/iam/client`、`PUT /api/iam/client/{id}`、`DELETE /api/iam/client/{id}`、`POST /api/iam/client/{id}/secret` |
|
||||
| 令牌 | `POST /api/v1/oauth/token`、`GET /api/v1/oauth/jwks`、`POST /api/v1/oauth/introspect`、`POST /api/v1/oauth/revoke` |
|
||||
|
||||
## 5. 配置
|
||||
|
||||
| 配置项 | 说明 | 默认 |
|
||||
|---|---|---|
|
||||
| `server.port` | 服务端口 | `8900` |
|
||||
| `iam.cache.type` | 缓存实现:`redis` / `memory` | `redis`(`demo`/`test` 为 `memory`) |
|
||||
| `config.jwt.secret` | 控制台会话令牌密钥(HS256) | 开发默认值,生产必须覆盖 |
|
||||
| `config.iam-jwt.private-key` | 资源服务令牌私钥(RS256,PEM) | 缺省时启动生成临时密钥并告警 |
|
||||
| `config.internal-key` | introspect 调用方自证密钥 | `change-me` |
|
||||
|
||||
## 6. 部署
|
||||
|
||||
- 端口 `8900`,与 `open-api`(8800) / `dp-api`(18800) 并存。
|
||||
- 生产用 Docker(`Dockerfile` + `docker-compose.yml`),MySQL + Redis 由编排提供。
|
||||
- 演示与测试走 `-Pdemo` / `test` profile,H2 + 内存缓存,无外部依赖。
|
||||
- Flyway 用**独立迁移账号**(`FLYWAY_USER` / `FLYWAY_PASSWORD`),与业务账号分离:
|
||||
Flyway 建连接时要读 `performance_schema.user_variables_by_thread`,业务账号不该为此扩权。
|
||||
建号脚本 `scripts/create-flyway-user.sql`;无法授权时用 `scripts/apply-migrations.sh` 手工迁移。
|
||||
|
||||
## 7. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 双用户表导致身份漂移 | 迁移期以 iam-api 为准,open-api / dp-api 只读;禁止双向写 |
|
||||
| 令牌吊销的传播延迟 | access token 短有效期(默认 2h)+ 资源服务就近校验 + introspect 兜底 |
|
||||
| Redis 不可用导致全员无法登录 | `CacheStore` 降级策略与 `iam.cache.type` 显式切换,健康检查暴露状态 |
|
||||
| 认证与业务耦合 | 资源服务只依赖 JWKS 公钥,不引入 iam-api 的代码依赖 |
|
||||
| 迁移账号权限不足导致启动失败 | Flyway 独立账号 + 建号脚本;退路是关闭 Flyway 用 `scripts/apply-migrations.sh` |
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
Vendored
+189
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
<?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 https://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>3.5.16</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.gxwebsoft</groupId>
|
||||
<artifactId>iam-api</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>iam-api</name>
|
||||
<description>统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌签发</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
|
||||
<mybatis-plus.version>3.5.17</mybatis-plus.version>
|
||||
<druid.version>1.2.28</druid.version>
|
||||
<!-- knife4j 4.5.0 内部绑定 springdoc 2.3.0,两者必须同版本 -->
|
||||
<springdoc.version>2.3.0</springdoc.version>
|
||||
<knife4j.version>4.5.0</knife4j.version>
|
||||
<jjwt.version>0.13.0</jjwt.version>
|
||||
<hutool.version>5.8.47</hutool.version>
|
||||
<lombok.version>1.18.48</lombok.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- ========== Spring Boot 基础能力 ========== -->
|
||||
<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-security</artifactId>
|
||||
</dependency>
|
||||
<!-- 资源服务令牌:Nimbus 的 RS256 编解码与 JWKS 发布 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ========== 持久层 ========== -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<!-- 3.5.9+ 把 jsqlparser 拆成独立模块,多租户/分页插件需要它 -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>druid-spring-boot-3-starter</artifactId>
|
||||
<version>${druid.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- 数据库版本化:迁移脚本在 src/main/resources/db/migration -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ========== 缓存 / 邮件 ========== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ========== 接口文档 ========== -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.xiaoymin</groupId>
|
||||
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
||||
<version>${knife4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ========== 会话令牌:JWT(HS256) ========== -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ========== 工具库 ========== -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.whvcse</groupId>
|
||||
<artifactId>easy-captcha</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- H2 仅用于测试与 -Pdemo 本地演示,不会进生产包 -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
<!--
|
||||
演示:把 H2 提到 runtime,配合 application-demo.yml 不连 MySQL/Redis 即可启动。
|
||||
./mvnw -Pdemo spring-boot:run
|
||||
默认构建不含 H2,生产包干净。
|
||||
-->
|
||||
<profile>
|
||||
<id>demo</id>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!--
|
||||
JDK 23 起注解处理不再从 classpath 隐式启用(javac 默认 -proc:none),
|
||||
必须显式声明 Lombok 处理器,否则 @Getter/@Setter 不生效。
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
<testAnnotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</testAnnotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 开发环境启动脚本。
|
||||
#
|
||||
# 项目要求 Java 25(maven.compiler.release=25),系统默认 JDK 可能是 17,
|
||||
# 直接跑 ./mvnw 会报 "release version 25 not supported",所以这里固定 JDK。
|
||||
#
|
||||
# 用法:
|
||||
# ./run.sh 使用 dev profile(连本地 MySQL / Redis)
|
||||
# ./run.sh demo 使用 demo profile(H2 + 内存缓存,零外部依赖)
|
||||
# ./run.sh dev --server.port=8081
|
||||
# JDK25_HOME=/path/to/jdk ./run.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
JDK25_HOME="${JDK25_HOME:-$HOME/Library/Java/JavaVirtualMachines/temurin-25.jdk}"
|
||||
|
||||
# macOS 的 .jdk 是 bundle 结构,真正的 JDK 根目录在 Contents/Home;
|
||||
# 也兼容直接指向 JDK 根目录的情况。
|
||||
if [[ -x "$JDK25_HOME/Contents/Home/bin/java" ]]; then
|
||||
JDK25_HOME="$JDK25_HOME/Contents/Home"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$JDK25_HOME/bin/java" ]]; then
|
||||
echo "未找到 JDK 25:$JDK25_HOME" >&2
|
||||
echo "可通过 JDK25_HOME=/path/to/jdk25 ./run.sh 指定其他位置" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export JAVA_HOME="$JDK25_HOME"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# JDK 23 起 sun.misc.Unsafe 的内存访问方法被标记为「最终弃用」,
|
||||
# Netty(来自 Lettuce/Redis)内部仍在使用,启动时会打印 WARNING。
|
||||
APP_JVM_ARGS="${APP_JVM_ARGS:---sun-misc-unsafe-memory-access=allow}"
|
||||
|
||||
PROFILE="${1:-dev}"
|
||||
if [[ "$PROFILE" == "demo" ]]; then
|
||||
# demo 需要 H2,而 H2 只在 -Pdemo 时是 runtime 依赖
|
||||
MVN_PROFILE=(-Pdemo)
|
||||
shift || true
|
||||
else
|
||||
MVN_PROFILE=()
|
||||
shift || true
|
||||
fi
|
||||
|
||||
echo "JDK: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1)"
|
||||
echo "Profile: $PROFILE"
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
exec ./mvnw -q "${MVN_PROFILE[@]}" spring-boot:run \
|
||||
-Dspring-boot.run.profiles="$PROFILE" \
|
||||
-Dspring-boot.run.jvmArguments="$APP_JVM_ARGS" \
|
||||
-Dspring-boot.run.arguments="$*"
|
||||
fi
|
||||
|
||||
exec ./mvnw -q "${MVN_PROFILE[@]}" spring-boot:run \
|
||||
-Dspring-boot.run.profiles="$PROFILE" \
|
||||
-Dspring-boot.run.jvmArguments="$APP_JVM_ARGS"
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 生成 RS256 密钥对,输出可直接追加到 config/iam-jwt.properties 的内容。
|
||||
#
|
||||
# bash scripts/gen-iam-key.sh >> config/iam-jwt.properties
|
||||
#
|
||||
# 需要 openssl 与 JDK 25 的 keytool 不冲突:这里统一用 openssl,
|
||||
# 生成的是 PKCS#8 私钥 + X.509 公钥,正好是 IamJwtConfig 期望的格式。
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$WORK_DIR/private.pem" 2>/dev/null
|
||||
openssl rsa -in "$WORK_DIR/private.pem" -pubout -out "$WORK_DIR/public.pem" 2>/dev/null
|
||||
|
||||
# properties 文件里换行需要转义,这里直接压成单行交给 IamJwtConfig 解析
|
||||
PRIVATE_KEY="$(awk 'BEGIN{ORS=""} {print}' "$WORK_DIR/private.pem")"
|
||||
PUBLIC_KEY="$(awk 'BEGIN{ORS=""} {print}' "$WORK_DIR/public.pem")"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
# ===== 由 scripts/gen-iam-key.sh 生成于 $(date '+%Y-%m-%d %H:%M:%S') =====
|
||||
private-key=$PRIVATE_KEY
|
||||
public-key=$PUBLIC_KEY
|
||||
EOF
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.gxwebsoft;
|
||||
|
||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||
import com.gxwebsoft.common.core.config.JwtProperties;
|
||||
import com.gxwebsoft.common.system.config.AuthProperties;
|
||||
import com.gxwebsoft.common.system.config.MailProperties;
|
||||
import com.gxwebsoft.common.system.config.SmsProperties;
|
||||
import com.gxwebsoft.iam.config.IamJwtProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 统一身份中心(iam-api)启动类。
|
||||
*
|
||||
* <p>iam-api 是 open-api 与 dp-api 共用的唯一账号源:负责用户、租户、角色、权限、菜单、
|
||||
* 登录会话,以及面向资源服务的 RS256 令牌签发与 JWKS 发布。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.gxwebsoft.**.mapper")
|
||||
@EnableConfigurationProperties({
|
||||
ConfigProperties.class,
|
||||
JwtProperties.class,
|
||||
AuthProperties.class,
|
||||
SmsProperties.class,
|
||||
MailProperties.class,
|
||||
IamJwtProperties.class
|
||||
})
|
||||
public class IamApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(IamApiApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.gxwebsoft.common.core;
|
||||
|
||||
/**
|
||||
* 框架级常量。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public final class Constants {
|
||||
|
||||
/** 成功状态码 */
|
||||
public static final int RESULT_OK_CODE = 0;
|
||||
|
||||
/** 失败状态码 */
|
||||
public static final int RESULT_ERROR_CODE = 1;
|
||||
|
||||
public static final String RESULT_OK_MSG = "操作成功";
|
||||
|
||||
public static final String RESULT_ERROR_MSG = "操作失败";
|
||||
|
||||
/** 无权限 */
|
||||
public static final int UNAUTHORIZED_CODE = 403;
|
||||
|
||||
public static final String UNAUTHORIZED_MSG = "没有访问权限";
|
||||
|
||||
/** 未认证 */
|
||||
public static final int UNAUTHENTICATED_CODE = 401;
|
||||
|
||||
public static final String UNAUTHENTICATED_MSG = "请先登录";
|
||||
|
||||
public static final int TOKEN_EXPIRED_CODE = 401;
|
||||
|
||||
public static final String TOKEN_EXPIRED_MSG = "登录已过期";
|
||||
|
||||
public static final int BAD_CREDENTIALS_CODE = 401;
|
||||
|
||||
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
|
||||
|
||||
public static final String ORDER_DESC_VALUE = "desc";
|
||||
|
||||
/** Token 传递位置:请求头 */
|
||||
public static final String TOKEN_HEADER_NAME = "Authorization";
|
||||
|
||||
/** Token 认证类型 */
|
||||
public static final String TOKEN_TYPE = "Bearer";
|
||||
|
||||
/** 多租户:请求头中的租户 ID */
|
||||
public static final String TENANT_HEADER_NAME = "tenantId";
|
||||
|
||||
/** 内部服务自证密钥的请求头 */
|
||||
public static final String INTERNAL_KEY_HEADER = "X-Internal-Key";
|
||||
|
||||
private Constants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.common.core.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;
|
||||
|
||||
/**
|
||||
* 声明查询条件字段:字段名 → 数据库列 + 比较方式。
|
||||
*
|
||||
* <p>{@code keywords=true} 的字段参与通用关键字模糊搜索(多个字段之间是 OR)。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface QueryField {
|
||||
|
||||
/** 数据库列名;为空时按驼峰转下划线取字段名 */
|
||||
String value() default "";
|
||||
|
||||
/** 比较方式 */
|
||||
QueryType type() default QueryType.EQ;
|
||||
|
||||
/** 是否参与通用 keywords 模糊搜索 */
|
||||
boolean keywords() default false;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.gxwebsoft.common.core.annotation;
|
||||
|
||||
/**
|
||||
* 查询条件类型。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public enum QueryType {
|
||||
|
||||
/** 等于 */
|
||||
EQ,
|
||||
|
||||
/** 不等于 */
|
||||
NE,
|
||||
|
||||
/** 大于等于 */
|
||||
GE,
|
||||
|
||||
/** 小于等于 */
|
||||
LE,
|
||||
|
||||
/** 模糊匹配 */
|
||||
LIKE,
|
||||
|
||||
/** 包含于(逗号分隔的字符串入参) */
|
||||
IN
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.gxwebsoft.common.core.cache;
|
||||
|
||||
/**
|
||||
* 键值缓存抽象。
|
||||
*
|
||||
* <p>验证码、失败计数、令牌吊销名单都落在缓存里。抽象成接口是为了让「演示 / 测试环境」
|
||||
* 不依赖 Redis 也能跑起来:实现见 {@link MemoryCacheStore} 与 {@link RedisCacheStore}。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface CacheStore {
|
||||
|
||||
/** 写入并设置过期时间(秒) */
|
||||
void set(String key, String value, long ttlSeconds);
|
||||
|
||||
/** 读取,不存在或已过期返回 null */
|
||||
String get(String key);
|
||||
|
||||
/** 删除 */
|
||||
void delete(String key);
|
||||
|
||||
/** 自增并返回自增后的值;key 不存在时从 0 开始,且首次写入设置过期时间 */
|
||||
long increment(String key, long ttlSeconds);
|
||||
|
||||
/** key 不存在时写入并返回 true,否则返回 false(用于发码频控) */
|
||||
boolean setIfAbsent(String key, String value, long ttlSeconds);
|
||||
|
||||
/** 是否存在 */
|
||||
default boolean exists(String key) {
|
||||
return get(key) != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.gxwebsoft.common.core.cache;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 进程内缓存实现。
|
||||
*
|
||||
* <p>用途:`demo` profile 与单元测试。多实例部署下验证码与失败计数不共享,
|
||||
* 生产必须使用 {@link RedisCacheStore}。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class MemoryCacheStore implements CacheStore {
|
||||
|
||||
/** 单个键的过期时间戳,已过期的键在读取时惰性清理 */
|
||||
private record Entry(String value, long expireAtMillis) {
|
||||
|
||||
boolean expired(long now) {
|
||||
return expireAtMillis > 0 && now >= expireAtMillis;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Entry> store = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void set(String key, String value, long ttlSeconds) {
|
||||
store.put(key, new Entry(value, expireAt(ttlSeconds)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
Entry entry = store.get(key);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expired(System.currentTimeMillis())) {
|
||||
store.remove(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String key) {
|
||||
store.remove(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long increment(String key, long ttlSeconds) {
|
||||
AtomicLong counter = new AtomicLong();
|
||||
store.compute(key, (k, existing) -> {
|
||||
long now = System.currentTimeMillis();
|
||||
long base = existing == null || existing.expired(now) ? 0L : Long.parseLong(existing.value());
|
||||
long next = base + 1;
|
||||
counter.set(next);
|
||||
long expireAt = existing == null || existing.expired(now) ? expireAt(ttlSeconds) : existing.expireAtMillis();
|
||||
return new Entry(String.valueOf(next), expireAt);
|
||||
});
|
||||
return counter.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setIfAbsent(String key, String value, long ttlSeconds) {
|
||||
long now = System.currentTimeMillis();
|
||||
Entry created = new Entry(value, expireAt(ttlSeconds));
|
||||
Entry previous = store.compute(key, (k, existing) ->
|
||||
existing == null || existing.expired(now) ? created : existing);
|
||||
return previous == created;
|
||||
}
|
||||
|
||||
private static long expireAt(long ttlSeconds) {
|
||||
return ttlSeconds > 0 ? System.currentTimeMillis() + ttlSeconds * 1000L : 0L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.gxwebsoft.common.core.cache;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 缓存实现(生产默认)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class RedisCacheStore implements CacheStore {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public RedisCacheStore(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value, long ttlSeconds) {
|
||||
if (ttlSeconds > 0) {
|
||||
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));
|
||||
} else {
|
||||
redisTemplate.opsForValue().set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return redisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String key) {
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long increment(String key, long ttlSeconds) {
|
||||
Long value = redisTemplate.opsForValue().increment(key);
|
||||
long result = value == null ? 0L : value;
|
||||
// 只在第一次自增时设置过期时间,避免每次自增都把窗口往后推
|
||||
if (result == 1L && ttlSeconds > 0) {
|
||||
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setIfAbsent(String key, String value, long ttlSeconds) {
|
||||
Boolean ok = ttlSeconds > 0
|
||||
? redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(ttlSeconds))
|
||||
: redisTemplate.opsForValue().setIfAbsent(key, value);
|
||||
return Boolean.TRUE.equals(ok);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import com.gxwebsoft.common.core.cache.CacheStore;
|
||||
import com.gxwebsoft.common.core.cache.MemoryCacheStore;
|
||||
import com.gxwebsoft.common.core.cache.RedisCacheStore;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 缓存实现装配。
|
||||
*
|
||||
* <p>{@code iam.cache.type=redis}(默认)走 Redis;{@code memory} 走进程内 Map,
|
||||
* 用于演示与测试。多实例部署必须用 redis,否则验证码与失败计数不共享。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
public class CacheConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "iam.cache.type", havingValue = "memory", matchIfMissing = false)
|
||||
public CacheStore memoryCacheStore() {
|
||||
log.warn("iam.cache.type=memory:验证码与登录失败计数仅存于本进程,禁止用于多实例生产部署");
|
||||
return new MemoryCacheStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "iam.cache.type", havingValue = "redis", matchIfMissing = true)
|
||||
public CacheStore redisCacheStore(StringRedisTemplate stringRedisTemplate) {
|
||||
return new RedisCacheStore(stringRedisTemplate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 框架通用配置(前缀 {@code config})。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "config")
|
||||
public class ConfigProperties {
|
||||
|
||||
private String swaggerTitle = "iam-api 接口文档";
|
||||
|
||||
private String swaggerDescription = "统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌";
|
||||
|
||||
private String swaggerVersion = "0.1.0";
|
||||
|
||||
private List<String> swaggerBasePackages = List.of("com.gxwebsoft");
|
||||
|
||||
/** 对外服务地址,用于拼接回调地址 */
|
||||
private String serverUrl;
|
||||
|
||||
/** 内部服务调用密钥:introspect / revoke 的调用方用 X-Internal-Key 自证 */
|
||||
private String internalKey;
|
||||
|
||||
/**
|
||||
* 允许跨域访问的来源白名单(支持通配,例如 {@code https://*.websoft.top})。
|
||||
*
|
||||
* <p>禁止配置成 {@code *}:接口开启了 allowCredentials,任意站点都能带凭证调用本服务。</p>
|
||||
*/
|
||||
private List<String> corsAllowedOriginPatterns = List.of(
|
||||
"https://websoft.top",
|
||||
"https://*.websoft.top",
|
||||
"http://localhost:[*]",
|
||||
"http://127.0.0.1:[*]"
|
||||
);
|
||||
|
||||
/** 免鉴权路径白名单 */
|
||||
private List<String> permitAllPaths = List.of(
|
||||
"/",
|
||||
"/api/login",
|
||||
"/api/loginByEmail",
|
||||
"/api/loginBySms",
|
||||
"/api/captcha",
|
||||
"/api/sendSmsCaptcha",
|
||||
"/api/sendEmailCaptcha",
|
||||
"/api/resetPassword",
|
||||
// 申请授权码:终端用户用账号密码换码,接口本身免登录(内部自证用户身份)
|
||||
"/api/v1/oauth/authorize",
|
||||
// 令牌自省:调用方是资源服务,用 X-Internal-Key 头自证
|
||||
"/api/v1/oauth/introspect",
|
||||
"/api/v1/oauth/revoke",
|
||||
"/actuator/health",
|
||||
"/v3/api-docs/**",
|
||||
"/doc.html",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/webjars/**"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Jackson 配置:统一日期时间格式。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
/**
|
||||
* 只注册「定制器」,ObjectMapper 本身交给 Spring Boot 自动配置。
|
||||
*
|
||||
* <p>不要在这里自己 new 一个 ObjectMapper Bean:那会覆盖自动配置,并且和 Boot 自带的
|
||||
* {@code standardJacksonObjectMapperBuilderCustomizer} 一起被注入时产生歧义。</p>
|
||||
*/
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
|
||||
return builder -> {
|
||||
JavaTimeModule module = new JavaTimeModule();
|
||||
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter));
|
||||
module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(formatter));
|
||||
builder.modules(module);
|
||||
builder.simpleDateFormat(DATE_TIME_PATTERN);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 控制台会话令牌配置(前缀 {@code config.jwt})。
|
||||
*
|
||||
* <p>会话令牌只服务于控制台自身(HS256),与面向资源服务的 RS256 令牌是两套东西,
|
||||
* 后者见 {@code com.gxwebsoft.iam.config.IamJwtProperties}。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "config.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
/** 签名密钥:Base64 或不少于 32 字节的明文,生产环境必须用环境变量注入 */
|
||||
private String secret;
|
||||
|
||||
/** Token 有效期(秒) */
|
||||
private long expire = 86400;
|
||||
|
||||
/** 剩余有效期低于该值(秒)时自动续签 */
|
||||
private long refreshThreshold = 1800;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 公共字段自动填充:创建时间 / 更新时间。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class MybatisMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
|
||||
strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.gxwebsoft.common.core.context.TenantContext;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import net.sf.jsqlparser.expression.NullValue;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 配置:多租户 + 分页。
|
||||
*
|
||||
* <p>租户 ID 由 {@link TenantContext} 在请求进入时解析完成,这里只负责把它拼进 SQL。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
/** 不做租户隔离的公共表:平台级元数据,或本身已带租户条件由业务显式指定 */
|
||||
private static final Set<String> IGNORE_TABLES = Set.of(
|
||||
"sys_tenant",
|
||||
"sys_menu",
|
||||
"sys_user_role",
|
||||
"sys_role_menu",
|
||||
"sys_login_record",
|
||||
"iam_client",
|
||||
"iam_auth_code");
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
Long tenantId = TenantContext.getTenantId();
|
||||
return tenantId == null ? new NullValue() : new LongValue(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
return TenantContext.isIgnoreTenant()
|
||||
|| IGNORE_TABLES.contains(tableName.toLowerCase());
|
||||
}
|
||||
}));
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import com.gxwebsoft.common.core.security.JwtAccessDeniedHandler;
|
||||
import com.gxwebsoft.common.core.security.JwtAuthenticationEntryPoint;
|
||||
import com.gxwebsoft.common.core.security.JwtAuthenticationFilter;
|
||||
import com.gxwebsoft.common.core.security.JwtUtil;
|
||||
import com.gxwebsoft.common.core.security.LoginUserProvider;
|
||||
import com.gxwebsoft.common.core.security.RevokedTokenStore;
|
||||
import com.gxwebsoft.common.core.web.ResponseWriter;
|
||||
import com.gxwebsoft.common.core.web.TenantFilter;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.header.writers.StaticHeadersWriter;
|
||||
|
||||
/**
|
||||
* Spring Security 配置。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final ConfigProperties configProperties;
|
||||
|
||||
public SecurityConfig(ConfigProperties configProperties) {
|
||||
this.configProperties = configProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
TenantFilter tenantFilter,
|
||||
JwtAuthenticationEntryPoint entryPoint,
|
||||
JwtAccessDeniedHandler accessDeniedHandler) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.logout(logout -> logout.disable())
|
||||
.headers(headers -> headers
|
||||
// 拒绝被第三方站点 iframe 嵌套(点击劫持),同源内嵌(接口文档页)保留
|
||||
.frameOptions(frame -> frame.sameOrigin())
|
||||
// TLS 在反向代理终结,这里统一补 HSTS,避免降级 / SSL 剥离
|
||||
.addHeaderWriter(new StaticHeadersWriter("Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains"))
|
||||
.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", "no-referrer")))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(registry -> registry
|
||||
// 放行内部 ERROR 转发,否则过滤器链里的异常会被包装成 401,掩盖真实错误
|
||||
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/v1/oauth/token", "/api/v1/oauth/jwks").permitAll()
|
||||
.requestMatchers(configProperties.getPermitAllPaths().toArray(String[]::new)).permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(handling -> handling
|
||||
.authenticationEntryPoint(entryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(tenantFilter, JwtAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtil jwtUtil, LoginUserProvider loginUserProvider,
|
||||
ResponseWriter responseWriter,
|
||||
RevokedTokenStore revokedTokenStore) {
|
||||
return new JwtAuthenticationFilter(jwtUtil, loginUserProvider, responseWriter, revokedTokenStore);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TenantFilter tenantFilter() {
|
||||
return new TenantFilter();
|
||||
}
|
||||
|
||||
/** 过滤器只挂在 Security 链上,这里关闭 Servlet 容器的重复注册 */
|
||||
@Bean
|
||||
public FilterRegistrationBean<JwtAuthenticationFilter> jwtFilterRegistration(JwtAuthenticationFilter filter) {
|
||||
FilterRegistrationBean<JwtAuthenticationFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
|
||||
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的登录用户加载器:直接把 token 载体作为主体,不查库。
|
||||
*
|
||||
* <p>system 模块提供自己的 {@link LoginUserProvider} 实现后,本 Bean 自动失效。</p>
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(LoginUserProvider.class)
|
||||
public LoginUserProvider defaultLoginUserProvider() {
|
||||
return subject -> subject;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.gxwebsoft.common.core.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 配置:跨域。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ConfigProperties configProperties;
|
||||
|
||||
public WebMvcConfig(ConfigProperties configProperties) {
|
||||
this.configProperties = configProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns(configProperties.getCorsAllowedOriginPatterns().toArray(String[]::new))
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
// 令牌放在 Authorization 头里由前端显式携带;开启凭证只为兼容以 Cookie 绑定的图形验证码
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.core.context;
|
||||
|
||||
/**
|
||||
* 租户感知:登录主体实现该接口后,租户过滤器可以直接从主体上取到租户 ID。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface TenantAware {
|
||||
|
||||
/** 所属租户 ID */
|
||||
Long getTenantId();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.gxwebsoft.common.core.context;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* 租户上下文。
|
||||
*
|
||||
* <p>由 {@code TenantFilter} 在请求进入时写入,供多租户插件与业务代码读取。
|
||||
* 使用 {@link ThreadLocal},在虚拟线程下同样成立(每个虚拟线程各自持有副本)。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public final class TenantContext {
|
||||
|
||||
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Boolean> IGNORE_TENANT = new ThreadLocal<>();
|
||||
|
||||
private TenantContext() {
|
||||
}
|
||||
|
||||
/** 当前租户 ID,未识别到时返回 null */
|
||||
public static Long getTenantId() {
|
||||
return TENANT_ID.get();
|
||||
}
|
||||
|
||||
public static void setTenantId(Long tenantId) {
|
||||
TENANT_ID.set(tenantId);
|
||||
}
|
||||
|
||||
/** 是否忽略租户隔离 */
|
||||
public static boolean isIgnoreTenant() {
|
||||
return Boolean.TRUE.equals(IGNORE_TENANT.get());
|
||||
}
|
||||
|
||||
public static void setIgnoreTenant(boolean ignore) {
|
||||
IGNORE_TENANT.set(ignore);
|
||||
}
|
||||
|
||||
/** 清理上下文,必须在请求结束时调用,避免线程复用造成串租户 */
|
||||
public static void clear() {
|
||||
TENANT_ID.remove();
|
||||
IGNORE_TENANT.remove();
|
||||
}
|
||||
|
||||
/** 在忽略租户隔离的上下文中执行并返回结果 */
|
||||
public static <T> T callIgnoreTenant(Supplier<T> supplier) {
|
||||
boolean origin = isIgnoreTenant();
|
||||
try {
|
||||
setIgnoreTenant(true);
|
||||
return supplier.get();
|
||||
} finally {
|
||||
setIgnoreTenant(origin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定租户上下文中执行并返回结果。
|
||||
*
|
||||
* <p>用于登录等「租户尚未进入上下文、但需要按租户查数据」的场景。</p>
|
||||
*/
|
||||
public static <T> T callWithTenant(Long tenantId, Supplier<T> supplier) {
|
||||
Long origin = getTenantId();
|
||||
try {
|
||||
setTenantId(tenantId);
|
||||
return supplier.get();
|
||||
} finally {
|
||||
setTenantId(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.gxwebsoft.common.core.exception;
|
||||
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* 业务异常:由全局异常处理器翻译为 {@code ApiResult} 失败响应。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 业务状态码,默认 1(失败) */
|
||||
private final int code;
|
||||
|
||||
public BusinessException(String message) {
|
||||
this(Constants.RESULT_ERROR_CODE, message);
|
||||
}
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.gxwebsoft.common.core.exception;
|
||||
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 全局异常处理。
|
||||
*
|
||||
* <p>控制器抛出的业务异常统一翻译为 HTTP 200 + {@code code != 0},与既有前端契约一致:
|
||||
* 前端 axios 拦截器只在 HTTP 401 时清理登录态,业务错误按 {@code message} 提示。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ApiResult<?> handleBusiness(BusinessException e) {
|
||||
return ApiResult.fail(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
/** 参数校验失败:取第一条字段错误,避免把整个校验列表丢给用户 */
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
||||
public ApiResult<?> handleValidation(Exception e) {
|
||||
FieldError fieldError = e instanceof MethodArgumentNotValidException ex
|
||||
? ex.getBindingResult().getFieldError()
|
||||
: ((BindException) e).getBindingResult().getFieldError();
|
||||
String message = fieldError == null ? "请求参数不合法" : fieldError.getDefaultMessage();
|
||||
return ApiResult.fail(message);
|
||||
}
|
||||
|
||||
/** 未预期的异常:日志留全量堆栈,响应只给出通用提示,避免泄露内部实现 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ApiResult<?> handleUnexpected(Exception e) {
|
||||
log.error("未处理的异常", e);
|
||||
return ApiResult.fail(Constants.RESULT_ERROR_CODE, "服务异常,请稍后重试");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.ResponseWriter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 无权限处理:统一返回 {@code code=403}。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final ResponseWriter responseWriter;
|
||||
|
||||
public JwtAccessDeniedHandler(ResponseWriter responseWriter) {
|
||||
this.responseWriter = responseWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException {
|
||||
responseWriter.write(response, ApiResult.fail(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG));
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.ResponseWriter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 未认证处理:统一返回 {@code code=401}。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final ResponseWriter responseWriter;
|
||||
|
||||
public JwtAuthenticationEntryPoint(ResponseWriter responseWriter) {
|
||||
this.responseWriter = responseWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
responseWriter.write(response, ApiResult.fail(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG));
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.ResponseWriter;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 携带会话 token 的请求认证过滤器。
|
||||
*
|
||||
* <p>不做 servlet 容器的自动注册,只在 Spring Security 过滤器链中生效,
|
||||
* 避免过滤器被重复执行两次。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final LoginUserProvider loginUserProvider;
|
||||
private final ResponseWriter responseWriter;
|
||||
private final RevokedTokenStore revokedTokenStore;
|
||||
|
||||
public JwtAuthenticationFilter(JwtUtil jwtUtil, LoginUserProvider loginUserProvider,
|
||||
ResponseWriter responseWriter, RevokedTokenStore revokedTokenStore) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.loginUserProvider = loginUserProvider;
|
||||
this.responseWriter = responseWriter;
|
||||
this.revokedTokenStore = revokedTokenStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌端点(/api/v1/oauth/**)用 client_id + client_secret 或 X-Internal-Key 自证身份,
|
||||
* 不认会话令牌。
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return request.getRequestURI().startsWith("/api/v1/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
String token = JwtUtil.getAccessToken(request);
|
||||
if (StrUtil.isBlank(token)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Claims claims = jwtUtil.parseToken(token);
|
||||
// 登出 / 强制下线:令牌即使签名有效且未过期,也拒绝继续使用
|
||||
if (revokedTokenStore.isRevoked(claims.getId())) {
|
||||
log.debug("token 已被吊销: jti={}", claims.getId());
|
||||
responseWriter.write(response,
|
||||
ApiResult.fail(Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG));
|
||||
return;
|
||||
}
|
||||
JwtSubject subject = jwtUtil.toSubject(claims);
|
||||
Object principal = loginUserProvider.loadPrincipal(subject);
|
||||
if (principal == null) {
|
||||
throw new IllegalStateException("用户不存在或已被禁用");
|
||||
}
|
||||
Collection<? extends GrantedAuthority> authorities = loginUserProvider.loadAuthorities(principal);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(principal, null, authorities);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 临近过期自动续签,避免用户使用中被登出
|
||||
if (jwtUtil.needRefresh(claims)) {
|
||||
response.setHeader(Constants.TOKEN_HEADER_NAME, jwtUtil.buildToken(subject));
|
||||
}
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.debug("token 已过期: {}", e.getMessage());
|
||||
responseWriter.write(response,
|
||||
ApiResult.fail(Constants.TOKEN_EXPIRED_CODE, Constants.TOKEN_EXPIRED_MSG));
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.debug("token 校验失败: {}", e.getMessage());
|
||||
responseWriter.write(response,
|
||||
ApiResult.fail(Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG));
|
||||
return;
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 会话令牌载体:只放鉴权必需的最小信息。
|
||||
*
|
||||
* @param username 账号
|
||||
* @param tenantId 租户 ID
|
||||
* @author WebSoft
|
||||
*/
|
||||
public record JwtSubject(String username, Long tenantId) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.config.JwtProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 控制台会话令牌工具(HS256)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
private static final String CLAIM_TENANT_ID = "tenantId";
|
||||
|
||||
private final JwtProperties properties;
|
||||
private final SecretKey key;
|
||||
|
||||
public JwtUtil(JwtProperties properties) {
|
||||
this.properties = properties;
|
||||
this.key = buildKey(properties.getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头提取 token,自动去掉 Bearer 前缀。
|
||||
*
|
||||
* <p>只认 {@code Authorization} 请求头:URL 里的令牌会落进反向代理 access log、
|
||||
* Referer、浏览器历史与中间缓存,属于明确的劝退用法。</p>
|
||||
*/
|
||||
public static String getAccessToken(HttpServletRequest request) {
|
||||
String token = request.getHeader(Constants.TOKEN_HEADER_NAME);
|
||||
if (StrUtil.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
if (token.startsWith(Constants.TOKEN_TYPE)) {
|
||||
token = StrUtil.removePrefix(token, Constants.TOKEN_TYPE).trim();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 签发 token:带 {@code jti} 供登出时吊销 */
|
||||
public String buildToken(JwtSubject subject) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(IdUtil.fastSimpleUUID())
|
||||
.subject(subject.username())
|
||||
.claim(CLAIM_TENANT_ID, subject.tenantId())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(properties.getExpire())))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 解析并校验 token,失败时抛出 jjwt 的运行时异常 */
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public JwtSubject toSubject(Claims claims) {
|
||||
return new JwtSubject(claims.getSubject(), claims.get(CLAIM_TENANT_ID, Long.class));
|
||||
}
|
||||
|
||||
/** token 剩余有效秒数 */
|
||||
public long remainingSeconds(Claims claims) {
|
||||
return (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
||||
}
|
||||
|
||||
/** 是否需要续签 */
|
||||
public boolean needRefresh(Claims claims) {
|
||||
return remainingSeconds(claims) < properties.getRefreshThreshold();
|
||||
}
|
||||
|
||||
/** 令牌有效期(秒),登录响应里回给前端 */
|
||||
public long getExpireSeconds() {
|
||||
return properties.getExpire();
|
||||
}
|
||||
|
||||
/** 构造签名密钥:支持 Base64 与明文两种写法,明文要求不少于 32 字节 */
|
||||
private static SecretKey buildKey(String secret) {
|
||||
// 占位符未被解析时会以 "${JWT_SECRET}" 这样的字面量传入,这里统一按「未配置」处理
|
||||
if (StrUtil.isBlank(secret) || secret.contains("${")) {
|
||||
throw new IllegalStateException(
|
||||
"未配置 JWT 密钥:请设置环境变量 JWT_SECRET(HS256 要求 Base64 或不少于 32 字节的明文)");
|
||||
}
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = Decoders.BASE64.decode(secret);
|
||||
} catch (Exception e) {
|
||||
bytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException("JWT 密钥长度不足:HS256 要求至少 32 字节");
|
||||
}
|
||||
return Keys.hmacShaKeyFor(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 登录主体与权限的加载扩展点。
|
||||
*
|
||||
* <p>框架层不认识业务实体,认证过滤器通过本接口拿到「主体对象」和「权限集合」;
|
||||
* system 模块提供基于 {@code sys_user} / {@code sys_menu} 的实现。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface LoginUserProvider {
|
||||
|
||||
/** 按令牌载体加载登录主体;返回 null 表示用户不存在或已停用 */
|
||||
Object loadPrincipal(JwtSubject subject);
|
||||
|
||||
/** 加载权限集合,默认无权限 */
|
||||
default Collection<? extends GrantedAuthority> loadAuthorities(Object principal) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import com.gxwebsoft.common.core.cache.CacheStore;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 令牌吊销名单。
|
||||
*
|
||||
* <p>JWT 本身无状态,只让客户端丢弃令牌的话,令牌在有效期内仍然可用。
|
||||
* 登出 / 强制下线时按 {@code jti} 写入吊销名单,有效期与令牌剩余寿命对齐。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class RevokedTokenStore {
|
||||
|
||||
private static final String KEY_PREFIX = "iam:revoked:";
|
||||
|
||||
private final CacheStore cacheStore;
|
||||
|
||||
public RevokedTokenStore(CacheStore cacheStore) {
|
||||
this.cacheStore = cacheStore;
|
||||
}
|
||||
|
||||
/** 吊销令牌,ttlSeconds 为令牌剩余有效期 */
|
||||
public void revoke(String jti, long ttlSeconds) {
|
||||
if (jti == null || ttlSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
cacheStore.set(KEY_PREFIX + jti, "1", ttlSeconds);
|
||||
}
|
||||
|
||||
public boolean isRevoked(String jti) {
|
||||
return jti != null && cacheStore.exists(KEY_PREFIX + jti);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.gxwebsoft.common.core.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 客户端 IP 解析。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public final class IpUtils {
|
||||
|
||||
private static final String UNKNOWN = "unknown";
|
||||
|
||||
private IpUtils() {
|
||||
}
|
||||
|
||||
/** 取真实客户端 IP:优先反向代理头,其次 RemoteAddr */
|
||||
public static String clientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
for (String header : new String[]{"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"}) {
|
||||
String value = request.getHeader(header);
|
||||
if (StrUtil.isNotBlank(value) && !UNKNOWN.equalsIgnoreCase(value)) {
|
||||
// X-Forwarded-For 可能是「client, proxy1, proxy2」,第一个才是客户端
|
||||
int comma = value.indexOf(',');
|
||||
String ip = comma > 0 ? value.substring(0, comma) : value;
|
||||
return truncate(ip.trim());
|
||||
}
|
||||
}
|
||||
return truncate(request.getRemoteAddr());
|
||||
}
|
||||
|
||||
/** 列宽 64,超长直接截断,避免写库失败 */
|
||||
private static String truncate(String ip) {
|
||||
if (ip == null) {
|
||||
return null;
|
||||
}
|
||||
return ip.length() <= 64 ? ip : ip.substring(0, 64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 统一返回结果。
|
||||
*
|
||||
* <p>保持 {@code code / message / data} 契约:前端 axios 拦截器按 {@code code === 0} 判断成功。</p>
|
||||
*
|
||||
* @param <T> 数据类型
|
||||
* @author WebSoft
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "统一返回结果")
|
||||
public class ApiResult<T> implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "状态码")
|
||||
private Integer code;
|
||||
|
||||
@Schema(description = "状态信息")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "返回数据")
|
||||
private T data;
|
||||
|
||||
public ApiResult() {
|
||||
}
|
||||
|
||||
public ApiResult(Integer code, String message) {
|
||||
this(code, message, null);
|
||||
}
|
||||
|
||||
public ApiResult(Integer code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static ApiResult<?> ok() {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG);
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> ok(T data) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> ok(String message, T data) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> ok(String message) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, message, null);
|
||||
}
|
||||
|
||||
public static ApiResult<?> fail() {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, Constants.RESULT_ERROR_MSG);
|
||||
}
|
||||
|
||||
public static ApiResult<?> fail(String message) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, message);
|
||||
}
|
||||
|
||||
public static ApiResult<?> fail(Integer code, String message) {
|
||||
return new ApiResult<>(code, message);
|
||||
}
|
||||
|
||||
public Integer getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public ApiResult<T> setCode(Integer code) {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public ApiResult<T> setMessage(String message) {
|
||||
this.message = message;
|
||||
return this;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public ApiResult<T> setData(T data) {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.context.TenantContext;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Controller 基类:统一成功/失败返回、请求参数处理。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public abstract class BaseController {
|
||||
|
||||
@Resource
|
||||
protected HttpServletRequest request;
|
||||
|
||||
public ApiResult<?> success() {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG);
|
||||
}
|
||||
|
||||
public ApiResult<?> success(String message) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, message);
|
||||
}
|
||||
|
||||
public <T> ApiResult<T> success(T data) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data);
|
||||
}
|
||||
|
||||
public <T> ApiResult<T> success(String message, T data) {
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, message, data);
|
||||
}
|
||||
|
||||
public <T> ApiResult<PageResult<T>> success(List<T> list, Long count) {
|
||||
return success(new PageResult<>(list, count));
|
||||
}
|
||||
|
||||
public <T> ApiResult<PageResult<T>> success(IPage<T> page) {
|
||||
return success(page.getRecords(), page.getTotal());
|
||||
}
|
||||
|
||||
public ApiResult<?> fail(String message) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, message);
|
||||
}
|
||||
|
||||
/** 请求参数的空字符串转为 null,避免把 "" 当成有效值写库 */
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
|
||||
}
|
||||
|
||||
/** 当前请求的租户 ID */
|
||||
public Long getTenantId() {
|
||||
return TenantContext.getTenantId();
|
||||
}
|
||||
|
||||
public String getAuthorization() {
|
||||
return request.getHeader(Constants.TOKEN_HEADER_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 查询参数基类:分页、排序、时间范围等公共字段。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class BaseParam implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "分页查询页码")
|
||||
private Long page;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "分页查询每页数量")
|
||||
private Long limit;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "排序字段或 SQL,如 `id asc, name desc`")
|
||||
private String sort;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "sort 是字段名时的排序方式:asc 升序、desc 降序")
|
||||
private String order;
|
||||
|
||||
@QueryField(value = "create_time", type = QueryType.GE)
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "创建时间起始值")
|
||||
private String createTimeStart;
|
||||
|
||||
@QueryField(value = "create_time", type = QueryType.LE)
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "创建时间结束值")
|
||||
private String createTimeEnd;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "租户 ID")
|
||||
private Long tenantId;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "通用模糊搜索关键字")
|
||||
private String keywords;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.OrderItem;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 分页 + 排序 + 条件搜索参数封装。
|
||||
*
|
||||
* <p>查询条件由 {@link BaseParam} 子类字段上的 {@link QueryField} 注解声明,
|
||||
* {@link #getWrapper()} 负责翻译成 MyBatis-Plus 的条件构造器。</p>
|
||||
*
|
||||
* @param <T> 实体类型
|
||||
* @param <U> 查询参数类型
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class PageParam<T, U extends BaseParam> extends Page<T> {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 合法标识符:字母 / 数字 / 下划线 */
|
||||
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
|
||||
|
||||
/** 禁止参与排序的敏感列:按敏感列排序可用于盲注旁路 */
|
||||
private static final Set<String> NON_SORTABLE_COLUMNS = Set.of("password", "client_secret", "secret");
|
||||
|
||||
/** 最大每页条数,避免一次拉全表 */
|
||||
private static final long MAX_SIZE = 200L;
|
||||
|
||||
private final Class<T> entityClass;
|
||||
|
||||
private final U where;
|
||||
|
||||
public PageParam(Class<T> entityClass, U where) {
|
||||
super();
|
||||
this.entityClass = entityClass;
|
||||
this.where = where;
|
||||
if (where != null) {
|
||||
if (where.getPage() != null) {
|
||||
setCurrent(Math.max(1L, where.getPage()));
|
||||
}
|
||||
if (where.getLimit() != null) {
|
||||
setSize(Math.min(MAX_SIZE, Math.max(1L, where.getLimit())));
|
||||
}
|
||||
if (StrUtil.isNotBlank(where.getSort())) {
|
||||
setOrders(parseUserSort(where));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认排序:调用方未指定 sort 时生效,兜底保证分页结果稳定 */
|
||||
public PageParam<T, U> setDefaultOrder(String orderSql) {
|
||||
if (orders() == null || orders().isEmpty()) {
|
||||
List<OrderItem> items = new ArrayList<>();
|
||||
for (String item : orderSql.split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+");
|
||||
boolean asc = parts.length == 1 || !Constants.ORDER_DESC_VALUE.equals(parts[parts.length - 1]);
|
||||
items.add(asc ? OrderItem.asc(parts[0]) : OrderItem.desc(parts[0]));
|
||||
}
|
||||
setOrders(items);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 构造查询条件 */
|
||||
public QueryWrapper<T> getWrapper() {
|
||||
QueryWrapper<T> wrapper = new QueryWrapper<>();
|
||||
if (where == null) {
|
||||
return wrapper;
|
||||
}
|
||||
List<String> keywordColumns = new ArrayList<>();
|
||||
for (Field field : allFields(where.getClass())) {
|
||||
QueryField annotation = field.getAnnotation(QueryField.class);
|
||||
if (annotation == null) {
|
||||
continue;
|
||||
}
|
||||
String column = StrUtil.isBlank(annotation.value())
|
||||
? StrUtil.toUnderlineCase(field.getName())
|
||||
: annotation.value();
|
||||
Object value = readField(field, where);
|
||||
if (annotation.keywords()) {
|
||||
keywordColumns.add(column);
|
||||
}
|
||||
if (value == null || (value instanceof String str && StrUtil.isBlank(str))) {
|
||||
continue;
|
||||
}
|
||||
applyCondition(wrapper, column, annotation.type(), value);
|
||||
}
|
||||
// 关键字搜索:多个字段之间 OR,整体再与其它条件 AND
|
||||
if (StrUtil.isNotBlank(where.getKeywords()) && !keywordColumns.isEmpty()) {
|
||||
String keyword = where.getKeywords().trim();
|
||||
wrapper.and(nested -> {
|
||||
for (int i = 0; i < keywordColumns.size(); i++) {
|
||||
if (i > 0) {
|
||||
nested.or();
|
||||
}
|
||||
nested.like(keywordColumns.get(i), keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private void applyCondition(QueryWrapper<T> wrapper, String column, QueryType type, Object value) {
|
||||
switch (type) {
|
||||
case EQ -> wrapper.eq(column, value);
|
||||
case NE -> wrapper.ne(column, value);
|
||||
case GE -> wrapper.ge(column, value);
|
||||
case LE -> wrapper.le(column, value);
|
||||
case LIKE -> wrapper.like(column, value);
|
||||
case IN -> {
|
||||
List<String> values = Arrays.stream(String.valueOf(value).split(","))
|
||||
.map(String::trim).filter(StrUtil::isNotBlank).toList();
|
||||
if (!values.isEmpty()) {
|
||||
wrapper.in(column, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用户传入的 {@code sort}。
|
||||
*
|
||||
* <p>ORDER BY 的列名无法参数化,只能靠白名单拦:列名必须命中实体真实字段,
|
||||
* 且不属于敏感列,从而同时挡住表达式注入与「按敏感列排序」的盲注旁路。</p>
|
||||
*/
|
||||
private List<OrderItem> parseUserSort(U where) {
|
||||
List<OrderItem> orders = new ArrayList<>();
|
||||
for (String item : where.getSort().split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+");
|
||||
String column = requireSortableColumn(parts[0]);
|
||||
boolean asc = parts.length > 1
|
||||
? !Constants.ORDER_DESC_VALUE.equals(parts[parts.length - 1])
|
||||
: !Constants.ORDER_DESC_VALUE.equals(where.getOrder());
|
||||
orders.add(asc ? OrderItem.asc(column) : OrderItem.desc(column));
|
||||
}
|
||||
return orders;
|
||||
}
|
||||
|
||||
private String requireSortableColumn(String raw) {
|
||||
String column = StrUtil.toUnderlineCase(raw);
|
||||
if (!IDENTIFIER.matcher(column).matches() || NON_SORTABLE_COLUMNS.contains(column.toLowerCase())) {
|
||||
throw new BusinessException("不支持的排序字段:" + raw);
|
||||
}
|
||||
Set<String> columns = entityColumns();
|
||||
if (!columns.isEmpty() && !columns.contains(column)) {
|
||||
throw new BusinessException("不支持的排序字段:" + raw);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
/** 实体真实列名;取不到元数据时返回空集合,退化为「合法标识符」校验 */
|
||||
private Set<String> entityColumns() {
|
||||
if (entityClass == null) {
|
||||
return Set.of();
|
||||
}
|
||||
TableInfo info = TableInfoHelper.getTableInfo(entityClass);
|
||||
if (info == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> columns = new HashSet<>();
|
||||
if (StrUtil.isNotBlank(info.getKeyColumn())) {
|
||||
columns.add(info.getKeyColumn());
|
||||
}
|
||||
for (TableFieldInfo field : info.getFieldList()) {
|
||||
columns.add(field.getColumn());
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static List<Field> allFields(Class<?> type) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
for (Class<?> current = type; current != null && current != Object.class; current = current.getSuperclass()) {
|
||||
fields.addAll(Arrays.asList(current.getDeclaredFields()));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static Object readField(Field field, Object target) {
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new IllegalStateException("读取查询条件失败:" + field.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分页返回结构。
|
||||
*
|
||||
* @param <T> 记录类型
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "分页返回结构")
|
||||
public class PageResult<T> implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "当前页数据")
|
||||
private List<T> list;
|
||||
|
||||
@Schema(description = "总数量")
|
||||
private Long total;
|
||||
|
||||
public PageResult() {
|
||||
}
|
||||
|
||||
public PageResult(List<T> list, Long total) {
|
||||
this.list = list;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
public List<T> getList() {
|
||||
return list;
|
||||
}
|
||||
|
||||
public PageResult<T> setList(List<T> list) {
|
||||
this.list = list;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public PageResult<T> setTotal(Long total) {
|
||||
this.total = total;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 过滤器链中的统一响应输出。
|
||||
*
|
||||
* <p>过滤器在进入 Spring MVC 之前就可能拒绝请求,此时没有 {@code @RestControllerAdvice},
|
||||
* 只能自己写响应体。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
public class ResponseWriter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ResponseWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void write(HttpServletResponse response, ApiResult<?> body) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write(objectMapper.writeValueAsString(body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.gxwebsoft.common.core.web;
|
||||
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.context.TenantAware;
|
||||
import com.gxwebsoft.common.core.context.TenantContext;
|
||||
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 租户解析过滤器。
|
||||
*
|
||||
* <p>解析顺序:登录主体 → 请求头 {@code tenantId}。解析结果写入 {@link TenantContext},
|
||||
* 请求结束时清理,防止线程复用串租户。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class TenantFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
try {
|
||||
TenantContext.setTenantId(resolveTenantId(request));
|
||||
chain.doFilter(request, response);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveTenantId(HttpServletRequest request) {
|
||||
// 已认证请求:以令牌中的租户为准,禁止用请求头切换租户,否则会越权读取其他租户数据
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null) {
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof JwtSubject subject) {
|
||||
return subject.tenantId();
|
||||
}
|
||||
if (principal instanceof TenantAware tenantAware) {
|
||||
return tenantAware.getTenantId();
|
||||
}
|
||||
}
|
||||
// 未认证请求:请求头指定(登录、取验证码)
|
||||
return parse(request.getHeader(Constants.TENANT_HEADER_NAME));
|
||||
}
|
||||
|
||||
private Long parse(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.gxwebsoft.common.system.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 登录与验证码策略配置(前缀 {@code config.auth})。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "config.auth")
|
||||
public class AuthProperties {
|
||||
|
||||
/** 登录是否强制图形验证码;本地联调可临时设为 false */
|
||||
private boolean captchaEnabled = true;
|
||||
|
||||
/** 图形验证码有效期(秒) */
|
||||
private long captchaExpire = 300;
|
||||
|
||||
/** 登录失败锁定阈值:连续失败达到该值锁定账号 */
|
||||
private int loginFailThreshold = 5;
|
||||
|
||||
/** 锁定持续时间(秒) */
|
||||
private long loginLockSeconds = 600;
|
||||
|
||||
/** 是否开放邮箱验证码 */
|
||||
private boolean emailCaptchaEnabled = true;
|
||||
|
||||
/** 是否开放开发者自助注册;默认关闭,账号由平台管理员开通 */
|
||||
private boolean registerEnabled = false;
|
||||
|
||||
/** 发码接口的 IP 维度每小时上限 */
|
||||
private int codeSendIpHourlyLimit = 30;
|
||||
|
||||
/** 发码接口的 IP 维度最小间隔(秒) */
|
||||
private int codeSendIpIntervalSeconds = 5;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.gxwebsoft.common.system.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 邮件验证码配置(前缀 {@code config.mail})。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "config.mail")
|
||||
public class MailProperties {
|
||||
|
||||
/**
|
||||
* 是否真正发信。false 或未配置 {@code spring.mail.host} 时验证码只写日志,
|
||||
* 便于本地联调;生产必须开着并配好 SMTP。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 发件人地址,为空时取 {@code spring.mail.username} */
|
||||
private String from;
|
||||
|
||||
/** 邮件主题 */
|
||||
private String subject = "云枢开放平台验证码";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.gxwebsoft.common.system.config;
|
||||
|
||||
import com.gxwebsoft.common.system.service.LoggingSmsSender;
|
||||
import com.gxwebsoft.common.system.service.SmsSender;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 短信通道装配。
|
||||
*
|
||||
* <p>{@code @ConditionalOnMissingBean} 必须写在 {@code @Bean} 方法上:
|
||||
* 直接标在被扫描的 {@code @Component} 上时,条件判断会看到自己这条定义,
|
||||
* 结果把自己排除掉,最终「一个 SmsSender 都没有」。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Configuration
|
||||
public class SmsConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(SmsSender.class)
|
||||
public SmsSender loggingSmsSender(SmsProperties smsProperties) {
|
||||
return new LoggingSmsSender(smsProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.gxwebsoft.common.system.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 短信验证码配置(前缀 {@code config.sms})。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "config.sms")
|
||||
public class SmsProperties {
|
||||
|
||||
/** 是否真正下发短信;false 时验证码只写日志,便于本地联调 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 同一手机号的最小发送间隔(秒) */
|
||||
private long intervalSeconds = 60;
|
||||
|
||||
/** 同一手机号每日上限 */
|
||||
private int dailyLimit = 10;
|
||||
|
||||
/** 验证码有效期(秒) */
|
||||
private long codeExpire = 300;
|
||||
|
||||
/** 短信签名 */
|
||||
private String signName;
|
||||
|
||||
/** 短信模板 */
|
||||
private String templateCode;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.gxwebsoft.common.core.util.IpUtils;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.param.BindPhoneCaptchaParam;
|
||||
import com.gxwebsoft.common.system.param.BindPhoneParam;
|
||||
import com.gxwebsoft.common.system.param.ChangePasswordParam;
|
||||
import com.gxwebsoft.common.system.param.EmailLoginParam;
|
||||
import com.gxwebsoft.common.system.param.LoginParam;
|
||||
import com.gxwebsoft.common.system.param.SelfResetPasswordParam;
|
||||
import com.gxwebsoft.common.system.param.SmsLoginParam;
|
||||
import com.gxwebsoft.common.system.result.LoginResult;
|
||||
import com.gxwebsoft.common.system.result.UserInfoResult;
|
||||
import com.gxwebsoft.common.system.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.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.RestController;
|
||||
|
||||
/**
|
||||
* 认证接口。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "认证")
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class AuthController extends BaseController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@Operation(summary = "账号密码登录")
|
||||
@PostMapping("/login")
|
||||
public ApiResult<LoginResult> login(@Valid @RequestBody LoginParam param, HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.login(param, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "邮箱登录")
|
||||
@PostMapping("/loginByEmail")
|
||||
public ApiResult<LoginResult> loginByEmail(@Valid @RequestBody EmailLoginParam param,
|
||||
HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.loginByEmail(param, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "手机号验证码登录")
|
||||
@PostMapping("/loginBySms")
|
||||
public ApiResult<LoginResult> loginBySms(@Valid @RequestBody SmsLoginParam param,
|
||||
HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.loginBySms(param, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "登出")
|
||||
@PostMapping("/logout")
|
||||
public ApiResult<?> logout(HttpServletRequest request) {
|
||||
authService.logout(request);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "当前登录用户信息")
|
||||
@GetMapping("/me")
|
||||
public ApiResult<UserInfoResult> me() {
|
||||
return ApiResult.ok(authService.currentUser());
|
||||
}
|
||||
|
||||
@Operation(summary = "修改密码(需校验原密码,成功后当前会话失效)")
|
||||
@PostMapping("/changePassword")
|
||||
public ApiResult<?> changePassword(@Valid @RequestBody ChangePasswordParam param,
|
||||
HttpServletRequest request) {
|
||||
authService.changePassword(param, request);
|
||||
return ApiResult.ok("密码修改成功,请使用新密码登录");
|
||||
}
|
||||
|
||||
@Operation(summary = "自助找回密码")
|
||||
@PostMapping("/resetPassword")
|
||||
public ApiResult<?> resetPassword(@Valid @RequestBody SelfResetPasswordParam param,
|
||||
HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.resetPasswordBySelf(param, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "绑定手机号:发送短信验证码")
|
||||
@PostMapping("/bindPhone/captcha")
|
||||
public ApiResult<?> bindPhoneCaptcha(@Valid @RequestBody BindPhoneCaptchaParam param,
|
||||
HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.sendBindPhoneCaptcha(param, IpUtils.clientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "绑定 / 换绑手机号(换绑需校验原密码)")
|
||||
@PostMapping("/bindPhone")
|
||||
public ApiResult<?> bindPhone(@Valid @RequestBody BindPhoneParam param, HttpServletRequest request) {
|
||||
authService.bindPhone(param, request);
|
||||
return ApiResult.ok("手机号绑定成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.gxwebsoft.common.core.util.IpUtils;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.param.EmailCaptchaParam;
|
||||
import com.gxwebsoft.common.system.param.SmsCaptchaParam;
|
||||
import com.gxwebsoft.common.system.result.CaptchaResult;
|
||||
import com.gxwebsoft.common.system.service.AuthService;
|
||||
import com.gxwebsoft.common.system.service.CaptchaService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.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.RestController;
|
||||
|
||||
/**
|
||||
* 验证码接口。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "验证码")
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class CaptchaController extends BaseController {
|
||||
|
||||
private final CaptchaService captchaService;
|
||||
private final AuthService authService;
|
||||
|
||||
public CaptchaController(CaptchaService captchaService, AuthService authService) {
|
||||
this.captchaService = captchaService;
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取图形验证码")
|
||||
@GetMapping("/captcha")
|
||||
public ApiResult<CaptchaResult> captcha() {
|
||||
return ApiResult.ok(captchaService.generate());
|
||||
}
|
||||
|
||||
@Operation(summary = "发送短信验证码")
|
||||
@PostMapping("/sendSmsCaptcha")
|
||||
public ApiResult<?> sendSmsCaptcha(@Valid @RequestBody SmsCaptchaParam param, HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.sendSmsCaptcha(param.phone(), param.scene(), IpUtils.clientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "发送邮箱验证码")
|
||||
@PostMapping("/sendEmailCaptcha")
|
||||
public ApiResult<?> sendEmailCaptcha(@Valid @RequestBody EmailCaptchaParam param, HttpServletRequest request) {
|
||||
return ApiResult.ok(authService.sendEmailCaptcha(param.email(), IpUtils.clientIp(request)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.entity.Menu;
|
||||
import com.gxwebsoft.common.system.param.MenuPageParam;
|
||||
import com.gxwebsoft.common.system.param.MenuSaveParam;
|
||||
import com.gxwebsoft.common.system.service.MenuService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单 / 权限管理接口(平台级元数据)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "菜单管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/iam/menu")
|
||||
public class MenuController extends BaseController {
|
||||
|
||||
private final MenuService menuService;
|
||||
|
||||
public MenuController(MenuService menuService) {
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
@Operation(summary = "菜单树(全量启用)")
|
||||
@GetMapping("/tree")
|
||||
@PreAuthorize("hasAuthority('iam:menu:list')")
|
||||
public ApiResult<List<Menu>> tree(MenuPageParam param) {
|
||||
return success(menuService.tree(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "菜单扁平列表(启用)")
|
||||
@GetMapping("/list")
|
||||
@PreAuthorize("hasAuthority('iam:menu:list')")
|
||||
public ApiResult<List<Menu>> list(MenuPageParam param) {
|
||||
return success(menuService.listEnabled(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "新建菜单")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('iam:menu:add')")
|
||||
public ApiResult<?> create(@Valid @RequestBody MenuSaveParam param) {
|
||||
return success("创建成功", menuService.save(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改菜单")
|
||||
@PutMapping("/{menuId}")
|
||||
@PreAuthorize("hasAuthority('iam:menu:edit')")
|
||||
public ApiResult<?> update(@PathVariable Long menuId, @Valid @RequestBody MenuSaveParam param) {
|
||||
MenuSaveParam target = new MenuSaveParam(menuId, param.parentId(), param.menuName(), param.menuType(),
|
||||
param.path(), param.component(), param.permission(), param.icon(), param.sort(),
|
||||
param.visible(), param.status());
|
||||
return success("修改成功", menuService.save(target));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除菜单")
|
||||
@DeleteMapping("/{menuId}")
|
||||
@PreAuthorize("hasAuthority('iam:menu:remove')")
|
||||
public ApiResult<?> remove(@PathVariable Long menuId) {
|
||||
menuService.remove(menuId);
|
||||
return success("删除成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.entity.Role;
|
||||
import com.gxwebsoft.common.system.param.RoleMenuParam;
|
||||
import com.gxwebsoft.common.system.param.RolePageParam;
|
||||
import com.gxwebsoft.common.system.param.RoleSaveParam;
|
||||
import com.gxwebsoft.common.system.service.RoleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色管理接口(租户内)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "角色管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/iam/role")
|
||||
public class RoleController extends BaseController {
|
||||
|
||||
private final RoleService roleService;
|
||||
|
||||
public RoleController(RoleService roleService) {
|
||||
this.roleService = roleService;
|
||||
}
|
||||
|
||||
@Operation(summary = "角色分页")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('iam:role:list')")
|
||||
public ApiResult<?> page(RolePageParam param) {
|
||||
Page<Role> page = roleService.page(param);
|
||||
return success(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "启用角色列表(下拉选择)")
|
||||
@GetMapping("/options")
|
||||
@PreAuthorize("hasAuthority('iam:role:list')")
|
||||
public ApiResult<List<Role>> options() {
|
||||
return success(roleService.listEnabled());
|
||||
}
|
||||
|
||||
@Operation(summary = "新建角色")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('iam:role:add')")
|
||||
public ApiResult<?> create(@Valid @RequestBody RoleSaveParam param) {
|
||||
return success("创建成功", roleService.save(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改角色")
|
||||
@PutMapping("/{roleId}")
|
||||
@PreAuthorize("hasAuthority('iam:role:edit')")
|
||||
public ApiResult<?> update(@PathVariable Long roleId, @Valid @RequestBody RoleSaveParam param) {
|
||||
RoleSaveParam target = new RoleSaveParam(roleId, param.tenantId(), param.roleName(), param.roleCode(),
|
||||
param.sort(), param.remark(), param.status());
|
||||
return success("修改成功", roleService.save(target));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除角色")
|
||||
@DeleteMapping("/{roleId}")
|
||||
@PreAuthorize("hasAuthority('iam:role:remove')")
|
||||
public ApiResult<?> remove(@PathVariable Long roleId) {
|
||||
roleService.remove(roleId);
|
||||
return success("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "角色已授权菜单ID")
|
||||
@GetMapping("/{roleId}/menus")
|
||||
@PreAuthorize("hasAuthority('iam:role:list')")
|
||||
public ApiResult<List<Long>> menus(@PathVariable Long roleId) {
|
||||
return success(roleService.menuIdsOfRole(roleId));
|
||||
}
|
||||
|
||||
@Operation(summary = "角色菜单授权(覆盖式)")
|
||||
@PutMapping("/{roleId}/menus")
|
||||
@PreAuthorize("hasAuthority('iam:role:edit')")
|
||||
public ApiResult<?> assignMenus(@PathVariable Long roleId, @RequestBody RoleMenuParam param) {
|
||||
roleService.assignMenus(roleId, param.menuIds());
|
||||
return success("授权成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.entity.Tenant;
|
||||
import com.gxwebsoft.common.system.param.TenantOpenParam;
|
||||
import com.gxwebsoft.common.system.param.TenantPageParam;
|
||||
import com.gxwebsoft.common.system.param.TenantSaveParam;
|
||||
import com.gxwebsoft.common.system.service.TenantService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 租户管理接口(平台级,仅平台运营角色可用)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "租户管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/iam/tenant")
|
||||
public class TenantController extends BaseController {
|
||||
|
||||
private final TenantService tenantService;
|
||||
|
||||
public TenantController(TenantService tenantService) {
|
||||
this.tenantService = tenantService;
|
||||
}
|
||||
|
||||
@Operation(summary = "租户分页")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('iam:tenant:list')")
|
||||
public ApiResult<?> page(TenantPageParam param) {
|
||||
Page<Tenant> page = tenantService.page(param);
|
||||
return success(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "租户详情")
|
||||
@GetMapping("/{tenantId}")
|
||||
@PreAuthorize("hasAuthority('iam:tenant:list')")
|
||||
public ApiResult<Tenant> detail(@PathVariable Long tenantId) {
|
||||
return success(tenantService.getRequired(tenantId));
|
||||
}
|
||||
|
||||
@Operation(summary = "开通租户账号(创建租户 + 管理员)")
|
||||
@PostMapping("/open")
|
||||
@PreAuthorize("hasAuthority('iam:tenant:add')")
|
||||
public ApiResult<?> open(@Valid @RequestBody TenantOpenParam param) {
|
||||
return success("开通成功", tenantService.open(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "新建租户")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('iam:tenant:add')")
|
||||
public ApiResult<?> create(@Valid @RequestBody TenantSaveParam param) {
|
||||
return success("创建成功", tenantService.save(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改租户")
|
||||
@PutMapping("/{tenantId}")
|
||||
@PreAuthorize("hasAuthority('iam:tenant:edit')")
|
||||
public ApiResult<?> update(@PathVariable Long tenantId, @Valid @RequestBody TenantSaveParam param) {
|
||||
TenantSaveParam target = new TenantSaveParam(tenantId, param.tenantName(), param.tenantCode(),
|
||||
param.contactName(), param.contactPhone(), param.status(), param.expireTime());
|
||||
return success("修改成功", tenantService.save(target));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除租户")
|
||||
@DeleteMapping("/{tenantId}")
|
||||
@PreAuthorize("hasAuthority('iam:tenant:remove')")
|
||||
public ApiResult<?> remove(@PathVariable Long tenantId) {
|
||||
tenantService.remove(tenantId);
|
||||
return success("删除成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.gxwebsoft.common.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.system.entity.Role;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.param.UserPageParam;
|
||||
import com.gxwebsoft.common.system.param.UserResetPasswordParam;
|
||||
import com.gxwebsoft.common.system.param.UserSaveParam;
|
||||
import com.gxwebsoft.common.system.service.RoleService;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户管理接口(租户内)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Tag(name = "用户管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/iam/user")
|
||||
public class UserController extends BaseController {
|
||||
|
||||
private final UserService userService;
|
||||
private final RoleService roleService;
|
||||
|
||||
public UserController(UserService userService, RoleService roleService) {
|
||||
this.userService = userService;
|
||||
this.roleService = roleService;
|
||||
}
|
||||
|
||||
@Operation(summary = "用户分页")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('iam:user:list')")
|
||||
public ApiResult<?> page(UserPageParam param) {
|
||||
Page<User> page = userService.page(param);
|
||||
return success(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "用户详情")
|
||||
@GetMapping("/{userId}")
|
||||
@PreAuthorize("hasAuthority('iam:user:list')")
|
||||
public ApiResult<User> detail(@PathVariable Long userId) {
|
||||
return success(userService.getById(userId));
|
||||
}
|
||||
|
||||
@Operation(summary = "用户已分配角色")
|
||||
@GetMapping("/{userId}/roles")
|
||||
@PreAuthorize("hasAuthority('iam:user:list')")
|
||||
public ApiResult<List<Role>> roles(@PathVariable Long userId) {
|
||||
return success(roleService.listByUserId(userId));
|
||||
}
|
||||
|
||||
@Operation(summary = "新建用户")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('iam:user:add')")
|
||||
public ApiResult<?> create(@Valid @RequestBody UserSaveParam param) {
|
||||
return success("创建成功", userService.save(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改用户")
|
||||
@PutMapping("/{userId}")
|
||||
@PreAuthorize("hasAuthority('iam:user:edit')")
|
||||
public ApiResult<?> update(@PathVariable Long userId, @Valid @RequestBody UserSaveParam param) {
|
||||
UserSaveParam target = new UserSaveParam(userId, param.tenantId(), param.username(), param.password(),
|
||||
param.nickname(), param.phone(), param.email(), param.avatar(), param.status(), param.roleIds());
|
||||
return success("修改成功", userService.save(target));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除用户")
|
||||
@DeleteMapping("/{userId}")
|
||||
@PreAuthorize("hasAuthority('iam:user:remove')")
|
||||
public ApiResult<?> remove(@PathVariable Long userId) {
|
||||
userService.remove(userId);
|
||||
return success("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "重置用户密码")
|
||||
@PostMapping("/{userId}/resetPassword")
|
||||
@PreAuthorize("hasAuthority('iam:user:resetPwd')")
|
||||
public ApiResult<?> resetPassword(@PathVariable Long userId,
|
||||
@Valid @RequestBody UserResetPasswordParam param) {
|
||||
userService.resetPassword(userId, param.newPassword());
|
||||
return success("密码已重置");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 登录审计流水。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_login_record")
|
||||
@Schema(description = "登录记录")
|
||||
public class LoginRecord implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final int TYPE_LOGIN = 1;
|
||||
|
||||
public static final int TYPE_LOGOUT = 2;
|
||||
|
||||
public static final int TYPE_REFRESH = 3;
|
||||
|
||||
public static final int TYPE_BIND_PHONE = 4;
|
||||
|
||||
public static final int STATUS_FAILURE = 0;
|
||||
|
||||
public static final int STATUS_SUCCESS = 1;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long tenantId;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String username;
|
||||
|
||||
@Schema(description = "类型:1登录 2登出 3续签 4绑定手机号")
|
||||
private Integer type;
|
||||
|
||||
@Schema(description = "结果:0失败 1成功")
|
||||
private Integer status;
|
||||
|
||||
private String ip;
|
||||
|
||||
private String userAgent;
|
||||
|
||||
private String message;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单 / 权限。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_menu")
|
||||
@Schema(description = "菜单")
|
||||
public class Menu implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 菜单类型:目录 */
|
||||
public static final int TYPE_DIR = 1;
|
||||
|
||||
/** 菜单类型:菜单 */
|
||||
public static final int TYPE_MENU = 2;
|
||||
|
||||
/** 菜单类型:按钮 */
|
||||
public static final int TYPE_BUTTON = 3;
|
||||
|
||||
@TableId(value = "menu_id", type = IdType.AUTO)
|
||||
@Schema(description = "菜单ID")
|
||||
private Long menuId;
|
||||
|
||||
@Schema(description = "父菜单ID,0 为根")
|
||||
private Long parentId;
|
||||
|
||||
@Schema(description = "菜单名称")
|
||||
private String menuName;
|
||||
|
||||
@Schema(description = "类型:1目录 2菜单 3按钮")
|
||||
private Integer menuType;
|
||||
|
||||
@Schema(description = "路由地址")
|
||||
private String path;
|
||||
|
||||
@Schema(description = "前端组件路径")
|
||||
private String component;
|
||||
|
||||
@Schema(description = "权限标识")
|
||||
private String permission;
|
||||
|
||||
@Schema(description = "图标")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "是否显示:0隐藏 1显示")
|
||||
private Integer visible;
|
||||
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
|
||||
@TableLogic
|
||||
@Schema(description = "逻辑删除", hidden = true)
|
||||
private Integer delFlag;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** 子菜单,构建树时使用 */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "子菜单")
|
||||
private List<Menu> children = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 角色。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_role")
|
||||
@Schema(description = "角色")
|
||||
public class Role implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "role_id", type = IdType.AUTO)
|
||||
@Schema(description = "角色ID")
|
||||
private Long roleId;
|
||||
|
||||
@Schema(description = "所属租户")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "角色名称")
|
||||
private String roleName;
|
||||
|
||||
@Schema(description = "角色标识")
|
||||
private String roleCode;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
|
||||
@TableLogic
|
||||
@Schema(description = "逻辑删除", hidden = true)
|
||||
private Integer delFlag;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 角色菜单关联。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_role_menu")
|
||||
public class RoleMenu implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long roleId;
|
||||
|
||||
private Long menuId;
|
||||
|
||||
public RoleMenu() {
|
||||
}
|
||||
|
||||
public RoleMenu(Long roleId, Long menuId) {
|
||||
this.roleId = roleId;
|
||||
this.menuId = menuId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 租户。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_tenant")
|
||||
@Schema(description = "租户")
|
||||
public class Tenant implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "tenant_id", type = IdType.AUTO)
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "租户名称")
|
||||
private String tenantName;
|
||||
|
||||
@Schema(description = "业务系统租户标识,用于与业务库对齐")
|
||||
private String tenantCode;
|
||||
|
||||
@Schema(description = "联系人")
|
||||
private String contactName;
|
||||
|
||||
@Schema(description = "联系电话")
|
||||
private String contactPhone;
|
||||
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "到期时间")
|
||||
private LocalDateTime expireTime;
|
||||
|
||||
@TableLogic
|
||||
@Schema(description = "逻辑删除:0正常 1已删除", hidden = true)
|
||||
private Integer delFlag;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.gxwebsoft.common.core.context.TenantAware;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户。
|
||||
*
|
||||
* <p>作为 Spring Security 的登录主体,认证过滤器会把它放进 SecurityContext。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_user")
|
||||
@Schema(description = "用户")
|
||||
public class User implements Serializable, TenantAware {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "user_id", type = IdType.AUTO)
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "所属租户")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "登录账号")
|
||||
private String username;
|
||||
|
||||
/** 密码只进不出,任何序列化场景都不输出 */
|
||||
@JsonIgnore
|
||||
@Schema(description = "密码", hidden = true)
|
||||
private String password;
|
||||
|
||||
@Schema(description = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "头像")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "最后登录时间")
|
||||
private LocalDateTime lastLoginTime;
|
||||
|
||||
@TableLogic
|
||||
@Schema(description = "逻辑删除:0正常 1已删除", hidden = true)
|
||||
private Integer delFlag;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.gxwebsoft.common.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 用户角色关联。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_user_role")
|
||||
public class UserRole implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private Long roleId;
|
||||
|
||||
public UserRole() {
|
||||
}
|
||||
|
||||
public UserRole(Long userId, Long roleId) {
|
||||
this.userId = userId;
|
||||
this.roleId = roleId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.LoginRecord;
|
||||
|
||||
/**
|
||||
* 登录记录 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface LoginRecordMapper extends BaseMapper<LoginRecord> {
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.Menu;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单 / 权限 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface MenuMapper extends BaseMapper<Menu> {
|
||||
|
||||
/** 用户拥有的权限标识集合(去重) */
|
||||
@Select("""
|
||||
SELECT DISTINCT m.permission FROM sys_menu m
|
||||
JOIN sys_role_menu rm ON rm.menu_id = m.menu_id
|
||||
JOIN sys_user_role ur ON ur.role_id = rm.role_id
|
||||
WHERE ur.user_id = #{userId}
|
||||
AND m.del_flag = 0 AND m.status = 1
|
||||
AND m.permission IS NOT NULL AND m.permission <> ''
|
||||
""")
|
||||
List<String> selectPermissionsByUserId(@Param("userId") Long userId);
|
||||
|
||||
/** 用户可见的菜单(目录 / 菜单 / 按钮),按排序返回 */
|
||||
@Select("""
|
||||
SELECT DISTINCT m.* FROM sys_menu m
|
||||
JOIN sys_role_menu rm ON rm.menu_id = m.menu_id
|
||||
JOIN sys_user_role ur ON ur.role_id = rm.role_id
|
||||
WHERE ur.user_id = #{userId} AND m.del_flag = 0 AND m.status = 1
|
||||
ORDER BY m.sort, m.menu_id
|
||||
""")
|
||||
List<Menu> selectByUserId(@Param("userId") Long userId);
|
||||
|
||||
/** 角色已勾选的菜单 ID */
|
||||
@Select("""
|
||||
SELECT rm.menu_id FROM sys_role_menu rm
|
||||
JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = #{roleId} AND m.del_flag = 0
|
||||
""")
|
||||
List<Long> selectMenuIdsByRoleId(@Param("roleId") Long roleId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.Role;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface RoleMapper extends BaseMapper<Role> {
|
||||
|
||||
/** 用户已分配的角色 */
|
||||
@Select("""
|
||||
SELECT r.* FROM sys_role r
|
||||
JOIN sys_user_role ur ON ur.role_id = r.role_id
|
||||
WHERE ur.user_id = #{userId} AND r.del_flag = 0 AND r.status = 1
|
||||
ORDER BY r.sort, r.role_id
|
||||
""")
|
||||
List<Role> selectByUserId(@Param("userId") Long userId);
|
||||
|
||||
/** 用户的角色标识集合 */
|
||||
@Select("""
|
||||
SELECT r.role_code FROM sys_role r
|
||||
JOIN sys_user_role ur ON ur.role_id = r.role_id
|
||||
WHERE ur.user_id = #{userId} AND r.del_flag = 0 AND r.status = 1
|
||||
""")
|
||||
List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.RoleMenu;
|
||||
|
||||
/**
|
||||
* 角色菜单关联 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.Tenant;
|
||||
|
||||
/**
|
||||
* 租户 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface TenantMapper extends BaseMapper<Tenant> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
|
||||
/**
|
||||
* 用户 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.gxwebsoft.common.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.common.system.entity.UserRole;
|
||||
|
||||
/**
|
||||
* 用户角色关联 Mapper。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public interface UserRoleMapper extends BaseMapper<UserRole> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 绑定手机号:发送短信验证码参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "绑定手机号验证码")
|
||||
public record BindPhoneCaptchaParam(
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Schema(description = "待绑定手机号") String phone) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 绑定 / 换绑手机号参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "绑定手机号")
|
||||
public record BindPhoneParam(
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Schema(description = "新手机号") String phone,
|
||||
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Schema(description = "短信验证码") String code,
|
||||
|
||||
@Schema(description = "原密码,仅换绑(账号已绑定手机号)时必填") String password) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 修改密码参数(已登录,需校验原密码)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "修改密码")
|
||||
public record ChangePasswordParam(
|
||||
@NotBlank(message = "原密码不能为空")
|
||||
@Schema(description = "原密码") String oldPassword,
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Schema(description = "新密码,至少 8 位且包含字母和数字") String newPassword,
|
||||
|
||||
@NotBlank(message = "确认密码不能为空")
|
||||
@Schema(description = "确认新密码") String confirmPassword) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "发送邮箱验证码")
|
||||
public record EmailCaptchaParam(
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Schema(description = "邮箱") String email,
|
||||
|
||||
@Schema(description = "场景:login(默认)/ register") String scene) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 邮箱登录参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "邮箱登录")
|
||||
public record EmailLoginParam(
|
||||
@Schema(description = "租户ID,可空;为空时按邮箱自动定位所属租户") Long tenantId,
|
||||
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Schema(description = "邮箱") String email,
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Schema(description = "密码") String password,
|
||||
|
||||
@Schema(description = "图形验证码标识") String captchaId,
|
||||
|
||||
@Schema(description = "图形验证码") String captchaCode) {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 账号密码登录参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "账号密码登录")
|
||||
public record LoginParam(
|
||||
@NotNull(message = "租户ID不能为空")
|
||||
@Schema(description = "租户ID") Long tenantId,
|
||||
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@Schema(description = "登录账号") String username,
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Schema(description = "密码") String password,
|
||||
|
||||
@Schema(description = "图形验证码标识") String captchaId,
|
||||
|
||||
@Schema(description = "图形验证码") String captchaCode) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 菜单查询参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class MenuPageParam extends BaseParam {
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "菜单名称")
|
||||
private String menuName;
|
||||
|
||||
@QueryField
|
||||
@Schema(description = "类型:1目录 2菜单 3按钮")
|
||||
private Integer menuType;
|
||||
|
||||
@QueryField
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 菜单保存参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "菜单保存")
|
||||
public record MenuSaveParam(
|
||||
@Schema(description = "菜单ID,新建时为空") Long menuId,
|
||||
|
||||
@Schema(description = "父菜单ID,0 为根") Long parentId,
|
||||
|
||||
@NotBlank(message = "菜单名称不能为空")
|
||||
@Schema(description = "菜单名称") String menuName,
|
||||
|
||||
@NotNull(message = "菜单类型不能为空")
|
||||
@Schema(description = "类型:1目录 2菜单 3按钮") Integer menuType,
|
||||
|
||||
@Schema(description = "路由地址") String path,
|
||||
|
||||
@Schema(description = "前端组件路径") String component,
|
||||
|
||||
@Schema(description = "权限标识") String permission,
|
||||
|
||||
@Schema(description = "图标") String icon,
|
||||
|
||||
@Schema(description = "排序") Integer sort,
|
||||
|
||||
@Schema(description = "是否显示:0隐藏 1显示") Integer visible,
|
||||
|
||||
@Schema(description = "状态:0停用 1启用") Integer status) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色菜单授权参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "角色菜单授权")
|
||||
public record RoleMenuParam(
|
||||
@Schema(description = "菜单ID集合,空集合表示取消全部授权") List<Long> menuIds) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 角色查询参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class RolePageParam extends BaseParam {
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "角色名称")
|
||||
private String roleName;
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "角色标识")
|
||||
private String roleCode;
|
||||
|
||||
@QueryField
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 角色保存参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "角色保存")
|
||||
public record RoleSaveParam(
|
||||
@Schema(description = "角色ID,新建时为空") Long roleId,
|
||||
|
||||
@Schema(description = "所属租户,为空时取当前租户") Long tenantId,
|
||||
|
||||
@NotBlank(message = "角色名称不能为空")
|
||||
@Schema(description = "角色名称") String roleName,
|
||||
|
||||
@NotBlank(message = "角色标识不能为空")
|
||||
@Schema(description = "角色标识") String roleCode,
|
||||
|
||||
@Schema(description = "排序") Integer sort,
|
||||
|
||||
@Schema(description = "备注") String remark,
|
||||
|
||||
@Schema(description = "状态:0停用 1启用") Integer status) {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 自助找回密码参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "自助找回密码")
|
||||
public record SelfResetPasswordParam(
|
||||
@Schema(description = "租户ID;邮箱找回可空,手机找回必填") Long tenantId,
|
||||
|
||||
@NotBlank(message = "找回方式不能为空")
|
||||
@Schema(description = "找回方式:sms / email") String type,
|
||||
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@Schema(description = "手机号或邮箱") String account,
|
||||
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Schema(description = "验证码") String code,
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Schema(description = "新密码") String newPassword,
|
||||
|
||||
@NotBlank(message = "确认密码不能为空")
|
||||
@Schema(description = "确认新密码") String confirmPassword) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 发送短信验证码参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "发送短信验证码")
|
||||
public record SmsCaptchaParam(
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Schema(description = "手机号") String phone,
|
||||
|
||||
@Schema(description = "场景:login(默认)/ register") String scene) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 手机号验证码登录参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "手机号验证码登录")
|
||||
public record SmsLoginParam(
|
||||
@NotNull(message = "租户ID不能为空")
|
||||
@Schema(description = "租户ID") Long tenantId,
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Schema(description = "手机号") String phone,
|
||||
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Schema(description = "短信验证码") String code) {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 开通租户账号参数:一次请求创建租户 + 管理员账号,供平台运营使用。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "开通租户账号")
|
||||
public record TenantOpenParam(
|
||||
@NotBlank(message = "租户名称不能为空")
|
||||
@Schema(description = "租户名称") String tenantName,
|
||||
|
||||
@Schema(description = "业务系统租户标识") String tenantCode,
|
||||
|
||||
@Schema(description = "联系人") String contactName,
|
||||
|
||||
@Schema(description = "联系电话") String contactPhone,
|
||||
|
||||
@NotBlank(message = "管理员账号不能为空")
|
||||
@Schema(description = "管理员登录账号") String adminUsername,
|
||||
|
||||
@NotBlank(message = "管理员密码不能为空")
|
||||
@Schema(description = "管理员密码") String adminPassword) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 租户查询参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class TenantPageParam extends BaseParam {
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "租户名称")
|
||||
private String tenantName;
|
||||
|
||||
@QueryField(type = QueryType.LIKE)
|
||||
@Schema(description = "租户标识")
|
||||
private String tenantCode;
|
||||
|
||||
@QueryField
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 租户保存参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "租户保存")
|
||||
public record TenantSaveParam(
|
||||
@Schema(description = "租户ID,新建时为空") Long tenantId,
|
||||
|
||||
@NotBlank(message = "租户名称不能为空")
|
||||
@Schema(description = "租户名称") String tenantName,
|
||||
|
||||
@Schema(description = "业务系统租户标识") String tenantCode,
|
||||
|
||||
@Schema(description = "联系人") String contactName,
|
||||
|
||||
@Schema(description = "联系电话") String contactPhone,
|
||||
|
||||
@Schema(description = "状态:0停用 1启用") Integer status,
|
||||
|
||||
@Schema(description = "到期时间,空表示永久") LocalDateTime expireTime) {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 用户查询参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserPageParam extends BaseParam {
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "账号")
|
||||
private String username;
|
||||
|
||||
@QueryField(type = QueryType.LIKE, keywords = true)
|
||||
@Schema(description = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@QueryField(type = QueryType.LIKE)
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@QueryField
|
||||
@Schema(description = "状态:0停用 1启用")
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 管理员重置用户密码参数。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "重置用户密码")
|
||||
public record UserResetPasswordParam(
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Schema(description = "新密码,至少 8 位且包含字母和数字") String newPassword) {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户保存参数(新建 / 修改)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "用户保存")
|
||||
public record UserSaveParam(
|
||||
@Schema(description = "用户ID,新建时为空") Long userId,
|
||||
|
||||
@Schema(description = "所属租户,为空时取当前租户") Long tenantId,
|
||||
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@Schema(description = "登录账号") String username,
|
||||
|
||||
@Schema(description = "密码,新建时必填;修改时留空表示不改密码") String password,
|
||||
|
||||
@Schema(description = "昵称") String nickname,
|
||||
|
||||
@Schema(description = "手机号") String phone,
|
||||
|
||||
@Schema(description = "邮箱") String email,
|
||||
|
||||
@Schema(description = "头像") String avatar,
|
||||
|
||||
@Schema(description = "状态:0停用 1启用") Integer status,
|
||||
|
||||
@Schema(description = "角色ID集合") List<Long> roleIds) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.gxwebsoft.common.system.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 图形验证码响应:只返回标识与图片,答案不下发。
|
||||
*
|
||||
* @param captchaId 验证码标识
|
||||
* @param image Base64 图片
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "图形验证码")
|
||||
public record CaptchaResult(String captchaId, String image) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.gxwebsoft.common.system.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 登录响应。
|
||||
*
|
||||
* @param token 访问令牌
|
||||
* @param tokenType 令牌类型,固定 Bearer
|
||||
* @param expiresIn 有效期(秒)
|
||||
* @param user 用户信息
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "登录响应")
|
||||
public record LoginResult(String token, String tokenType, Long expiresIn, UserInfoResult user) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.gxwebsoft.common.system.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 当前登录用户信息(含角色与权限标识)。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Schema(description = "用户信息")
|
||||
public record UserInfoResult(
|
||||
@Schema(description = "用户ID") Long userId,
|
||||
@Schema(description = "租户ID") Long tenantId,
|
||||
@Schema(description = "账号") String username,
|
||||
@Schema(description = "昵称") String nickname,
|
||||
@Schema(description = "头像") String avatar,
|
||||
@Schema(description = "手机号") String phone,
|
||||
@Schema(description = "邮箱") String email,
|
||||
@Schema(description = "角色标识") List<String> roles,
|
||||
@Schema(description = "权限标识") List<String> permissions) {
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.gxwebsoft.common.system.security;
|
||||
|
||||
import com.gxwebsoft.common.core.context.TenantContext;
|
||||
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||
import com.gxwebsoft.common.core.security.LoginUserProvider;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.service.MenuService;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 基于 {@code sys_user} / {@code sys_menu} 的登录主体加载。
|
||||
*
|
||||
* <p>认证过滤器在租户过滤器之前执行,此时 {@link TenantContext} 还没有租户,
|
||||
* 因此这里显式用令牌里的租户包一层,避免多租户插件把查询条件注入成
|
||||
* {@code tenant_id = null} 而永远查不到用户。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
public class SystemLoginUserProvider implements LoginUserProvider {
|
||||
|
||||
private static final int STATUS_ENABLED = 1;
|
||||
|
||||
private final UserService userService;
|
||||
private final MenuService menuService;
|
||||
|
||||
public SystemLoginUserProvider(UserService userService, MenuService menuService) {
|
||||
this.userService = userService;
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object loadPrincipal(JwtSubject subject) {
|
||||
if (subject.tenantId() == null) {
|
||||
return null;
|
||||
}
|
||||
User user = TenantContext.callWithTenant(subject.tenantId(),
|
||||
() -> userService.getByUsername(subject.tenantId(), subject.username()));
|
||||
if (user == null || user.getStatus() == null || user.getStatus() != STATUS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> loadAuthorities(Object principal) {
|
||||
if (!(principal instanceof User user)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> permissions = TenantContext.callWithTenant(user.getTenantId(),
|
||||
() -> menuService.permissionsOfUser(user.getUserId()));
|
||||
return permissions.stream().filter(p -> p != null && !p.isBlank())
|
||||
.distinct()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.cache.CacheStore;
|
||||
import com.gxwebsoft.common.core.context.TenantContext;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.core.security.JwtSubject;
|
||||
import com.gxwebsoft.common.core.security.JwtUtil;
|
||||
import com.gxwebsoft.common.core.security.RevokedTokenStore;
|
||||
import com.gxwebsoft.common.system.config.AuthProperties;
|
||||
import com.gxwebsoft.common.system.entity.LoginRecord;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.param.BindPhoneCaptchaParam;
|
||||
import com.gxwebsoft.common.system.param.BindPhoneParam;
|
||||
import com.gxwebsoft.common.system.param.ChangePasswordParam;
|
||||
import com.gxwebsoft.common.system.param.EmailLoginParam;
|
||||
import com.gxwebsoft.common.system.param.LoginParam;
|
||||
import com.gxwebsoft.common.system.param.SelfResetPasswordParam;
|
||||
import com.gxwebsoft.common.system.param.SmsLoginParam;
|
||||
import com.gxwebsoft.common.system.result.LoginResult;
|
||||
import com.gxwebsoft.common.system.result.UserInfoResult;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证服务:登录 / 登出 / 当前用户 / 改密 / 绑手机 / 找回密码。
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||
|
||||
private static final int STATUS_ENABLED = 1;
|
||||
|
||||
/** 场景:登录(默认),只对已注册账号发码 */
|
||||
private static final String SCENE_LOGIN = "login";
|
||||
|
||||
private static final String TYPE_EMAIL = "email";
|
||||
private static final String TYPE_SMS = "sms";
|
||||
|
||||
/** 统一的发码提示:账号是否存在不对外区分,避免手机号 / 邮箱枚举 */
|
||||
private static final String CAPTCHA_SENT_MESSAGE = "验证码已发送,请注意查收(5 分钟内有效)";
|
||||
|
||||
private final UserService userService;
|
||||
private final TenantService tenantService;
|
||||
private final RoleService roleService;
|
||||
private final MenuService menuService;
|
||||
private final LoginRecordService loginRecordService;
|
||||
private final CaptchaService captchaService;
|
||||
private final SmsCaptchaService smsCaptchaService;
|
||||
private final EmailCaptchaService emailCaptchaService;
|
||||
private final AuthProperties authProperties;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final CacheStore cacheStore;
|
||||
private final RevokedTokenStore revokedTokenStore;
|
||||
|
||||
public AuthService(UserService userService, TenantService tenantService, RoleService roleService,
|
||||
MenuService menuService, LoginRecordService loginRecordService,
|
||||
CaptchaService captchaService, SmsCaptchaService smsCaptchaService,
|
||||
EmailCaptchaService emailCaptchaService, AuthProperties authProperties,
|
||||
JwtUtil jwtUtil, PasswordEncoder passwordEncoder, CacheStore cacheStore,
|
||||
RevokedTokenStore revokedTokenStore) {
|
||||
this.userService = userService;
|
||||
this.tenantService = tenantService;
|
||||
this.roleService = roleService;
|
||||
this.menuService = menuService;
|
||||
this.loginRecordService = loginRecordService;
|
||||
this.captchaService = captchaService;
|
||||
this.smsCaptchaService = smsCaptchaService;
|
||||
this.emailCaptchaService = emailCaptchaService;
|
||||
this.authProperties = authProperties;
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.cacheStore = cacheStore;
|
||||
this.revokedTokenStore = revokedTokenStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码登录:校验图形验证码 → 锁定状态 → 校验账号密码 → 校验状态 → 签发 token。
|
||||
*
|
||||
* <p>登录请求还没有 token,租户来自请求体,因此整个流程都放在该租户的上下文中执行,
|
||||
* 保证多租户插件对 {@code sys_user} 的查询注入正确的 tenant_id。</p>
|
||||
*/
|
||||
public LoginResult login(LoginParam param, HttpServletRequest request) {
|
||||
captchaService.verify(param.captchaId(), param.captchaCode());
|
||||
User user = checkPasswordAndLock(param.tenantId(), param.username(), param.password(), request);
|
||||
return TenantContext.callWithTenant(param.tenantId(), () -> issueLogin(user, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱登录:与账号密码登录共用图形验证码与失败锁定,账号按「租户 + 邮箱」定位。
|
||||
*
|
||||
* <p>未指定租户时按邮箱反查所属租户,登录页因此无需填写租户 ID。</p>
|
||||
*/
|
||||
public LoginResult loginByEmail(EmailLoginParam param, HttpServletRequest request) {
|
||||
captchaService.verify(param.captchaId(), param.captchaCode());
|
||||
Long tenantId = param.tenantId() != null
|
||||
? param.tenantId()
|
||||
: userService.resolveTenantIdByEmail(param.email());
|
||||
if (tenantId == null) {
|
||||
throw new BusinessException("邮箱或密码错误");
|
||||
}
|
||||
checkLoginLocked(tenantId, param.email());
|
||||
LoginResult result = TenantContext.callWithTenant(tenantId, () -> {
|
||||
tenantService.getRequired(tenantId);
|
||||
User user = userService.getByEmail(tenantId, param.email());
|
||||
if (user == null || StrUtil.isBlank(param.password())
|
||||
|| !passwordEncoder.matches(param.password(), user.getPassword())) {
|
||||
loginRecordService.record(tenantId, null, param.email(),
|
||||
LoginRecord.TYPE_LOGIN, false, "邮箱或密码错误", request);
|
||||
increaseLoginFail(tenantId, param.email());
|
||||
throw new BusinessException("邮箱或密码错误");
|
||||
}
|
||||
checkUserEnabled(user, request);
|
||||
return issueLogin(user, request);
|
||||
});
|
||||
clearLoginFail(tenantId, param.email());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号验证码登录:短信验证码本身就是身份凭证,不再额外要求图形验证码。
|
||||
*/
|
||||
public LoginResult loginBySms(SmsLoginParam param, HttpServletRequest request) {
|
||||
return TenantContext.callWithTenant(param.tenantId(), () -> {
|
||||
tenantService.getRequired(param.tenantId());
|
||||
User user = userService.getByPhone(param.tenantId(), param.phone());
|
||||
// 发码接口只对已注册手机号真正发码,因此这里不存在可利用的枚举面
|
||||
if (user == null) {
|
||||
throw new BusinessException("该手机号未注册");
|
||||
}
|
||||
// 先核销验证码,再做状态判断,避免同一验证码被反复试探
|
||||
smsCaptchaService.verify(param.phone(), param.code());
|
||||
checkUserEnabled(user, request);
|
||||
return issueLogin(user, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出:把当前令牌写入吊销名单。
|
||||
*
|
||||
* <p>JWT 本身无状态,只让客户端丢弃令牌的话,令牌在有效期内仍然可用
|
||||
* (被复制走也照样能登录),因此这里必须显式吊销。</p>
|
||||
*/
|
||||
public void logout(HttpServletRequest request) {
|
||||
revoke(request);
|
||||
User user = currentLoginUser();
|
||||
if (user != null) {
|
||||
loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(),
|
||||
LoginRecord.TYPE_LOGOUT, true, null, request);
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
/** 当前登录用户信息(含角色与权限标识) */
|
||||
public UserInfoResult currentUser() {
|
||||
return buildUserInfo(requireCurrentUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(已登录,需校验原密码)。
|
||||
*
|
||||
* <p>密码变更后当前会话立即失效:即使旧令牌未过期,也应强制用新密码重新登录。</p>
|
||||
*/
|
||||
public void changePassword(ChangePasswordParam param, HttpServletRequest request) {
|
||||
User user = requireCurrentUser();
|
||||
if (!param.newPassword().equals(param.confirmPassword())) {
|
||||
throw new BusinessException("两次输入的新密码不一致");
|
||||
}
|
||||
userService.changePassword(user.getUserId(), param.oldPassword(), param.newPassword());
|
||||
revoke(request);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
/** 绑定手机号:发送短信验证码(需登录) */
|
||||
public String sendBindPhoneCaptcha(BindPhoneCaptchaParam param, String clientIp) {
|
||||
requireCurrentUser();
|
||||
checkSendIpLimit(clientIp);
|
||||
smsCaptchaService.send(param.phone(), SCENE_LOGIN);
|
||||
return CAPTCHA_SENT_MESSAGE;
|
||||
}
|
||||
|
||||
/** 绑定 / 换绑手机号;账号已绑定手机号时要求同时提供原密码 */
|
||||
public void bindPhone(BindPhoneParam param, HttpServletRequest request) {
|
||||
User user = requireCurrentUser();
|
||||
if (StrUtil.isNotBlank(user.getPhone())
|
||||
&& (StrUtil.isBlank(param.password())
|
||||
|| !passwordEncoder.matches(param.password(), user.getPassword()))) {
|
||||
throw new BusinessException("换绑手机号需要校验原密码");
|
||||
}
|
||||
smsCaptchaService.verify(param.phone(), param.code());
|
||||
userService.bindPhone(user.getUserId(), param.phone());
|
||||
loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(),
|
||||
LoginRecord.TYPE_BIND_PHONE, true, "绑定手机号 " + param.phone(), request);
|
||||
}
|
||||
|
||||
/** 自助找回密码:按 type 校验对应验证码后重置 */
|
||||
public String resetPasswordBySelf(SelfResetPasswordParam param, HttpServletRequest request) {
|
||||
if (!param.newPassword().equals(param.confirmPassword())) {
|
||||
throw new BusinessException("两次输入的新密码不一致");
|
||||
}
|
||||
boolean byEmail = TYPE_EMAIL.equalsIgnoreCase(param.type());
|
||||
if (!byEmail && !TYPE_SMS.equalsIgnoreCase(param.type())) {
|
||||
throw new BusinessException("不支持的找回方式");
|
||||
}
|
||||
Long tenantId = param.tenantId();
|
||||
if (byEmail) {
|
||||
emailCaptchaService.verify(param.account(), param.code());
|
||||
if (tenantId == null) {
|
||||
tenantId = userService.resolveTenantIdByEmail(param.account());
|
||||
}
|
||||
} else {
|
||||
if (tenantId == null) {
|
||||
throw new BusinessException("手机号找回需要指定租户");
|
||||
}
|
||||
smsCaptchaService.verify(param.account(), param.code());
|
||||
}
|
||||
if (tenantId == null) {
|
||||
throw new BusinessException("账号不存在");
|
||||
}
|
||||
Long resolvedTenantId = tenantId;
|
||||
TenantContext.callWithTenant(resolvedTenantId, () -> {
|
||||
User user = byEmail
|
||||
? userService.getByEmail(resolvedTenantId, param.account())
|
||||
: userService.getByPhone(resolvedTenantId, param.account());
|
||||
if (user == null) {
|
||||
throw new BusinessException("账号不存在");
|
||||
}
|
||||
userService.resetPasswordWithoutOld(user.getUserId(), param.newPassword());
|
||||
loginRecordService.record(resolvedTenantId, user.getUserId(), user.getUsername(),
|
||||
LoginRecord.TYPE_LOGIN, true, "自助重置密码", request);
|
||||
return null;
|
||||
});
|
||||
return "密码已重置,请使用新密码登录";
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送手机验证码。
|
||||
*
|
||||
* <p>{@code scene=login} 只给已注册手机号真正发码;号码未注册时同样返回成功提示,
|
||||
* 避免把「手机号是否注册」暴露成可枚举接口。</p>
|
||||
*/
|
||||
public String sendSmsCaptcha(String phone, String scene, String clientIp) {
|
||||
checkSendIpLimit(clientIp);
|
||||
String resolvedScene = StrUtil.blankToDefault(scene, SCENE_LOGIN);
|
||||
if (SCENE_LOGIN.equalsIgnoreCase(resolvedScene)) {
|
||||
Long tenantId = TenantContext.getTenantId();
|
||||
if (tenantId != null && userService.getByPhone(tenantId, phone) == null) {
|
||||
log.info("手机号未注册,静默返回不发送验证码:tenantId={}", tenantId);
|
||||
return CAPTCHA_SENT_MESSAGE;
|
||||
}
|
||||
}
|
||||
smsCaptchaService.send(phone, resolvedScene);
|
||||
return CAPTCHA_SENT_MESSAGE;
|
||||
}
|
||||
|
||||
/** 发送邮箱验证码 */
|
||||
public String sendEmailCaptcha(String email, String clientIp) {
|
||||
checkSendIpLimit(clientIp);
|
||||
emailCaptchaService.send(email);
|
||||
return CAPTCHA_SENT_MESSAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权码申请时的终端用户身份校验。
|
||||
*
|
||||
* <p>该入口也是免登录的,且直接收账号密码,因此必须与账号密码登录共用同一套
|
||||
* 图形验证码与失败锁定策略——否则它就会变成绕过验证码、无限试密码的旁路。</p>
|
||||
*/
|
||||
public User authenticateForAuthorize(Long tenantId, String username, String password,
|
||||
String captchaId, String captchaCode, HttpServletRequest request) {
|
||||
captchaService.verify(captchaId, captchaCode);
|
||||
return checkPasswordAndLock(tenantId, username, password, request);
|
||||
}
|
||||
|
||||
/** 失败锁定 → 账号密码比对 → 账号状态校验 */
|
||||
private User checkPasswordAndLock(Long tenantId, String username, String password,
|
||||
HttpServletRequest request) {
|
||||
checkLoginLocked(tenantId, username);
|
||||
User user = TenantContext.callWithTenant(tenantId, () -> {
|
||||
tenantService.getRequired(tenantId);
|
||||
User found = userService.getByUsername(tenantId, username);
|
||||
if (found == null || StrUtil.isBlank(password)
|
||||
|| !passwordEncoder.matches(password, found.getPassword())) {
|
||||
loginRecordService.record(tenantId, null, username,
|
||||
LoginRecord.TYPE_LOGIN, false, "账号或密码错误", request);
|
||||
increaseLoginFail(tenantId, username);
|
||||
throw new BusinessException("账号或密码错误");
|
||||
}
|
||||
checkUserEnabled(found, request);
|
||||
return found;
|
||||
});
|
||||
clearLoginFail(tenantId, username);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 账号停用校验(各登录方式共用) */
|
||||
private void checkUserEnabled(User user, HttpServletRequest request) {
|
||||
if (user.getStatus() == null || user.getStatus() != STATUS_ENABLED) {
|
||||
loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(),
|
||||
LoginRecord.TYPE_LOGIN, false, "账号已停用", request);
|
||||
throw new BusinessException("账号已停用,请联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录成功后的统一收尾:更新登录时间、签发令牌、写审计 */
|
||||
private LoginResult issueLogin(User user, HttpServletRequest request) {
|
||||
userService.touchLastLogin(user.getUserId());
|
||||
String token = jwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()));
|
||||
loginRecordService.record(user.getTenantId(), user.getUserId(), user.getUsername(),
|
||||
LoginRecord.TYPE_LOGIN, true, null, request);
|
||||
log.info("用户登录成功 tenantId={} username={}", user.getTenantId(), user.getUsername());
|
||||
return new LoginResult(token, Constants.TOKEN_TYPE, jwtUtil.getExpireSeconds(), buildUserInfo(user));
|
||||
}
|
||||
|
||||
/** 组装用户信息:角色与权限标识供控制台做按钮级控制 */
|
||||
private UserInfoResult buildUserInfo(User user) {
|
||||
return TenantContext.callWithTenant(user.getTenantId(), () -> {
|
||||
List<String> roles = roleService.roleCodesByUserId(user.getUserId());
|
||||
List<String> permissions = menuService.permissionsOfUser(user.getUserId());
|
||||
return new UserInfoResult(user.getUserId(), user.getTenantId(), user.getUsername(),
|
||||
user.getNickname(), user.getAvatar(), user.getPhone(), user.getEmail(),
|
||||
roles, permissions);
|
||||
});
|
||||
}
|
||||
|
||||
private User currentLoginUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null) {
|
||||
return null;
|
||||
}
|
||||
Object principal = authentication.getPrincipal();
|
||||
return principal instanceof User user ? user : null;
|
||||
}
|
||||
|
||||
private User requireCurrentUser() {
|
||||
User user = currentLoginUser();
|
||||
if (user == null) {
|
||||
throw new BusinessException(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 把当前请求携带的令牌写入吊销名单 */
|
||||
private void revoke(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
String rawToken = JwtUtil.getAccessToken(request);
|
||||
if (StrUtil.isBlank(rawToken)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtUtil.parseToken(rawToken);
|
||||
revokedTokenStore.revoke(claims.getId(), jwtUtil.remainingSeconds(claims));
|
||||
} catch (Exception e) {
|
||||
log.debug("登出时解析令牌失败,忽略:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLoginLocked(Long tenantId, String account) {
|
||||
if (cacheStore.exists(loginLockKey(tenantId, account))) {
|
||||
long minutes = Math.max(1, authProperties.getLoginLockSeconds() / 60);
|
||||
throw new BusinessException("账号已被锁定,请 " + minutes + " 分钟后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private void increaseLoginFail(Long tenantId, String account) {
|
||||
String failKey = "iam:login:fail:" + tenantId + ":" + account;
|
||||
long count = cacheStore.increment(failKey, authProperties.getLoginLockSeconds());
|
||||
if (count >= authProperties.getLoginFailThreshold()) {
|
||||
cacheStore.set(loginLockKey(tenantId, account), "1", authProperties.getLoginLockSeconds());
|
||||
cacheStore.delete(failKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearLoginFail(Long tenantId, String account) {
|
||||
cacheStore.delete("iam:login:fail:" + tenantId + ":" + account);
|
||||
cacheStore.delete(loginLockKey(tenantId, account));
|
||||
}
|
||||
|
||||
private String loginLockKey(Long tenantId, String account) {
|
||||
return "iam:login:lock:" + tenantId + ":" + account;
|
||||
}
|
||||
|
||||
/** 发码接口的 IP 维度限流:短间隔 + 每小时上限 */
|
||||
private void checkSendIpLimit(String clientIp) {
|
||||
if (StrUtil.isBlank(clientIp)) {
|
||||
return;
|
||||
}
|
||||
String prefix = "iam:code:send:ip:";
|
||||
boolean ok = cacheStore.setIfAbsent(prefix + clientIp, "1", authProperties.getCodeSendIpIntervalSeconds());
|
||||
if (!ok) {
|
||||
throw new BusinessException("请求过于频繁,请稍后再试");
|
||||
}
|
||||
long count = cacheStore.increment(prefix + clientIp + ":hourly", 3600);
|
||||
if (count > authProperties.getCodeSendIpHourlyLimit()) {
|
||||
throw new BusinessException("该 IP 发码次数已达上限,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.cache.CacheStore;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.config.AuthProperties;
|
||||
import com.gxwebsoft.common.system.result.CaptchaResult;
|
||||
import com.wf.captcha.SpecCaptcha;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 图形验证码服务。
|
||||
*
|
||||
* <p>答案只存缓存、不下发;校验成功后立即删除,避免同一个验证码被反复使用。
|
||||
* 缓存不可用时按配置降级放行,避免缓存故障导致全员无法登录。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Service
|
||||
public class CaptchaService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CaptchaService.class);
|
||||
|
||||
private static final String KEY_PREFIX = "iam:captcha:";
|
||||
|
||||
private static final int WIDTH = 130;
|
||||
|
||||
private static final int HEIGHT = 48;
|
||||
|
||||
private static final int LENGTH = 4;
|
||||
|
||||
private final CacheStore cacheStore;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
public CaptchaService(CacheStore cacheStore, AuthProperties authProperties) {
|
||||
this.cacheStore = cacheStore;
|
||||
this.authProperties = authProperties;
|
||||
}
|
||||
|
||||
/** 生成验证码:返回标识与 Base64 图片 */
|
||||
public CaptchaResult generate() {
|
||||
SpecCaptcha captcha = new SpecCaptcha(WIDTH, HEIGHT, LENGTH);
|
||||
String captchaId = IdUtil.fastSimpleUUID();
|
||||
cacheStore.set(KEY_PREFIX + captchaId, captcha.text().toLowerCase(), authProperties.getCaptchaExpire());
|
||||
return new CaptchaResult(captchaId, captcha.toBase64());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验验证码。
|
||||
*
|
||||
* <p>{@code config.auth.captcha-enabled=false} 时直接放行;缓存不可用时降级放行并打告警,
|
||||
* 因为验证码失效只是「少一道风控」,而放行失败会让所有人都无法登录。</p>
|
||||
*/
|
||||
public void verify(String captchaId, String captchaCode) {
|
||||
if (!authProperties.isCaptchaEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captchaCode)) {
|
||||
throw new BusinessException("请输入图形验证码");
|
||||
}
|
||||
String key = KEY_PREFIX + captchaId;
|
||||
String expected;
|
||||
try {
|
||||
expected = cacheStore.get(key);
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存不可用,图形验证码校验降级放行:{}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (expected == null) {
|
||||
throw new BusinessException("图形验证码已过期,请重新获取");
|
||||
}
|
||||
if (!expected.equalsIgnoreCase(captchaCode.trim())) {
|
||||
throw new BusinessException("图形验证码错误");
|
||||
}
|
||||
cacheStore.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.cache.CacheStore;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.config.AuthProperties;
|
||||
import com.gxwebsoft.common.system.config.MailProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 邮箱验证码:生成 / 限流 / 下发 / 校验。
|
||||
*
|
||||
* <p>未配置 SMTP 时只写日志,保证本地与演示环境可用;生产配置 {@code spring.mail.*} 后自动发信。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Service
|
||||
public class EmailCaptchaService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailCaptchaService.class);
|
||||
|
||||
private static final String CODE_KEY = "iam:email:code:";
|
||||
private static final String INTERVAL_KEY = "iam:email:interval:";
|
||||
|
||||
private final CacheStore cacheStore;
|
||||
private final AuthProperties authProperties;
|
||||
private final MailProperties mailProperties;
|
||||
private final ObjectProvider<JavaMailSender> mailSenderProvider;
|
||||
|
||||
public EmailCaptchaService(CacheStore cacheStore, AuthProperties authProperties,
|
||||
MailProperties mailProperties, ObjectProvider<JavaMailSender> mailSenderProvider) {
|
||||
this.cacheStore = cacheStore;
|
||||
this.authProperties = authProperties;
|
||||
this.mailProperties = mailProperties;
|
||||
this.mailSenderProvider = mailSenderProvider;
|
||||
}
|
||||
|
||||
/** 发送邮箱验证码;间隔 60 秒 */
|
||||
public void send(String email) {
|
||||
if (!authProperties.isEmailCaptchaEnabled()) {
|
||||
throw new BusinessException("邮箱验证码暂未开放");
|
||||
}
|
||||
boolean ok = cacheStore.setIfAbsent(INTERVAL_KEY + email, "1", 60);
|
||||
if (!ok) {
|
||||
throw new BusinessException("请求过于频繁,请稍后再试");
|
||||
}
|
||||
String code = RandomUtil.randomNumbers(6);
|
||||
cacheStore.set(CODE_KEY + email, code, authProperties.getCaptchaExpire());
|
||||
sendMail(email, code);
|
||||
}
|
||||
|
||||
/** 校验并核销验证码 */
|
||||
public void verify(String email, String code) {
|
||||
if (StrUtil.isBlank(email) || StrUtil.isBlank(code)) {
|
||||
throw new BusinessException("验证码不能为空");
|
||||
}
|
||||
String key = CODE_KEY + email;
|
||||
String expected = cacheStore.get(key);
|
||||
if (expected == null) {
|
||||
throw new BusinessException("验证码已过期,请重新获取");
|
||||
}
|
||||
if (!expected.equals(code.trim())) {
|
||||
throw new BusinessException("验证码错误");
|
||||
}
|
||||
cacheStore.delete(key);
|
||||
}
|
||||
|
||||
private void sendMail(String email, String code) {
|
||||
JavaMailSender mailSender = mailSenderProvider.getIfAvailable();
|
||||
if (mailSender == null || !mailProperties.isEnabled()) {
|
||||
log.info("邮件通道未启用,验证码仅记录日志:email={} code={}", email, code);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
if (StrUtil.isNotBlank(mailProperties.getFrom())) {
|
||||
message.setFrom(mailProperties.getFrom());
|
||||
}
|
||||
message.setTo(email);
|
||||
message.setSubject(mailProperties.getSubject());
|
||||
message.setText("您的验证码是:" + code + ",5 分钟内有效。如非本人操作请忽略本邮件。");
|
||||
mailSender.send(message);
|
||||
} catch (Exception e) {
|
||||
// 发信失败不能把验证码留在缓存里让别人无从得知,直接抛出并让调用方重试
|
||||
cacheStore.delete(CODE_KEY + email);
|
||||
log.error("邮件发送失败:email={}", email, e);
|
||||
throw new BusinessException("验证码发送失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import com.gxwebsoft.common.system.config.SmsProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 默认短信通道:只写日志。
|
||||
*
|
||||
* <p>接入真实短信服务时提供一个 {@link SmsSender} 实现,本 Bean 自动失效
|
||||
* (装配见 {@code SmsConfig})。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
public class LoggingSmsSender implements SmsSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoggingSmsSender.class);
|
||||
|
||||
private final SmsProperties smsProperties;
|
||||
|
||||
public LoggingSmsSender(SmsProperties smsProperties) {
|
||||
this.smsProperties = smsProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(String phone, String code) {
|
||||
if (smsProperties.isEnabled()) {
|
||||
log.warn("config.sms.enabled=true 但未接入短信通道,验证码仅记录日志:phone={} code={}", phone, code);
|
||||
return;
|
||||
}
|
||||
log.info("短信通道未启用,验证码仅记录日志:phone={} code={}", phone, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.util.IpUtils;
|
||||
import com.gxwebsoft.common.system.entity.LoginRecord;
|
||||
import com.gxwebsoft.common.system.mapper.LoginRecordMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 登录审计流水。
|
||||
*
|
||||
* <p>审计属于旁路能力:写库失败不能把登录流程带崩,因此这里吞掉异常只记日志。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Service
|
||||
public class LoginRecordService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginRecordService.class);
|
||||
|
||||
private static final int USER_AGENT_MAX_LENGTH = 500;
|
||||
private static final int MESSAGE_MAX_LENGTH = 255;
|
||||
|
||||
private final LoginRecordMapper loginRecordMapper;
|
||||
|
||||
public LoginRecordService(LoginRecordMapper loginRecordMapper) {
|
||||
this.loginRecordMapper = loginRecordMapper;
|
||||
}
|
||||
|
||||
/** 记录一条审计流水 */
|
||||
public void record(Long tenantId, Long userId, String username, int type, boolean success,
|
||||
String message, HttpServletRequest request) {
|
||||
try {
|
||||
LoginRecord record = new LoginRecord();
|
||||
record.setTenantId(tenantId);
|
||||
record.setUserId(userId);
|
||||
record.setUsername(username);
|
||||
record.setType(type);
|
||||
record.setStatus(success ? LoginRecord.STATUS_SUCCESS : LoginRecord.STATUS_FAILURE);
|
||||
record.setMessage(StrUtil.brief(message, MESSAGE_MAX_LENGTH));
|
||||
if (request != null) {
|
||||
record.setIp(IpUtils.clientIp(request));
|
||||
record.setUserAgent(StrUtil.brief(request.getHeader("User-Agent"), USER_AGENT_MAX_LENGTH));
|
||||
}
|
||||
loginRecordMapper.insert(record);
|
||||
} catch (Exception e) {
|
||||
log.warn("登录审计写入失败 tenantId={} username={}:{}", tenantId, username, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.Menu;
|
||||
import com.gxwebsoft.common.system.mapper.MenuMapper;
|
||||
import com.gxwebsoft.common.system.param.MenuPageParam;
|
||||
import com.gxwebsoft.common.system.param.MenuSaveParam;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 菜单 / 权限服务。
|
||||
*
|
||||
* <p>菜单是平台级元数据({@code sys_menu} 无 tenant_id),角色通过 {@code sys_role_menu}
|
||||
* 决定各自可见范围,因此菜单本身不做租户隔离。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Service
|
||||
public class MenuService {
|
||||
|
||||
private static final int STATUS_ENABLED = 1;
|
||||
|
||||
private final MenuMapper menuMapper;
|
||||
|
||||
public MenuService(MenuMapper menuMapper) {
|
||||
this.menuMapper = menuMapper;
|
||||
}
|
||||
|
||||
public Menu getRequired(Long menuId) {
|
||||
Menu menu = menuMapper.selectById(menuId);
|
||||
if (menu == null) {
|
||||
throw new BusinessException("菜单不存在");
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
/** 启用菜单(扁平列表) */
|
||||
public List<Menu> listEnabled(MenuPageParam param) {
|
||||
LambdaQueryWrapper<Menu> wrapper = Wrappers.<Menu>lambdaQuery().eq(Menu::getStatus, STATUS_ENABLED);
|
||||
if (param != null) {
|
||||
if (StrUtil.isNotBlank(param.getMenuName())) {
|
||||
wrapper.like(Menu::getMenuName, param.getMenuName().trim());
|
||||
}
|
||||
if (param.getMenuType() != null) {
|
||||
wrapper.eq(Menu::getMenuType, param.getMenuType());
|
||||
}
|
||||
}
|
||||
return menuMapper.selectList(wrapper.orderByAsc(Menu::getSort, Menu::getMenuId));
|
||||
}
|
||||
|
||||
/** 菜单树 */
|
||||
public List<Menu> tree(MenuPageParam param) {
|
||||
return buildTree(listEnabled(param));
|
||||
}
|
||||
|
||||
/** 用户可见的菜单树(按已授权角色过滤) */
|
||||
public List<Menu> treeOfUser(Long userId) {
|
||||
return buildTree(menuMapper.selectByUserId(userId));
|
||||
}
|
||||
|
||||
/** 用户权限标识集合 */
|
||||
public List<String> permissionsOfUser(Long userId) {
|
||||
return menuMapper.selectPermissionsByUserId(userId);
|
||||
}
|
||||
|
||||
public Long save(MenuSaveParam param) {
|
||||
if (param.menuId() == null) {
|
||||
checkPermissionUnique(param.permission(), null);
|
||||
Menu menu = new Menu();
|
||||
apply(menu, param);
|
||||
menuMapper.insert(menu);
|
||||
return menu.getMenuId();
|
||||
}
|
||||
Menu existing = getRequired(param.menuId());
|
||||
if (param.menuId().equals(param.parentId())) {
|
||||
throw new BusinessException("上级菜单不能是自己");
|
||||
}
|
||||
checkPermissionUnique(param.permission(), param.menuId());
|
||||
apply(existing, param);
|
||||
menuMapper.updateById(existing);
|
||||
return existing.getMenuId();
|
||||
}
|
||||
|
||||
/** 逻辑删除菜单:存在下级菜单时不允许删除,避免权限静默失效 */
|
||||
public void remove(Long menuId) {
|
||||
getRequired(menuId);
|
||||
Long children = menuMapper.selectCount(Wrappers.<Menu>lambdaQuery().eq(Menu::getParentId, menuId));
|
||||
if (children != null && children > 0) {
|
||||
throw new BusinessException("请先删除下级菜单");
|
||||
}
|
||||
menuMapper.deleteById(menuId);
|
||||
}
|
||||
|
||||
private void apply(Menu menu, MenuSaveParam param) {
|
||||
menu.setParentId(param.parentId() == null ? 0L : param.parentId());
|
||||
menu.setMenuName(param.menuName());
|
||||
menu.setMenuType(param.menuType());
|
||||
menu.setPath(param.path());
|
||||
menu.setComponent(param.component());
|
||||
menu.setPermission(param.permission());
|
||||
menu.setIcon(param.icon());
|
||||
menu.setSort(param.sort() == null ? 0 : param.sort());
|
||||
menu.setVisible(param.visible() == null ? 1 : param.visible());
|
||||
menu.setStatus(param.status() == null ? STATUS_ENABLED : param.status());
|
||||
}
|
||||
|
||||
private void checkPermissionUnique(String permission, Long excludeMenuId) {
|
||||
if (StrUtil.isBlank(permission)) {
|
||||
return;
|
||||
}
|
||||
Long count = menuMapper.selectCount(Wrappers.<Menu>lambdaQuery()
|
||||
.eq(Menu::getPermission, permission.trim())
|
||||
.ne(excludeMenuId != null, Menu::getMenuId, excludeMenuId));
|
||||
if (count != null && count > 0) {
|
||||
throw new BusinessException("权限标识已存在");
|
||||
}
|
||||
}
|
||||
|
||||
/** 扁平列表构建为树:先建索引再挂父子,避免 O(n^2) */
|
||||
private List<Menu> buildTree(List<Menu> menus) {
|
||||
Map<Long, Menu> index = new LinkedHashMap<>();
|
||||
for (Menu menu : menus) {
|
||||
menu.setChildren(new ArrayList<>());
|
||||
index.put(menu.getMenuId(), menu);
|
||||
}
|
||||
List<Menu> roots = new ArrayList<>();
|
||||
for (Menu menu : menus) {
|
||||
Long parentId = menu.getParentId();
|
||||
Menu parent = parentId == null ? null : index.get(parentId);
|
||||
if (parent == null) {
|
||||
// 父节点不在可见集合内(未授权 / 已停用)时按根节点处理,避免整棵子树丢失
|
||||
roots.add(menu);
|
||||
} else {
|
||||
parent.getChildren().add(menu);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user