diff --git a/iam-api/.gitignore b/iam-api/.gitignore
new file mode 100644
index 0000000..7e84944
--- /dev/null
+++ b/iam-api/.gitignore
@@ -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
diff --git a/iam-api/.mvn/wrapper/maven-wrapper.properties b/iam-api/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..216df05
--- /dev/null
+++ b/iam-api/.mvn/wrapper/maven-wrapper.properties
@@ -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
diff --git a/iam-api/Dockerfile b/iam-api/Dockerfile
new file mode 100644
index 0000000..ecee7a5
--- /dev/null
+++ b/iam-api/Dockerfile
@@ -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"]
diff --git a/iam-api/README.md b/iam-api/README.md
new file mode 100644
index 0000000..f570e41
--- /dev/null
+++ b/iam-api/README.md
@@ -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=`。
+
+初始账号:`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 ` 的签名、`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 -u root -p < scripts/create-flyway-user.sql
+
+# 2) 自检:应返回一行,而不是 SELECT command denied
+mysql -h -u -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` 会先打基线再跳过已应用的版本。
diff --git a/iam-api/config/iam-jwt.properties.example b/iam-api/config/iam-jwt.properties.example
new file mode 100644
index 0000000..a9ec597
--- /dev/null
+++ b/iam-api/config/iam-jwt.properties.example
@@ -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-----
diff --git a/iam-api/docker-compose.yml b/iam-api/docker-compose.yml
new file mode 100644
index 0000000..71d8d0a
--- /dev/null
+++ b/iam-api/docker-compose.yml
@@ -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:
diff --git a/iam-api/docs/iam-api-dev-plan.md b/iam-api/docs/iam-api-dev-plan.md
new file mode 100644
index 0000000..1580aa0
--- /dev/null
+++ b/iam-api/docs/iam-api-dev-plan.md
@@ -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` |
diff --git a/iam-api/mvnw b/iam-api/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/iam-api/mvnw
@@ -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-,maven-mvnd--}/
+[ -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 "$@"
diff --git a/iam-api/mvnw.cmd b/iam-api/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/iam-api/mvnw.cmd
@@ -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-,maven-mvnd--}/
+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"
diff --git a/iam-api/pom.xml b/iam-api/pom.xml
new file mode 100644
index 0000000..47c9188
--- /dev/null
+++ b/iam-api/pom.xml
@@ -0,0 +1,232 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.16
+
+
+
+ com.gxwebsoft
+ iam-api
+ 0.1.0
+ jar
+
+ iam-api
+ 统一身份中心:账号 / 租户 / 角色 / 权限 / 菜单 / 令牌签发
+
+
+ 25
+ 25
+ UTF-8
+ UTF-8
+
+ 3.5.17
+ 1.2.28
+
+ 2.3.0
+ 4.5.0
+ 0.13.0
+ 5.8.47
+ 1.18.48
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ ${mybatis-plus.version}
+
+
+
+ com.baomidou
+ mybatis-plus-jsqlparser
+ ${mybatis-plus.version}
+
+
+ com.alibaba
+ druid-spring-boot-3-starter
+ ${druid.version}
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+ com.github.xiaoymin
+ knife4j-openapi3-jakarta-spring-boot-starter
+ ${knife4j.version}
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+
+ cn.hutool
+ hutool-all
+ ${hutool.version}
+
+
+ com.github.whvcse
+ easy-captcha
+ 1.6.2
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+ com.h2database
+ h2
+ test
+
+
+
+
+
+
+ demo
+
+
+ com.h2database
+ h2
+ runtime
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/iam-api/run.sh b/iam-api/run.sh
new file mode 100755
index 0000000..4ded989
--- /dev/null
+++ b/iam-api/run.sh
@@ -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"
diff --git a/iam-api/scripts/gen-iam-key.sh b/iam-api/scripts/gen-iam-key.sh
new file mode 100755
index 0000000..b79b576
--- /dev/null
+++ b/iam-api/scripts/gen-iam-key.sh
@@ -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 <iam-api 是 open-api 与 dp-api 共用的唯一账号源:负责用户、租户、角色、权限、菜单、
+ * 登录会话,以及面向资源服务的 RS256 令牌签发与 JWKS 发布。
+ *
+ * @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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java b/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java
new file mode 100644
index 0000000..c81c61a
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/Constants.java
@@ -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() {
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java
new file mode 100644
index 0000000..ed147b7
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryField.java
@@ -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;
+
+/**
+ * 声明查询条件字段:字段名 → 数据库列 + 比较方式。
+ *
+ * {@code keywords=true} 的字段参与通用关键字模糊搜索(多个字段之间是 OR)。
+ *
+ * @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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java
new file mode 100644
index 0000000..5315829
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/annotation/QueryType.java
@@ -0,0 +1,27 @@
+package com.gxwebsoft.common.core.annotation;
+
+/**
+ * 查询条件类型。
+ *
+ * @author WebSoft
+ */
+public enum QueryType {
+
+ /** 等于 */
+ EQ,
+
+ /** 不等于 */
+ NE,
+
+ /** 大于等于 */
+ GE,
+
+ /** 小于等于 */
+ LE,
+
+ /** 模糊匹配 */
+ LIKE,
+
+ /** 包含于(逗号分隔的字符串入参) */
+ IN
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java
new file mode 100644
index 0000000..e96ca03
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/CacheStore.java
@@ -0,0 +1,32 @@
+package com.gxwebsoft.common.core.cache;
+
+/**
+ * 键值缓存抽象。
+ *
+ * 验证码、失败计数、令牌吊销名单都落在缓存里。抽象成接口是为了让「演示 / 测试环境」
+ * 不依赖 Redis 也能跑起来:实现见 {@link MemoryCacheStore} 与 {@link RedisCacheStore}。
+ *
+ * @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;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java
new file mode 100644
index 0000000..a73c3b3
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/MemoryCacheStore.java
@@ -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;
+
+/**
+ * 进程内缓存实现。
+ *
+ * 用途:`demo` profile 与单元测试。多实例部署下验证码与失败计数不共享,
+ * 生产必须使用 {@link RedisCacheStore}。
+ *
+ * @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 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;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java
new file mode 100644
index 0000000..f8024e2
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/cache/RedisCacheStore.java
@@ -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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java
new file mode 100644
index 0000000..c73f166
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/CacheConfig.java
@@ -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;
+
+/**
+ * 缓存实现装配。
+ *
+ * {@code iam.cache.type=redis}(默认)走 Redis;{@code memory} 走进程内 Map,
+ * 用于演示与测试。多实例部署必须用 redis,否则验证码与失败计数不共享。
+ *
+ * @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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java
new file mode 100644
index 0000000..9681e13
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/ConfigProperties.java
@@ -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 swaggerBasePackages = List.of("com.gxwebsoft");
+
+ /** 对外服务地址,用于拼接回调地址 */
+ private String serverUrl;
+
+ /** 内部服务调用密钥:introspect / revoke 的调用方用 X-Internal-Key 自证 */
+ private String internalKey;
+
+ /**
+ * 允许跨域访问的来源白名单(支持通配,例如 {@code https://*.websoft.top})。
+ *
+ * 禁止配置成 {@code *}:接口开启了 allowCredentials,任意站点都能带凭证调用本服务。
+ */
+ private List corsAllowedOriginPatterns = List.of(
+ "https://websoft.top",
+ "https://*.websoft.top",
+ "http://localhost:[*]",
+ "http://127.0.0.1:[*]"
+ );
+
+ /** 免鉴权路径白名单 */
+ private List 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/**"
+ );
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java
new file mode 100644
index 0000000..af8bd56
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JacksonConfig.java
@@ -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 自动配置。
+ *
+ * 不要在这里自己 new 一个 ObjectMapper Bean:那会覆盖自动配置,并且和 Boot 自带的
+ * {@code standardJacksonObjectMapperBuilderCustomizer} 一起被注入时产生歧义。
+ */
+ @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);
+ };
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java
new file mode 100644
index 0000000..32d9503
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/JwtProperties.java
@@ -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})。
+ *
+ * 会话令牌只服务于控制台自身(HS256),与面向资源服务的 RS256 令牌是两套东西,
+ * 后者见 {@code com.gxwebsoft.iam.config.IamJwtProperties}。
+ *
+ * @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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java
new file mode 100644
index 0000000..e11878d
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisMetaObjectHandler.java
@@ -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());
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..13bc801
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java
@@ -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 配置:多租户 + 分页。
+ *
+ * 租户 ID 由 {@link TenantContext} 在请求进入时解析完成,这里只负责把它拼进 SQL。
+ *
+ * @author WebSoft
+ */
+@Configuration
+public class MybatisPlusConfig {
+
+ /** 不做租户隔离的公共表:平台级元数据,或本身已带租户条件由业务显式指定 */
+ private static final Set 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;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java
new file mode 100644
index 0000000..fd6a167
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/SecurityConfig.java
@@ -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 jwtFilterRegistration(JwtAuthenticationFilter filter) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>(filter);
+ registration.setEnabled(false);
+ return registration;
+ }
+
+ @Bean
+ public FilterRegistrationBean tenantFilterRegistration(TenantFilter filter) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>(filter);
+ registration.setEnabled(false);
+ return registration;
+ }
+
+ /**
+ * 默认的登录用户加载器:直接把 token 载体作为主体,不查库。
+ *
+ * system 模块提供自己的 {@link LoginUserProvider} 实现后,本 Bean 自动失效。
+ */
+ @Bean
+ @ConditionalOnMissingBean(LoginUserProvider.class)
+ public LoginUserProvider defaultLoginUserProvider() {
+ return subject -> subject;
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java
new file mode 100644
index 0000000..83e3b9b
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/config/WebMvcConfig.java
@@ -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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java
new file mode 100644
index 0000000..98bfc43
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantAware.java
@@ -0,0 +1,12 @@
+package com.gxwebsoft.common.core.context;
+
+/**
+ * 租户感知:登录主体实现该接口后,租户过滤器可以直接从主体上取到租户 ID。
+ *
+ * @author WebSoft
+ */
+public interface TenantAware {
+
+ /** 所属租户 ID */
+ Long getTenantId();
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java
new file mode 100644
index 0000000..d9496c6
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java
@@ -0,0 +1,70 @@
+package com.gxwebsoft.common.core.context;
+
+import java.util.function.Supplier;
+
+/**
+ * 租户上下文。
+ *
+ * 由 {@code TenantFilter} 在请求进入时写入,供多租户插件与业务代码读取。
+ * 使用 {@link ThreadLocal},在虚拟线程下同样成立(每个虚拟线程各自持有副本)。
+ *
+ * @author WebSoft
+ */
+public final class TenantContext {
+
+ private static final ThreadLocal TENANT_ID = new ThreadLocal<>();
+ private static final ThreadLocal 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 callIgnoreTenant(Supplier supplier) {
+ boolean origin = isIgnoreTenant();
+ try {
+ setIgnoreTenant(true);
+ return supplier.get();
+ } finally {
+ setIgnoreTenant(origin);
+ }
+ }
+
+ /**
+ * 在指定租户上下文中执行并返回结果。
+ *
+ * 用于登录等「租户尚未进入上下文、但需要按租户查数据」的场景。
+ */
+ public static T callWithTenant(Long tenantId, Supplier supplier) {
+ Long origin = getTenantId();
+ try {
+ setTenantId(tenantId);
+ return supplier.get();
+ } finally {
+ setTenantId(origin);
+ }
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java
new file mode 100644
index 0000000..b38db84
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java
@@ -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;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..f79d3a4
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/exception/GlobalExceptionHandler.java
@@ -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;
+
+/**
+ * 全局异常处理。
+ *
+ * 控制器抛出的业务异常统一翻译为 HTTP 200 + {@code code != 0},与既有前端契约一致:
+ * 前端 axios 拦截器只在 HTTP 401 时清理登录态,业务错误按 {@code message} 提示。
+ *
+ * @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, "服务异常,请稍后重试");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java
new file mode 100644
index 0000000..d447731
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAccessDeniedHandler.java
@@ -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));
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..5b4dc78
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationEntryPoint.java
@@ -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));
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..d181184
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java
@@ -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 的请求认证过滤器。
+ *
+ * 不做 servlet 容器的自动注册,只在 Spring Security 过滤器链中生效,
+ * 避免过滤器被重复执行两次。
+ *
+ * @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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java
new file mode 100644
index 0000000..b797297
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtSubject.java
@@ -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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java
new file mode 100644
index 0000000..ab8be9c
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/JwtUtil.java
@@ -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 前缀。
+ *
+ * 只认 {@code Authorization} 请求头:URL 里的令牌会落进反向代理 access log、
+ * Referer、浏览器历史与中间缓存,属于明确的劝退用法。
+ */
+ 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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java
new file mode 100644
index 0000000..78c97e3
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/LoginUserProvider.java
@@ -0,0 +1,25 @@
+package com.gxwebsoft.common.core.security;
+
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 登录主体与权限的加载扩展点。
+ *
+ * 框架层不认识业务实体,认证过滤器通过本接口拿到「主体对象」和「权限集合」;
+ * system 模块提供基于 {@code sys_user} / {@code sys_menu} 的实现。
+ *
+ * @author WebSoft
+ */
+public interface LoginUserProvider {
+
+ /** 按令牌载体加载登录主体;返回 null 表示用户不存在或已停用 */
+ Object loadPrincipal(JwtSubject subject);
+
+ /** 加载权限集合,默认无权限 */
+ default Collection extends GrantedAuthority> loadAuthorities(Object principal) {
+ return List.of();
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java b/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java
new file mode 100644
index 0000000..5328d65
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/security/RevokedTokenStore.java
@@ -0,0 +1,36 @@
+package com.gxwebsoft.common.core.security;
+
+import com.gxwebsoft.common.core.cache.CacheStore;
+import org.springframework.stereotype.Component;
+
+/**
+ * 令牌吊销名单。
+ *
+ * JWT 本身无状态,只让客户端丢弃令牌的话,令牌在有效期内仍然可用。
+ * 登出 / 强制下线时按 {@code jti} 写入吊销名单,有效期与令牌剩余寿命对齐。
+ *
+ * @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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java b/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java
new file mode 100644
index 0000000..c16b738
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/util/IpUtils.java
@@ -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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java
new file mode 100644
index 0000000..915f7b8
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java
@@ -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;
+
+/**
+ * 统一返回结果。
+ *
+ * 保持 {@code code / message / data} 契约:前端 axios 拦截器按 {@code code === 0} 判断成功。
+ *
+ * @param 数据类型
+ * @author WebSoft
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Schema(description = "统一返回结果")
+public class ApiResult 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 ApiResult ok(T data) {
+ return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data);
+ }
+
+ public static ApiResult ok(String message, T data) {
+ return new ApiResult<>(Constants.RESULT_OK_CODE, message, data);
+ }
+
+ public static ApiResult 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 setCode(Integer code) {
+ this.code = code;
+ return this;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public ApiResult setMessage(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public ApiResult setData(T data) {
+ this.data = data;
+ return this;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java
new file mode 100644
index 0000000..d817543
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseController.java
@@ -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 ApiResult success(T data) {
+ return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, data);
+ }
+
+ public ApiResult success(String message, T data) {
+ return new ApiResult<>(Constants.RESULT_OK_CODE, message, data);
+ }
+
+ public ApiResult> success(List list, Long count) {
+ return success(new PageResult<>(list, count));
+ }
+
+ public ApiResult> success(IPage 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);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java
new file mode 100644
index 0000000..15c0325
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/BaseParam.java
@@ -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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java
new file mode 100644
index 0000000..00a8919
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageParam.java
@@ -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;
+
+/**
+ * 分页 + 排序 + 条件搜索参数封装。
+ *
+ * 查询条件由 {@link BaseParam} 子类字段上的 {@link QueryField} 注解声明,
+ * {@link #getWrapper()} 负责翻译成 MyBatis-Plus 的条件构造器。
+ *
+ * @param 实体类型
+ * @param 查询参数类型
+ * @author WebSoft
+ */
+public class PageParam extends Page {
+
+ @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 NON_SORTABLE_COLUMNS = Set.of("password", "client_secret", "secret");
+
+ /** 最大每页条数,避免一次拉全表 */
+ private static final long MAX_SIZE = 200L;
+
+ private final Class entityClass;
+
+ private final U where;
+
+ public PageParam(Class 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 setDefaultOrder(String orderSql) {
+ if (orders() == null || orders().isEmpty()) {
+ List 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 getWrapper() {
+ QueryWrapper wrapper = new QueryWrapper<>();
+ if (where == null) {
+ return wrapper;
+ }
+ List 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 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 values = Arrays.stream(String.valueOf(value).split(","))
+ .map(String::trim).filter(StrUtil::isNotBlank).toList();
+ if (!values.isEmpty()) {
+ wrapper.in(column, values);
+ }
+ }
+ }
+ }
+
+ /**
+ * 解析用户传入的 {@code sort}。
+ *
+ * ORDER BY 的列名无法参数化,只能靠白名单拦:列名必须命中实体真实字段,
+ * 且不属于敏感列,从而同时挡住表达式注入与「按敏感列排序」的盲注旁路。
+ */
+ private List parseUserSort(U where) {
+ List 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 columns = entityColumns();
+ if (!columns.isEmpty() && !columns.contains(column)) {
+ throw new BusinessException("不支持的排序字段:" + raw);
+ }
+ return column;
+ }
+
+ /** 实体真实列名;取不到元数据时返回空集合,退化为「合法标识符」校验 */
+ private Set entityColumns() {
+ if (entityClass == null) {
+ return Set.of();
+ }
+ TableInfo info = TableInfoHelper.getTableInfo(entityClass);
+ if (info == null) {
+ return Set.of();
+ }
+ Set 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 allFields(Class> type) {
+ List 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);
+ }
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java
new file mode 100644
index 0000000..9536e29
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/PageResult.java
@@ -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 记录类型
+ * @author WebSoft
+ */
+@Schema(description = "分页返回结构")
+public class PageResult implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "当前页数据")
+ private List list;
+
+ @Schema(description = "总数量")
+ private Long total;
+
+ public PageResult() {
+ }
+
+ public PageResult(List list, Long total) {
+ this.list = list;
+ this.total = total;
+ }
+
+ public List getList() {
+ return list;
+ }
+
+ public PageResult setList(List list) {
+ this.list = list;
+ return this;
+ }
+
+ public Long getTotal() {
+ return total;
+ }
+
+ public PageResult setTotal(Long total) {
+ this.total = total;
+ return this;
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java
new file mode 100644
index 0000000..1ba2cac
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/ResponseWriter.java
@@ -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;
+
+/**
+ * 过滤器链中的统一响应输出。
+ *
+ * 过滤器在进入 Spring MVC 之前就可能拒绝请求,此时没有 {@code @RestControllerAdvice},
+ * 只能自己写响应体。
+ *
+ * @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));
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java b/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java
new file mode 100644
index 0000000..a55e5fb
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/core/web/TenantFilter.java
@@ -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;
+
+/**
+ * 租户解析过滤器。
+ *
+ * 解析顺序:登录主体 → 请求头 {@code tenantId}。解析结果写入 {@link TenantContext},
+ * 请求结束时清理,防止线程复用串租户。
+ *
+ * @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;
+ }
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java
new file mode 100644
index 0000000..fa13aaa
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/AuthProperties.java
@@ -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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java
new file mode 100644
index 0000000..3d765a7
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/MailProperties.java
@@ -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 = "云枢开放平台验证码";
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java
new file mode 100644
index 0000000..1347615
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsConfig.java
@@ -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;
+
+/**
+ * 短信通道装配。
+ *
+ * {@code @ConditionalOnMissingBean} 必须写在 {@code @Bean} 方法上:
+ * 直接标在被扫描的 {@code @Component} 上时,条件判断会看到自己这条定义,
+ * 结果把自己排除掉,最终「一个 SmsSender 都没有」。
+ *
+ * @author WebSoft
+ */
+@Configuration
+public class SmsConfig {
+
+ @Bean
+ @ConditionalOnMissingBean(SmsSender.class)
+ public SmsSender loggingSmsSender(SmsProperties smsProperties) {
+ return new LoggingSmsSender(smsProperties);
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java
new file mode 100644
index 0000000..e2f96b1
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/config/SmsProperties.java
@@ -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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java
new file mode 100644
index 0000000..6a5eccc
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/AuthController.java
@@ -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 login(@Valid @RequestBody LoginParam param, HttpServletRequest request) {
+ return ApiResult.ok(authService.login(param, request));
+ }
+
+ @Operation(summary = "邮箱登录")
+ @PostMapping("/loginByEmail")
+ public ApiResult loginByEmail(@Valid @RequestBody EmailLoginParam param,
+ HttpServletRequest request) {
+ return ApiResult.ok(authService.loginByEmail(param, request));
+ }
+
+ @Operation(summary = "手机号验证码登录")
+ @PostMapping("/loginBySms")
+ public ApiResult 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 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("手机号绑定成功");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java
new file mode 100644
index 0000000..f37978a
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/CaptchaController.java
@@ -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 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)));
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java
new file mode 100644
index 0000000..6e35b9a
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/MenuController.java
@@ -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> tree(MenuPageParam param) {
+ return success(menuService.tree(param));
+ }
+
+ @Operation(summary = "菜单扁平列表(启用)")
+ @GetMapping("/list")
+ @PreAuthorize("hasAuthority('iam:menu:list')")
+ public ApiResult> 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("删除成功");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java
new file mode 100644
index 0000000..8b09ef7
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/RoleController.java
@@ -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 page = roleService.page(param);
+ return success(page);
+ }
+
+ @Operation(summary = "启用角色列表(下拉选择)")
+ @GetMapping("/options")
+ @PreAuthorize("hasAuthority('iam:role:list')")
+ public ApiResult> 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> 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("授权成功");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java
new file mode 100644
index 0000000..cfa55dd
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java
@@ -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 page = tenantService.page(param);
+ return success(page);
+ }
+
+ @Operation(summary = "租户详情")
+ @GetMapping("/{tenantId}")
+ @PreAuthorize("hasAuthority('iam:tenant:list')")
+ public ApiResult 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("删除成功");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java
new file mode 100644
index 0000000..85cbc02
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/controller/UserController.java
@@ -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 page = userService.page(param);
+ return success(page);
+ }
+
+ @Operation(summary = "用户详情")
+ @GetMapping("/{userId}")
+ @PreAuthorize("hasAuthority('iam:user:list')")
+ public ApiResult detail(@PathVariable Long userId) {
+ return success(userService.getById(userId));
+ }
+
+ @Operation(summary = "用户已分配角色")
+ @GetMapping("/{userId}/roles")
+ @PreAuthorize("hasAuthority('iam:user:list')")
+ public ApiResult> 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("密码已重置");
+ }
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java
new file mode 100644
index 0000000..2cf0033
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/LoginRecord.java
@@ -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;
+}
diff --git a/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java
new file mode 100644
index 0000000..32f2a30
--- /dev/null
+++ b/iam-api/src/main/java/com/gxwebsoft/common/system/entity/Menu.java
@@ -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