feat: EtherPMS 物业管理系统初始提交

- 后端:8个微服务(gateway/auth/base/operation/charge/notify/file/audit)+ pms-common公共模块
- 前端:Vue3 + TypeScript + Ant Design Vue 4.2 管理端(40个页面)
- 数据库:7个独立库 + Flyway迁移脚本(12个SQL)
- 基础设施:Docker Compose(MySQL/Redis/RabbitMQ/Nacos/Elasticsearch)
- 安全:JWT RS256 + BCrypt + AES-256-GCM + Redis分布式限流
- 文档:详细设计文档 + 评审报告 + CI/CD规划方案
This commit is contained in:
ether 2026-06-29 13:26:54 +08:00
commit a471b497a5
742 changed files with 115155 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# ─── Backend (Gradle) ───
backend/**/build/
backend/.gradle/
backend/local.properties
backend/*.iml
backend/.idea/
# ─── Frontend (Node/Vite) ───
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/*.local
# ─── IDE ───
.idea/
*.iml
*.ipr
*.iws
.vscode/
*.swp
*.swo
*~
# ─── OS ───
.DS_Store
Thumbs.db
# ─── Logs ───
*.log
logs/
# ─── Environment ───
.env
.env.local
.env.*.local
# ─── E2E test screenshots ───
e2e_*.png
# ─── Qoder cache ───
.qoder/
# ─── Docker volumes ───
docker/data/

58
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# IDE - IntelliJ IDEA
.idea/
*.iws
*.iml
*.ipr
out/
# IDE - Eclipse / Spring STS
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
# IDE - VS Code
.vscode/
*.code-workspace
# OS
.DS_Store
Thumbs.db
# 日志
*.log
logs/
# 编译输出
target/
*.class
# 包文件
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
# 环境配置(敏感信息)
application-local.yml
application-local.properties
application-prod.yml
*.env
# 密钥文件
*.pem
*.key
*.p12
*.jks

105
backend/build.gradle Normal file
View File

@ -0,0 +1,105 @@
// ========================================
//
// ========================================
plugins {
id 'java'
id 'java-library'
id 'io.spring.dependency-management' version '1.1.5' apply false
id 'org.springframework.boot' version "${springBootVersion}" apply false
id 'org.flywaydb.flyway' version "${flywayVersion}" apply false
}
//
subprojects {
apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'io.spring.dependency-management'
//
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
//
repositories {
mavenLocal()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/spring' }
mavenCentral()
}
//
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
mavenBom "com.alibaba.cloud:spring-cloud-alibaba-dependencies:${springCloudAlibabaVersion}"
}
dependencies {
// MyBatis-Plus
dependency "com.baomidou:mybatis-plus-spring-boot3-starter:${mybatisPlusVersion}"
dependency "com.baomidou:mybatis-plus-jsqlparser:${mybatisPlusVersion}"
// Flyway
dependency "org.flywaydb:flyway-core:${flywayVersion}"
dependency "org.flywaydb:flyway-mysql:${flywayVersion}"
// MySQL
dependency "com.mysql:mysql-connector-j:${mysqlConnectorVersion}"
// JWT (jjwt)
dependency "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
dependency "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
dependency "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
// MapStruct
dependency "org.mapstruct:mapstruct:${mapstructVersion}"
dependency "org.mapstruct:mapstruct-processor:${mapstructVersion}"
// Hutool
dependency "cn.hutool:hutool-all:${hutoolVersion}"
// Guava
dependency "com.google.guava:guava:${guavaVersion}"
}
}
//
dependencies {
// Lombok
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
testCompileOnly "org.projectlombok:lombok:${lombokVersion}"
testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}"
// MapStruct Lombok
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:${lombokMapstructBindingVersion}"
// Hutool
implementation "cn.hutool:hutool-all:${hutoolVersion}"
//
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
//
compileJava {
options.encoding = 'UTF-8'
options.compilerArgs << '-parameters'
}
compileTestJava {
options.encoding = 'UTF-8'
}
// 使 JUnit 5 Platform
test {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = false
}
}
}

142
backend/docker-compose.yml Normal file
View File

@ -0,0 +1,142 @@
# ========================================
# 物业管理系统 - 开发环境中间件编排
# 使用docker-compose up -d
# 停止docker-compose down
# ========================================
version: '3.8'
services:
# ====== MySQL 8.0 ======
mysql:
image: mysql:8.0
container_name: pms-mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
TZ: Asia/Shanghai
# 自动创建各服务数据库
MYSQL_DATABASE: auth_db
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-authentication-plugin=mysql_native_password
--lower_case_table_names=1
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- pms-net
# ====== Redis 7 ======
redis:
image: redis:7-alpine
container_name: pms-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- pms-net
# ====== RabbitMQ 3.12 ======
rabbitmq:
image: rabbitmq:3.12-management
container_name: pms-rabbitmq
restart: unless-stopped
ports:
- "5672:5672" # AMQP 端口
- "15672:15672" # 管理界面
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-guest}
volumes:
- rabbitmq-data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 15s
timeout: 10s
retries: 5
networks:
- pms-net
# ====== Nacos 2.3.0 ======
nacos:
image: nacos/nacos-server:v2.3.0
container_name: pms-nacos
restart: unless-stopped
ports:
- "8848:8848" # Nacos 主端口
- "9848:9848" # gRPC 端口
- "9849:9849" # gRPC 端口
environment:
MODE: standalone
PREFER_HOST_MODE: hostname
SPRING_DATASOURCE_PLATFORM: ""
NACOS_AUTH_ENABLE: "false"
JVM_XMS: 256m
JVM_XMX: 512m
volumes:
- nacos-data:/home/nacos/data
- nacos-logs:/home/nacos/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8848/nacos/v1/console/health/readiness"]
interval: 15s
timeout: 10s
retries: 10
networks:
- pms-net
# ====== Elasticsearch 8.x ======
elasticsearch:
image: elasticsearch:8.11.0
container_name: pms-elasticsearch
restart: unless-stopped
ports:
- "9200:9200"
- "9300:9300"
environment:
discovery.type: single-node
ES_JAVA_OPTS: "-Xms256m -Xmx256m"
xpack.security.enabled: "false"
bootstrap.memory_lock: "true"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
interval: 15s
timeout: 10s
retries: 10
networks:
- pms-net
# ====== 数据卷 ======
volumes:
mysql-data:
redis-data:
rabbitmq-data:
nacos-data:
nacos-logs:
es-data:
# ====== 网络 ======
networks:
pms-net:
driver: bridge

View File

@ -0,0 +1,12 @@
-- ========================================
-- 物业管理系统 - 数据库初始化脚本
-- 创建7个微服务独立数据库
-- ========================================
CREATE DATABASE IF NOT EXISTS auth_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS base_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS operation_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS charge_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS notify_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS file_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS audit_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;

31
backend/gradle.properties Normal file
View File

@ -0,0 +1,31 @@
# ========================================
# 版本属性集中管理
# ========================================
# JDK 版本
javaVersion=17
# Spring 家族
springBootVersion=3.2.5
springCloudVersion=2023.0.1
springCloudAlibabaVersion=2023.0.1.0
# 持久层
mybatisPlusVersion=3.5.5
flywayVersion=9.22.3
mysqlConnectorVersion=8.0.33
# 中间件客户端
springAmqpVersion=3.1.5
springDataElasticsearchVersion=5.2.5
# 工具库
lombokVersion=1.18.32
mapstructVersion=1.5.5.Final
lombokMapstructBindingVersion=0.2.0
hutoolVersion=5.8.27
jjwtVersion=0.12.5
guavaVersion=33.1.0-jre
# Gradle 构建
gradleWrapperVersion=8.7

View File

@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
backend/gradlew vendored Executable file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
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
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
backend/gradlew.bat vendored Normal file
View File

@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@ -0,0 +1,43 @@
// ========================================
// pms-audit
//
// ========================================
apply plugin: 'org.springframework.boot'
dependencies {
implementation project(':pms-common')
// WebServlet
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
//
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
// Nacos
implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery'
implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-config'
implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap'
// MyBatis-Plus
implementation "com.baomidou:mybatis-plus-spring-boot3-starter:${mybatisPlusVersion}"
// Flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
// MySQL
runtimeOnly "com.mysql:mysql-connector-j:${mysqlConnectorVersion}"
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// RabbitMQ
implementation 'org.springframework.boot:spring-boot-starter-amqp'
// MapStruct
implementation "org.mapstruct:mapstruct:${mapstructVersion}"
}

View File

@ -0,0 +1,22 @@
package com.pms.audit;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 审计服务启动类
* 操作审计日志登录日志记录与查询
*/
@SpringBootApplication(scanBasePackages = "com.pms")
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.pms")
@MapperScan({"com.pms.audit.**.mapper", "com.pms.common.mapper"})
public class AuditServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuditServiceApplication.class, args);
}
}

View File

@ -0,0 +1,32 @@
package com.pms.audit.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.pms.common.config.PmsTenantLineHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
* 乐观锁 + 多租户 + 分页插件
* 拦截器顺序OptimisticLocker TenantLine Pagination
* 审计日志表不使用逻辑删除实体中不声明 deleted 字段即可
*/
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new PmsTenantLineHandler()));
PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
pageInterceptor.setMaxLimit(100L);
pageInterceptor.setOverflow(false);
interceptor.addInnerInterceptor(pageInterceptor);
return interceptor;
}
}

View File

@ -0,0 +1,40 @@
package com.pms.audit.config;
import com.pms.audit.constant.AuditConstants;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置
* 声明审计服务消费所需的交换机队列及绑定关系
*/
@Configuration
public class RabbitMQConfig {
/**
* 审计交换机topic 类型
*/
@Bean
public TopicExchange auditExchange() {
return ExchangeBuilder.topicExchange(AuditConstants.EXCHANGE_AUDIT)
.durable(true)
.build();
}
/**
* 操作审计日志队列
*/
@Bean
public Queue auditLogQueue() {
return QueueBuilder.durable(AuditConstants.QUEUE_AUDIT_OPERATION)
.build();
}
@Bean
public Binding auditLogBinding() {
return BindingBuilder.bind(auditLogQueue())
.to(auditExchange())
.with(AuditConstants.ROUTING_KEY_AUDIT_OPERATION);
}
}

View File

@ -0,0 +1,27 @@
package com.pms.audit.config;
import com.pms.common.interceptor.UserContextInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
* 注册用户上下文拦截器
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final UserContextInterceptor userContextInterceptor;
public WebMvcConfig(UserContextInterceptor userContextInterceptor) {
this.userContextInterceptor = userContextInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns("/actuator/**", "/error");
}
}

View File

@ -0,0 +1,47 @@
package com.pms.audit.constant;
/**
* 审计服务常量
*/
public final class AuditConstants {
private AuditConstants() {
}
// ===== 交换机 =====
public static final String EXCHANGE_AUDIT = "exchange.audit";
// ===== 队列 =====
public static final String QUEUE_AUDIT_OPERATION = "queue.audit.operation";
// ===== 路由键 =====
public static final String ROUTING_KEY_AUDIT_OPERATION = "audit.operation";
// ===== 操作类型 =====
public static final String OP_CREATE = "CREATE";
public static final String OP_UPDATE = "UPDATE";
public static final String OP_DELETE = "DELETE";
public static final String OP_QUERY = "QUERY";
public static final String OP_EXPORT = "EXPORT";
public static final String OP_IMPORT = "IMPORT";
public static final String OP_LOGIN = "LOGIN";
public static final String OP_LOGOUT = "LOGOUT";
public static final String OP_OTHER = "OTHER";
// ===== 操作结果 =====
public static final String RESULT_SUCCESS = "SUCCESS";
public static final String RESULT_FAIL = "FAIL";
public static final String RESULT_ERROR = "ERROR";
// ===== 登录结果 =====
public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS";
public static final String LOGIN_FAIL = "LOGIN_FAIL";
public static final String LOGOUT = "LOGOUT";
public static final String KICK_OUT = "KICK_OUT";
// ===== 幂等去重 Redis Key 前缀 =====
public static final String IDEMPOTENT_KEY_PREFIX = "audit:idempotent:";
// ===== 敏感字段脱敏 =====
public static final String[] SENSITIVE_FIELDS = {"password", "oldPassword", "newPassword", "token", "secretKey", "bankAccount", "idCard"};
}

View File

@ -0,0 +1,92 @@
package com.pms.audit.consumer;
import com.pms.audit.constant.AuditConstants;
import com.pms.audit.dto.AuditEventMessage;
import com.pms.audit.service.AuditLogService;
import com.pms.common.entity.MqConsumeLog;
import com.pms.common.mapper.MqConsumeLogMapper;
import com.pms.common.util.JsonUtils;
import com.rabbitmq.client.Channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 审计日志消费者
* 消费各服务通过AOP切面采集并发送的操作日志事件异步写入数据库
* <p>
* 消费幂等Redis 快速去重 + mq_consume_log 数据库持久化保障
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuditConsumer {
private static final String CONSUMER_GROUP = "audit-consumer";
private final AuditLogService auditLogService;
private final StringRedisTemplate stringRedisTemplate;
private final MqConsumeLogMapper mqConsumeLogMapper;
/**
* 消费操作审计日志
*/
@RabbitListener(queues = AuditConstants.QUEUE_AUDIT_OPERATION)
public void onAuditOperation(String message, Channel channel,
org.springframework.amqp.core.Message amqpMessage) throws java.io.IOException {
long deliveryTag = amqpMessage.getMessageProperties().getDeliveryTag();
try {
AuditEventMessage event = JsonUtils.fromJson(message, AuditEventMessage.class);
if (event == null) {
log.warn("审计消息解析失败,丢弃: message={}", message);
channel.basicAck(deliveryTag, false);
return;
}
// 幂等校验基于 eventId 去重
String eventId = event.getEventId();
if (eventId == null || eventId.isBlank()) {
eventId = "audit:" + event.getOperatorId() + ":" + event.getOperationTime();
}
// 1. Redis 快速检查快速路径
String idempotentKey = AuditConstants.IDEMPOTENT_KEY_PREFIX + eventId;
Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(isNew)) {
log.debug("审计消息已消费过Redis跳过: eventId={}", eventId);
channel.basicAck(deliveryTag, false);
return;
}
// 2. 数据库持久化检查防止 Redis 失效后重复消费
if (mqConsumeLogMapper.countByEventIdAndGroup(eventId, CONSUMER_GROUP) > 0) {
log.debug("审计消息已消费过DB跳过: eventId={}", eventId);
channel.basicAck(deliveryTag, false);
return;
}
auditLogService.save(event);
// 3. 消费成功后记录到 mq_consume_log持久化保障
MqConsumeLog consumeLog = new MqConsumeLog();
consumeLog.setEventId(eventId);
consumeLog.setConsumerGroup(CONSUMER_GROUP);
consumeLog.setQueueName(AuditConstants.QUEUE_AUDIT_OPERATION);
consumeLog.setConsumeTime(LocalDateTime.now());
consumeLog.setStatus("SUCCESS");
mqConsumeLogMapper.insert(consumeLog);
channel.basicAck(deliveryTag, false);
log.debug("审计日志消费成功: eventId={}, module={}", eventId, event.getModule());
} catch (Exception e) {
log.error("审计日志消费失败: message={}", message, e);
// 拒绝消息不重新入队
channel.basicNack(deliveryTag, false, false);
}
}
}

View File

@ -0,0 +1,58 @@
package com.pms.audit.controller;
import com.pms.audit.dto.AuditLogDTO;
import com.pms.audit.dto.AuditLogQueryRequest;
import com.pms.audit.dto.AuditLogStatisticsDTO;
import com.pms.audit.service.AuditLogService;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 审计日志控制器
* 路径前缀: /api/v1/audit/logs
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/audit/logs")
@RequiredArgsConstructor
public class AuditLogController {
private final AuditLogService auditLogService;
/**
* 审计日志列表分页多条件筛选
*/
@GetMapping
public Result<PageResult<AuditLogDTO>> list(AuditLogQueryRequest request) {
return Result.success(auditLogService.page(request));
}
/**
* 审计日志详情
*/
@GetMapping("/{id}")
public Result<AuditLogDTO> getById(@PathVariable Long id) {
return Result.success(auditLogService.getById(id));
}
/**
* 导出审计日志
*/
@GetMapping("/export")
public Result<List<AuditLogDTO>> export(AuditLogQueryRequest request) {
return Result.success(auditLogService.export(request));
}
/**
* 操作统计
*/
@GetMapping("/statistics")
public Result<AuditLogStatisticsDTO> statistics() {
return Result.success(auditLogService.statistics());
}
}

View File

@ -0,0 +1,31 @@
package com.pms.audit.controller;
import com.pms.audit.dto.LoginLogDTO;
import com.pms.audit.dto.LoginLogQueryRequest;
import com.pms.audit.service.LoginLogService;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 登录日志控制器
* 路径前缀: /api/v1/audit/login-logs
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/audit/login-logs")
@RequiredArgsConstructor
public class LoginLogController {
private final LoginLogService loginLogService;
/**
* 登录日志列表
*/
@GetMapping
public Result<PageResult<LoginLogDTO>> list(LoginLogQueryRequest request) {
return Result.success(loginLogService.page(request));
}
}

View File

@ -0,0 +1,89 @@
package com.pms.audit.dto;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 审计事件消息RabbitMQ 消息体
* 各服务通过AOP切面采集操作日志后发送到此消息结构
*/
@Data
public class AuditEventMessage implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 事件唯一标识(用于消费幂等去重) */
private String eventId;
/** 物业项目ID */
private Long projectId;
/** 操作人ID */
private Long operatorId;
/** 操作人姓名 */
private String operatorName;
/** 操作人账号 */
private String operatorAccount;
/** 操作人类型 */
private Integer operatorType;
/** 操作类型 */
private String operationType;
/** 操作模块 */
private String module;
/** 操作描述 */
private String operationDesc;
/** 请求方法 */
private String method;
/** 请求URL */
private String requestUrl;
/** HTTP方法 */
private String requestMethod;
/** 请求参数JSON */
private String requestParams;
/** 响应结果JSON */
private String responseResult;
/** 操作结果 */
private String operationResult;
/** 错误信息 */
private String errorMsg;
/** 操作IP */
private String ip;
/** User-Agent */
private String userAgent;
/** 设备类型 */
private String deviceType;
/** 接口耗时(毫秒) */
private Long costTime;
/** 业务ID */
private Long bizId;
/** 业务类型 */
private String bizType;
/** 链路追踪ID */
private String traceId;
/** 操作时间(时间戳) */
private Long operationTime;
}

View File

@ -0,0 +1,64 @@
package com.pms.audit.dto;
import lombok.Data;
/**
* 审计日志响应DTO
*/
@Data
public class AuditLogDTO {
private Long id;
private String logNo;
private Long projectId;
private Long operatorId;
private String operatorName;
private String operatorAccount;
private Integer operatorType;
private String operationType;
private String module;
private String operationDesc;
private String method;
private String requestUrl;
private String requestMethod;
private String requestParams;
private String responseResult;
private String operationResult;
private String errorMsg;
private String ip;
private String ipLocation;
private String userAgent;
private String deviceType;
private Long costTime;
private Long bizId;
private String bizType;
private String traceId;
private Long operationTime;
private Long createdAt;
}

View File

@ -0,0 +1,40 @@
package com.pms.audit.dto;
import lombok.Data;
/**
* 审计日志查询请求
*/
@Data
public class AuditLogQueryRequest {
/** 页码 */
private Integer page;
/** 每页大小 */
private Integer size;
/** 操作人ID */
private Long operatorId;
/** 操作类型 */
private String operationType;
/** 操作模块 */
private String module;
/** 操作结果 */
private String operationResult;
/** 业务类型 */
private String bizType;
/** 链路追踪ID */
private String traceId;
/** 起始时间(时间戳) */
private Long startTime;
/** 结束时间(时间戳) */
private Long endTime;
}

View File

@ -0,0 +1,33 @@
package com.pms.audit.dto;
import lombok.Data;
import java.util.List;
/**
* 审计日志统计DTO
*/
@Data
public class AuditLogStatisticsDTO {
/** 总操作数 */
private long totalCount;
/** 成功数 */
private long successCount;
/** 失败数 */
private long failCount;
/** 异常数 */
private long errorCount;
/** 各模块操作统计 */
private List<ModuleStat> moduleStats;
@Data
public static class ModuleStat {
private String module;
private long count;
}
}

View File

@ -0,0 +1,52 @@
package com.pms.audit.dto;
import lombok.Data;
/**
* 登录日志响应DTO
*/
@Data
public class LoginLogDTO {
private Long id;
private String logNo;
private Long projectId;
private Long userId;
private String userName;
private String userAccount;
private Integer userType;
private String loginType;
private String loginResult;
private String failReason;
private String ip;
private String ipLocation;
private String userAgent;
private String deviceType;
private String osName;
private String browser;
private String sessionId;
private Long loginTime;
private Long logoutTime;
private Long onlineDuration;
private Long createdAt;
}

View File

@ -0,0 +1,34 @@
package com.pms.audit.dto;
import lombok.Data;
/**
* 登录日志查询请求
*/
@Data
public class LoginLogQueryRequest {
/** 页码 */
private Integer page;
/** 每页大小 */
private Integer size;
/** 用户ID */
private Long userId;
/** 登录账号 */
private String userAccount;
/** 登录结果 */
private String loginResult;
/** 登录方式 */
private String loginType;
/** 起始时间(时间戳) */
private Long startTime;
/** 结束时间(时间戳) */
private Long endTime;
}

View File

@ -0,0 +1,103 @@
package com.pms.audit.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 审计日志实体
* 注意审计日志表不使用逻辑删除 deleted 字段日志一旦写入不可修改
*/
@Data
@TableName("t_audit_log")
public class AuditLog implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 日志编号,全局唯一 */
private String logNo;
/** 物业项目ID0表示全平台 */
private Long projectId;
/** 操作人ID */
private Long operatorId;
/** 操作人姓名 */
private String operatorName;
/** 操作人账号 */
private String operatorAccount;
/** 操作人类型1-业主 2-物业员工 3-系统管理员 */
private Integer operatorType;
/** 操作类型CREATE/UPDATE/DELETE/QUERY/EXPORT/IMPORT/LOGIN/LOGOUT/OTHER */
private String operationType;
/** 操作模块 */
private String module;
/** 操作描述 */
private String operationDesc;
/** 请求方法如ChargeController.create */
private String method;
/** 请求URL */
private String requestUrl;
/** HTTP方法GET/POST/PUT/DELETE */
private String requestMethod;
/** 请求参数JSON敏感字段已脱敏 */
private String requestParams;
/** 响应结果JSON超长截断 */
private String responseResult;
/** 操作结果SUCCESS/FAIL/ERROR */
private String operationResult;
/** 错误信息 */
private String errorMsg;
/** 操作IP地址 */
private String ip;
/** IP归属地 */
private String ipLocation;
/** User-Agent */
private String userAgent;
/** 设备类型PC/APP/WEB */
private String deviceType;
/** 接口耗时(毫秒) */
private Long costTime;
/** 业务ID */
private Long bizId;
/** 业务类型 */
private String bizType;
/** 链路追踪ID */
private String traceId;
/** 操作时间(时间戳) */
private Long operationTime;
/** 创建时间(时间戳) */
private Long createdAt;
}

View File

@ -0,0 +1,91 @@
package com.pms.audit.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 登录日志实体
* 注意登录日志表不使用逻辑删除
*/
@Data
@TableName("t_login_log")
public class LoginLog implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 日志编号,全局唯一 */
private String logNo;
/** 物业项目ID0表示全平台 */
private Long projectId;
/** 用户ID */
private Long userId;
/** 用户姓名 */
private String userName;
/** 登录账号 */
private String userAccount;
/** 用户类型1-业主 2-物业员工 3-系统管理员 */
private Integer userType;
/** 登录方式PASSWORD/SMS_CODE/WECHAT/ALIPAY */
private String loginType;
/** 登录结果LOGIN_SUCCESS/LOGIN_FAIL/LOGOUT/KICK_OUT */
private String loginResult;
/** 失败原因 */
private String failReason;
/** 登录IP地址 */
private String ip;
/** IP归属地 */
private String ipLocation;
/** User-Agent */
private String userAgent;
/** 设备类型 */
private String deviceType;
/** 设备唯一标识 */
private String deviceId;
/** 操作系统 */
private String osName;
/** 浏览器 */
private String browser;
/** 会话ID */
private String sessionId;
/** 登录令牌(脱敏) */
private String token;
/** 登录时间(时间戳) */
private Long loginTime;
/** 登出时间(时间戳) */
private Long logoutTime;
/** 在线时长(秒) */
private Long onlineDuration;
/** 创建时间(时间戳) */
private Long createdAt;
}

View File

@ -0,0 +1,12 @@
package com.pms.audit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.audit.entity.AuditLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 审计日志 Mapper
*/
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLog> {
}

View File

@ -0,0 +1,12 @@
package com.pms.audit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.audit.entity.LoginLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 登录日志 Mapper
*/
@Mapper
public interface LoginLogMapper extends BaseMapper<LoginLog> {
}

View File

@ -0,0 +1,40 @@
package com.pms.audit.service;
import com.pms.audit.dto.AuditEventMessage;
import com.pms.audit.dto.AuditLogDTO;
import com.pms.audit.dto.AuditLogQueryRequest;
import com.pms.audit.dto.AuditLogStatisticsDTO;
import com.pms.common.response.PageResult;
import java.util.List;
/**
* 审计日志服务接口
*/
public interface AuditLogService {
/**
* 分页查询审计日志
*/
PageResult<AuditLogDTO> page(AuditLogQueryRequest request);
/**
* 审计日志详情
*/
AuditLogDTO getById(Long id);
/**
* 导出审计日志返回日志列表
*/
List<AuditLogDTO> export(AuditLogQueryRequest request);
/**
* 操作统计
*/
AuditLogStatisticsDTO statistics();
/**
* 保存审计日志从MQ消息转换
*/
void save(AuditEventMessage message);
}

View File

@ -0,0 +1,16 @@
package com.pms.audit.service;
import com.pms.audit.dto.LoginLogDTO;
import com.pms.audit.dto.LoginLogQueryRequest;
import com.pms.common.response.PageResult;
/**
* 登录日志服务接口
*/
public interface LoginLogService {
/**
* 分页查询登录日志
*/
PageResult<LoginLogDTO> page(LoginLogQueryRequest request);
}

View File

@ -0,0 +1,266 @@
package com.pms.audit.service.impl;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.audit.constant.AuditConstants;
import com.pms.audit.dto.AuditEventMessage;
import com.pms.audit.dto.AuditLogDTO;
import com.pms.audit.dto.AuditLogQueryRequest;
import com.pms.audit.dto.AuditLogStatisticsDTO;
import com.pms.audit.entity.AuditLog;
import com.pms.audit.mapper.AuditLogMapper;
import com.pms.audit.service.AuditLogService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 审计日志服务实现
* <p>
* 关键特性
* - 敏感数据脱敏request_params中的password等字段自动脱敏
* - 审计日志不使用逻辑删除
* - 按月分表建议在代码注释中说明实际分表后续实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditLogServiceImpl implements AuditLogService {
private final AuditLogMapper auditLogMapper;
/** 敏感字段值脱敏正则:"fieldName":"value" → "fieldName":"***" */
private static final Pattern SENSITIVE_PATTERN =
Pattern.compile("\"(password|oldPassword|newPassword|token|secretKey|bankAccount|idCard)\"\\s*:\\s*\"[^\"]*\"",
Pattern.CASE_INSENSITIVE);
@Override
public PageResult<AuditLogDTO> page(AuditLogQueryRequest request) {
int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : CommonConstants.DEFAULT_PAGE;
int size = request.getSize() != null && request.getSize() > 0 ? request.getSize() : CommonConstants.DEFAULT_SIZE;
size = Math.min(size, CommonConstants.MAX_SIZE);
LambdaQueryWrapper<AuditLog> wrapper = buildQueryWrapper(request);
wrapper.orderByDesc(AuditLog::getOperationTime);
Page<AuditLog> pageObj = new Page<>(page, size);
IPage<AuditLog> result = auditLogMapper.selectPage(pageObj, wrapper);
return new PageResult<>(
result.getRecords().stream().map(this::toDTO).toList(),
result.getTotal(), page, size);
}
@Override
public AuditLogDTO getById(Long id) {
AuditLog auditLog = auditLogMapper.selectById(id);
if (auditLog == null) {
throw new BusinessException(ErrorCode.AUDIT_LOG_NOT_FOUND);
}
return toDTO(auditLog);
}
@Override
public List<AuditLogDTO> export(AuditLogQueryRequest request) {
// 导出最多 10000
LambdaQueryWrapper<AuditLog> wrapper = buildQueryWrapper(request);
wrapper.orderByDesc(AuditLog::getOperationTime);
wrapper.last("LIMIT 10000");
List<AuditLog> logs = auditLogMapper.selectList(wrapper);
return logs.stream().map(this::toDTO).toList();
}
@Override
public AuditLogStatisticsDTO statistics() {
Long projectId = UserContext.getProjectId();
LambdaQueryWrapper<AuditLog> baseWrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
baseWrapper.eq(AuditLog::getProjectId, projectId);
}
long totalCount = auditLogMapper.selectCount(baseWrapper);
LambdaQueryWrapper<AuditLog> successWrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
successWrapper.eq(AuditLog::getProjectId, projectId);
}
successWrapper.eq(AuditLog::getOperationResult, AuditConstants.RESULT_SUCCESS);
long successCount = auditLogMapper.selectCount(successWrapper);
LambdaQueryWrapper<AuditLog> failWrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
failWrapper.eq(AuditLog::getProjectId, projectId);
}
failWrapper.eq(AuditLog::getOperationResult, AuditConstants.RESULT_FAIL);
long failCount = auditLogMapper.selectCount(failWrapper);
LambdaQueryWrapper<AuditLog> errorWrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
errorWrapper.eq(AuditLog::getProjectId, projectId);
}
errorWrapper.eq(AuditLog::getOperationResult, AuditConstants.RESULT_ERROR);
long errorCount = auditLogMapper.selectCount(errorWrapper);
AuditLogStatisticsDTO dto = new AuditLogStatisticsDTO();
dto.setTotalCount(totalCount);
dto.setSuccessCount(successCount);
dto.setFailCount(failCount);
dto.setErrorCount(errorCount);
// 模块统计暂为空列表实际可通过 GROUP BY 查询实现
dto.setModuleStats(new ArrayList<>());
return dto;
}
@Override
public void save(AuditEventMessage message) {
AuditLog auditLog = new AuditLog();
auditLog.setLogNo(generateLogNo());
auditLog.setProjectId(message.getProjectId() != null ? message.getProjectId() : 0L);
auditLog.setOperatorId(message.getOperatorId());
auditLog.setOperatorName(message.getOperatorName());
auditLog.setOperatorAccount(message.getOperatorAccount());
auditLog.setOperatorType(message.getOperatorType() != null ? message.getOperatorType() : 2);
auditLog.setOperationType(message.getOperationType());
auditLog.setModule(message.getModule());
auditLog.setOperationDesc(message.getOperationDesc());
auditLog.setMethod(message.getMethod());
auditLog.setRequestUrl(message.getRequestUrl());
auditLog.setRequestMethod(message.getRequestMethod());
// 敏感数据脱敏
auditLog.setRequestParams(desensitizeParams(message.getRequestParams()));
// 响应结果超长截断最大 5000 字符
auditLog.setResponseResult(truncate(message.getResponseResult(), 5000));
auditLog.setOperationResult(message.getOperationResult());
auditLog.setErrorMsg(truncate(message.getErrorMsg(), 2000));
auditLog.setIp(message.getIp());
auditLog.setUserAgent(message.getUserAgent());
auditLog.setDeviceType(message.getDeviceType());
auditLog.setCostTime(message.getCostTime() != null ? message.getCostTime() : 0L);
auditLog.setBizId(message.getBizId());
auditLog.setBizType(message.getBizType());
auditLog.setTraceId(message.getTraceId());
long now = System.currentTimeMillis();
auditLog.setOperationTime(message.getOperationTime() != null ? message.getOperationTime() : now);
auditLog.setCreatedAt(now);
auditLogMapper.insert(auditLog);
log.debug("审计日志保存成功: logNo={}, module={}, operationType={}",
auditLog.getLogNo(), auditLog.getModule(), auditLog.getOperationType());
}
// ====== 私有方法 ======
private LambdaQueryWrapper<AuditLog> buildQueryWrapper(AuditLogQueryRequest request) {
Long projectId = UserContext.getProjectId();
LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
wrapper.eq(AuditLog::getProjectId, projectId);
}
if (request.getOperatorId() != null) {
wrapper.eq(AuditLog::getOperatorId, request.getOperatorId());
}
if (StringUtils.hasText(request.getOperationType())) {
wrapper.eq(AuditLog::getOperationType, request.getOperationType());
}
if (StringUtils.hasText(request.getModule())) {
wrapper.eq(AuditLog::getModule, request.getModule());
}
if (StringUtils.hasText(request.getOperationResult())) {
wrapper.eq(AuditLog::getOperationResult, request.getOperationResult());
}
if (StringUtils.hasText(request.getBizType())) {
wrapper.eq(AuditLog::getBizType, request.getBizType());
}
if (StringUtils.hasText(request.getTraceId())) {
wrapper.eq(AuditLog::getTraceId, request.getTraceId());
}
if (request.getStartTime() != null) {
wrapper.ge(AuditLog::getOperationTime, request.getStartTime());
}
if (request.getEndTime() != null) {
wrapper.le(AuditLog::getOperationTime, request.getEndTime());
}
return wrapper;
}
/**
* 敏感数据脱敏
* requestParams JSON 中的 passwordtoken 等字段值替换为 ***
*/
private String desensitizeParams(String params) {
if (params == null || params.isBlank()) {
return params;
}
Matcher matcher = SENSITIVE_PATTERN.matcher(params);
return matcher.replaceAll(m -> {
// 保留字段名替换值为 ***
String matched = m.group();
int colonIdx = matched.indexOf(':');
return matched.substring(0, colonIdx + 1) + "\"***\"";
});
}
/**
* 字符串截断
*/
private String truncate(String str, int maxLength) {
if (str == null) {
return null;
}
if (str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength) + "...[truncated]";
}
private String generateLogNo() {
return "AUD" + System.currentTimeMillis() + IdUtil.fastSimpleUUID().substring(0, 6).toUpperCase();
}
private AuditLogDTO toDTO(AuditLog auditLog) {
AuditLogDTO dto = new AuditLogDTO();
dto.setId(auditLog.getId());
dto.setLogNo(auditLog.getLogNo());
dto.setProjectId(auditLog.getProjectId());
dto.setOperatorId(auditLog.getOperatorId());
dto.setOperatorName(auditLog.getOperatorName());
dto.setOperatorAccount(auditLog.getOperatorAccount());
dto.setOperatorType(auditLog.getOperatorType());
dto.setOperationType(auditLog.getOperationType());
dto.setModule(auditLog.getModule());
dto.setOperationDesc(auditLog.getOperationDesc());
dto.setMethod(auditLog.getMethod());
dto.setRequestUrl(auditLog.getRequestUrl());
dto.setRequestMethod(auditLog.getRequestMethod());
dto.setRequestParams(auditLog.getRequestParams());
dto.setResponseResult(auditLog.getResponseResult());
dto.setOperationResult(auditLog.getOperationResult());
dto.setErrorMsg(auditLog.getErrorMsg());
dto.setIp(auditLog.getIp());
dto.setIpLocation(auditLog.getIpLocation());
dto.setUserAgent(auditLog.getUserAgent());
dto.setDeviceType(auditLog.getDeviceType());
dto.setCostTime(auditLog.getCostTime());
dto.setBizId(auditLog.getBizId());
dto.setBizType(auditLog.getBizType());
dto.setTraceId(auditLog.getTraceId());
dto.setOperationTime(auditLog.getOperationTime());
dto.setCreatedAt(auditLog.getCreatedAt());
return dto;
}
}

View File

@ -0,0 +1,93 @@
package com.pms.audit.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.audit.dto.LoginLogDTO;
import com.pms.audit.dto.LoginLogQueryRequest;
import com.pms.audit.entity.LoginLog;
import com.pms.audit.mapper.LoginLogMapper;
import com.pms.audit.service.LoginLogService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* 登录日志服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginLogServiceImpl implements LoginLogService {
private final LoginLogMapper loginLogMapper;
@Override
public PageResult<LoginLogDTO> page(LoginLogQueryRequest request) {
int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : CommonConstants.DEFAULT_PAGE;
int size = request.getSize() != null && request.getSize() > 0 ? request.getSize() : CommonConstants.DEFAULT_SIZE;
size = Math.min(size, CommonConstants.MAX_SIZE);
Long projectId = UserContext.getProjectId();
LambdaQueryWrapper<LoginLog> wrapper = new LambdaQueryWrapper<>();
if (projectId != null) {
wrapper.eq(LoginLog::getProjectId, projectId);
}
if (request.getUserId() != null) {
wrapper.eq(LoginLog::getUserId, request.getUserId());
}
if (StringUtils.hasText(request.getUserAccount())) {
wrapper.eq(LoginLog::getUserAccount, request.getUserAccount());
}
if (StringUtils.hasText(request.getLoginResult())) {
wrapper.eq(LoginLog::getLoginResult, request.getLoginResult());
}
if (StringUtils.hasText(request.getLoginType())) {
wrapper.eq(LoginLog::getLoginType, request.getLoginType());
}
if (request.getStartTime() != null) {
wrapper.ge(LoginLog::getLoginTime, request.getStartTime());
}
if (request.getEndTime() != null) {
wrapper.le(LoginLog::getLoginTime, request.getEndTime());
}
wrapper.orderByDesc(LoginLog::getLoginTime);
Page<LoginLog> pageObj = new Page<>(page, size);
IPage<LoginLog> result = loginLogMapper.selectPage(pageObj, wrapper);
return new PageResult<>(
result.getRecords().stream().map(this::toDTO).toList(),
result.getTotal(), page, size);
}
private LoginLogDTO toDTO(LoginLog loginLog) {
LoginLogDTO dto = new LoginLogDTO();
dto.setId(loginLog.getId());
dto.setLogNo(loginLog.getLogNo());
dto.setProjectId(loginLog.getProjectId());
dto.setUserId(loginLog.getUserId());
dto.setUserName(loginLog.getUserName());
dto.setUserAccount(loginLog.getUserAccount());
dto.setUserType(loginLog.getUserType());
dto.setLoginType(loginLog.getLoginType());
dto.setLoginResult(loginLog.getLoginResult());
dto.setFailReason(loginLog.getFailReason());
dto.setIp(loginLog.getIp());
dto.setIpLocation(loginLog.getIpLocation());
dto.setUserAgent(loginLog.getUserAgent());
dto.setDeviceType(loginLog.getDeviceType());
dto.setOsName(loginLog.getOsName());
dto.setBrowser(loginLog.getBrowser());
dto.setSessionId(loginLog.getSessionId());
dto.setLoginTime(loginLog.getLoginTime());
dto.setLogoutTime(loginLog.getLogoutTime());
dto.setOnlineDuration(loginLog.getOnlineDuration());
dto.setCreatedAt(loginLog.getCreatedAt());
return dto;
}
}

View File

@ -0,0 +1,94 @@
server:
port: 8087
spring:
application:
name: audit-service
profiles:
active: dev
datasource:
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/audit_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
idle-timeout: 600000
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/audit_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
user: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 7
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
rabbitmq:
host: ${RABBITMQ_HOST:localhost}
port: ${RABBITMQ_PORT:5672}
username: ${RABBITMQ_USERNAME:guest}
password: ${RABBITMQ_PASSWORD:guest}
virtual-host: /
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 50
cloud:
nacos:
discovery:
server-addr: ${NACOS_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:public}
file-extension: yaml
shared-configs:
- data-id: pms-common.yaml
refresh: true
openfeign:
client:
config:
default:
connect-timeout: 3000
read-timeout: 5000
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.pms.audit.**.entity
global-config:
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
table-prefix: t_
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
logging:
level:
com.pms: debug
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always

View File

@ -0,0 +1,90 @@
-- ========================================
-- audit_db 数据库初始化脚本2张表
-- 审计服务:操作审计日志、登录日志
-- 技术约定:
-- 主键 BIGINT UNSIGNED雪花算法
-- 时间戳 BIGINT毫秒级时间戳
-- 审计日志表不使用逻辑删除(日志一旦写入不可修改)
-- ========================================
-- ====================
-- 1. 审计日志表
-- ====================
CREATE TABLE IF NOT EXISTS `t_audit_log` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
`log_no` VARCHAR(32) NOT NULL COMMENT '日志编号,全局唯一',
`project_id` BIGINT NOT NULL DEFAULT 0 COMMENT '物业项目ID0表示全平台',
`operator_id` BIGINT NOT NULL COMMENT '操作人ID',
`operator_name` VARCHAR(50) NOT NULL COMMENT '操作人姓名',
`operator_account` VARCHAR(50) NOT NULL COMMENT '操作人账号',
`operator_type` TINYINT NOT NULL COMMENT '操作人类型1-业主 2-物业员工 3-系统管理员',
`operation_type` VARCHAR(20) NOT NULL COMMENT '操作类型CREATE/UPDATE/DELETE/QUERY/EXPORT/IMPORT/LOGIN/LOGOUT/OTHER',
`module` VARCHAR(50) NOT NULL COMMENT '操作模块',
`operation_desc` VARCHAR(500) NOT NULL COMMENT '操作描述',
`method` VARCHAR(200) DEFAULT NULL COMMENT '请求方法如ChargeController.create',
`request_url` VARCHAR(500) DEFAULT NULL COMMENT '请求URL',
`request_method` VARCHAR(10) DEFAULT NULL COMMENT 'HTTP方法GET/POST/PUT/DELETE',
`request_params` TEXT COMMENT '请求参数JSON敏感字段已脱敏',
`response_result` TEXT COMMENT '响应结果JSON超长截断',
`operation_result` VARCHAR(20) NOT NULL COMMENT '操作结果SUCCESS/FAIL/ERROR',
`error_msg` TEXT COMMENT '错误信息',
`ip` VARCHAR(50) NOT NULL COMMENT '操作IP地址',
`ip_location` VARCHAR(200) DEFAULT NULL COMMENT 'IP归属地',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent',
`device_type` VARCHAR(20) DEFAULT NULL COMMENT '设备类型PC/APP/WEB',
`cost_time` BIGINT NOT NULL DEFAULT 0 COMMENT '接口耗时(毫秒)',
`biz_id` BIGINT DEFAULT NULL COMMENT '业务ID',
`biz_type` VARCHAR(50) DEFAULT NULL COMMENT '业务类型',
`trace_id` VARCHAR(64) DEFAULT NULL COMMENT '链路追踪ID',
`operation_time` BIGINT NOT NULL COMMENT '操作时间(时间戳)',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_log_no` (`log_no`),
KEY `idx_project_id` (`project_id`),
KEY `idx_operator_id` (`operator_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_module` (`module`),
KEY `idx_operation_result` (`operation_result`),
KEY `idx_trace_id` (`trace_id`),
KEY `idx_operation_time` (`operation_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审计日志表';
-- 说明审计日志表不使用逻辑删除字段deleted日志一旦写入不可修改或删除。
-- 建议后续按月分表(如 t_audit_log_202406并设置数据保留策略如保留2年
-- ====================
-- 2. 登录日志表
-- ====================
CREATE TABLE IF NOT EXISTS `t_login_log` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
`log_no` VARCHAR(32) NOT NULL COMMENT '日志编号,全局唯一',
`project_id` BIGINT NOT NULL DEFAULT 0 COMMENT '物业项目ID0表示全平台',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`user_name` VARCHAR(50) NOT NULL COMMENT '用户姓名',
`user_account` VARCHAR(50) NOT NULL COMMENT '登录账号',
`user_type` TINYINT NOT NULL COMMENT '用户类型1-业主 2-物业员工 3-系统管理员',
`login_type` VARCHAR(20) NOT NULL COMMENT '登录方式PASSWORD/SMS_CODE/WECHAT/ALIPAY',
`login_result` VARCHAR(20) NOT NULL COMMENT '登录结果LOGIN_SUCCESS/LOGIN_FAIL/LOGOUT/KICK_OUT',
`fail_reason` VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
`ip` VARCHAR(50) NOT NULL COMMENT '登录IP地址',
`ip_location` VARCHAR(200) DEFAULT NULL COMMENT 'IP归属地',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent',
`device_type` VARCHAR(20) DEFAULT NULL COMMENT '设备类型',
`device_id` VARCHAR(200) DEFAULT NULL COMMENT '设备唯一标识',
`os_name` VARCHAR(50) DEFAULT NULL COMMENT '操作系统',
`browser` VARCHAR(100) DEFAULT NULL COMMENT '浏览器',
`session_id` VARCHAR(128) DEFAULT NULL COMMENT '会话ID',
`token` VARCHAR(500) DEFAULT NULL COMMENT '登录令牌(脱敏)',
`login_time` BIGINT NOT NULL COMMENT '登录时间(时间戳)',
`logout_time` BIGINT DEFAULT NULL COMMENT '登出时间(时间戳)',
`online_duration` BIGINT NOT NULL DEFAULT 0 COMMENT '在线时长(秒)',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_log_no` (`log_no`),
KEY `idx_project_id` (`project_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_user_account` (`user_account`),
KEY `idx_login_result` (`login_result`),
KEY `idx_session_id` (`session_id`),
KEY `idx_login_time` (`login_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表';

View File

@ -0,0 +1,13 @@
-- 消息消费幂等日志表
-- 用于持久化保障消息消费幂等性,防止 Redis 失效后重复消费
CREATE TABLE IF NOT EXISTS mq_consume_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL COMMENT '事件唯一ID',
consumer_group VARCHAR(64) NOT NULL COMMENT '消费组名称',
queue_name VARCHAR(128) NOT NULL COMMENT '队列名称',
consume_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间',
status VARCHAR(16) NOT NULL DEFAULT 'SUCCESS' COMMENT '消费状态',
error_msg TEXT NULL COMMENT '错误信息',
PRIMARY KEY (id),
UNIQUE KEY uk_event_consumer (event_id, consumer_group)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息消费幂等日志表';

View File

@ -0,0 +1,47 @@
// ========================================
// pms-auth
// ========================================
apply plugin: 'org.springframework.boot'
dependencies {
implementation project(':pms-common')
// WebServlet
implementation 'org.springframework.boot:spring-boot-starter-web'
//
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Spring Security BCrypt
implementation 'org.springframework.boot:spring-boot-starter-security'
//
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
// Nacos
implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery'
implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-config'
implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap'
// MyBatis-Plus
implementation "com.baomidou:mybatis-plus-spring-boot3-starter:${mybatisPlusVersion}"
// Flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
// MySQL
runtimeOnly "com.mysql:mysql-connector-j:${mysqlConnectorVersion}"
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// RabbitMQ
implementation 'org.springframework.boot:spring-boot-starter-amqp'
// MapStruct
implementation "org.mapstruct:mapstruct:${mapstructVersion}"
}

View File

@ -0,0 +1,26 @@
package com.pms.auth;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import com.pms.common.security.JwtProperties;
/**
* 认证服务启动类
* 负责用户登录令牌签发RS256刷新权限管理
*/
@SpringBootApplication(scanBasePackages = "com.pms")
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.pms")
@EnableConfigurationProperties(JwtProperties.class)
@MapperScan("com.pms.auth.**.mapper")
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}

View File

@ -0,0 +1,23 @@
package com.pms.auth.config;
import com.pms.common.security.JwtProperties;
import com.pms.common.security.JwtUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* JWT Bean 配置
* pms-common 中的 JwtUtils 注册为 Spring Bean
*/
@Configuration
public class JwtBeanConfig {
/**
* JwtUtils Bean
* 持有 RSA 私钥用于签发令牌
*/
@Bean
public JwtUtils jwtUtils(JwtProperties jwtProperties) {
return new JwtUtils(jwtProperties);
}
}

View File

@ -0,0 +1,34 @@
package com.pms.auth.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.pms.common.config.PmsTenantLineHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
* 乐观锁 + 多租户 + 分页插件
* 拦截器顺序OptimisticLocker TenantLine Pagination
*/
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 多租户插件基于 project_id 行级隔离
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new PmsTenantLineHandler()));
// 分页插件
PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
pageInterceptor.setMaxLimit(100L);
pageInterceptor.setOverflow(false);
interceptor.addInnerInterceptor(pageInterceptor);
return interceptor;
}
}

View File

@ -0,0 +1,85 @@
package com.pms.auth.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置
* 自定义序列化方式Key 使用 StringValue 使用 JSON
* <p>
* 安全修复使用 BasicPolymorphicTypeValidator 白名单校验器替代 LaissezFaireSubTypeValidator
* 仅允许 com.pms.* 包下及标准 Java 类型反序列化防止远程代码执行RCE漏洞
*/
@Configuration
public class RedisConfig {
/**
* RedisTemplate 配置通用
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 使用 String 序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// Value 使用 Jackson JSON 序列化
Jackson2JsonRedisSerializer<Object> jsonSerializer = jackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
/**
* StringRedisTemplate用于存储简单字符串如验证码Token黑名单
*/
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
/**
* Jackson JSON 序列化器
* <p>
* 安全修复使用白名单 PolymorphicTypeValidator 替代 LaissezFaireSubTypeValidator.instance
* 白名单仅允许以下包/类进行多态反序列化
* - com.pms.common.entity, com.pms.auth.entity 等业务实体
* - java.util.*, java.lang.*, java.time.* 等标准 Java 类型
* 防止攻击者通过构造恶意 JSON 触发任意类实例化导致 RCE
*/
private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
// 构建白名单类型校验器仅允许安全的包前缀进行多态反序列化
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.allowIfSubType("com.pms.")
.allowIfSubType("java.util.")
.allowIfSubType("java.lang.")
.allowIfSubType("java.time.")
.allowIfSubType("java.math.")
.build();
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 使用白名单校验器 + As.PROPERTY 类型信息嵌入方式
mapper.activateDefaultTyping(typeValidator,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
return new Jackson2JsonRedisSerializer<>(mapper, Object.class);
}
}

View File

@ -0,0 +1,45 @@
package com.pms.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
/**
* Spring Security 配置
* 认证服务为无状态 APIJWT 鉴权由网关统一处理
* 此处主要提供 BCrypt 密码编码器并放行登录相关接口
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 关闭 CSRF无状态 API
.csrf(csrf -> csrf.disable())
// 关闭表单登录与默认认证
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
// 无状态会话
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 放行所有请求JWT 鉴权由网关统一处理下游服务无需再次认证
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
/**
* BCrypt 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -0,0 +1,38 @@
package com.pms.auth.config;
import com.pms.common.interceptor.UserContextInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
* 注册拦截器
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final UserContextInterceptor userContextInterceptor;
public WebMvcConfig(UserContextInterceptor userContextInterceptor) {
this.userContextInterceptor = userContextInterceptor;
}
/**
* 鉴权白名单无需解析用户上下文的路径
*/
private static final String[] WHITELIST = {
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/captcha",
"/actuator/**",
"/error"
};
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns(WHITELIST);
}
}

View File

@ -0,0 +1,61 @@
package com.pms.auth.constant;
/**
* 认证服务常量
*/
public final class AuthConstants {
private AuthConstants() {
}
// ====== Redis Key 前缀 ======
/** JWT 黑名单前缀 */
public static final String JWT_BLACKLIST_ACCESS = "jwt:blacklist:access:";
public static final String JWT_BLACKLIST_REFRESH = "jwt:blacklist:refresh:";
/** 验证码 Redis Key 前缀 */
public static final String CAPTCHA_KEY = "captcha:";
/** 用户权限缓存前缀 */
public static final String USER_PERMISSIONS_KEY = "user:permissions:";
/** 用户角色缓存前缀 */
public static final String USER_ROLES_KEY = "user:roles:";
/** 用户信息缓存前缀 */
public static final String USER_INFO_CACHE_KEY = "user:info:";
/** 登录失败计数 Redis Key 前缀 */
public static final String LOGIN_FAIL_KEY = "login:fail:";
/** 登录锁定 Redis Key 前缀 */
public static final String LOGIN_LOCK_KEY = "login:lock:";
// ====== 验证码配置 ======
/** 验证码有效期(秒) */
public static final long CAPTCHA_EXPIRE = 300;
// ====== 登录失败锁定配置 ======
/** 最大连续登录失败次数 */
public static final int MAX_FAIL_COUNT = 5;
/** 锁定时长(秒) */
public static final long LOCK_DURATION = 15 * 60;
/** 用户信息缓存 TTL 基准值(秒) */
public static final int USER_CACHE_TTL = 1800;
/** 用户信息缓存 TTL 随机偏移上限(秒),防雪崩 */
public static final int USER_CACHE_TTL_JITTER = 300;
/** 空值缓存 TTL防穿透 */
public static final int NULL_CACHE_TTL = 60;
// ====== 默认密码 ======
/** 系统默认密码 */
public static final String DEFAULT_PASSWORD = "Init@123456";
}

View File

@ -0,0 +1,120 @@
package com.pms.auth.controller;
import com.pms.auth.dto.*;
import com.pms.auth.service.AuthService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 认证控制器
* 路径前缀: /api/v1/auth
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 用户登录
*/
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
String clientIp = getClientIp(httpRequest);
return Result.success(authService.login(request, clientIp));
}
/**
* 退出登录
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody(required = false) RefreshTokenRequest request) {
String accessToken = null;
if (authHeader != null && authHeader.startsWith(CommonConstants.BEARER_PREFIX)) {
accessToken = authHeader.substring(CommonConstants.BEARER_PREFIX.length());
}
String refreshToken = request != null ? request.getRefreshToken() : null;
authService.logout(accessToken, refreshToken);
return Result.success();
}
/**
* 刷新令牌
*/
@PostMapping("/refresh")
public Result<TokenRefreshResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return Result.success(authService.refreshToken(request.getRefreshToken()));
}
/**
* 获取验证码
*/
@GetMapping("/captcha")
public Result<CaptchaResponse> captcha() {
return Result.success(authService.getCaptcha());
}
/**
* 修改密码
*/
@PutMapping("/password")
public Result<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(UserContext.getUserId(), request);
return Result.success();
}
/**
* 重置密码管理员
*/
@PutMapping("/password/reset")
public Result<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
authService.resetPassword(request);
return Result.success();
}
/**
* 获取当前用户信息
*/
@GetMapping("/userinfo")
public Result<UserInfoDTO> userInfo() {
return Result.success(authService.getCurrentUserInfo());
}
/**
* 权限检查
*/
@PostMapping("/check-permission")
public Result<Boolean> checkPermission(@RequestParam Long userId,
@RequestParam String permissionCode,
@RequestParam(required = false) Long projectId) {
return Result.success(authService.checkPermission(userId, permissionCode, projectId));
}
/**
* 获取客户端 IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理时取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@ -0,0 +1,78 @@
package com.pms.auth.controller;
import com.pms.auth.dto.UserDTO;
import com.pms.auth.mapper.UserMapper;
import com.pms.common.dto.internal.InternalServiceDTOs;
import com.pms.common.dto.internal.InternalServiceDTOs.PermissionResultDTO;
import com.pms.common.dto.internal.InternalServiceDTOs.VerifyPermissionRequest;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* auth-service 内部接口控制器
* <p>
* 供其他微服务通过 Feign 调用验证权限查询用户信息
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class InternalController {
private final UserMapper userMapper;
/**
* 验证用户权限
*/
@PostMapping("/api/internal/auth/verify-permission")
public Result<PermissionResultDTO> verifyPermission(@Valid @RequestBody VerifyPermissionRequest request) {
log.info("内部接口-验证权限: userId={}, permission={}",
request.getUserId(), request.getPermissionCode());
List<String> permissions = userMapper.selectPermissionCodesByUserId(request.getUserId());
boolean hasPermission = permissions != null && permissions.contains(request.getPermissionCode());
PermissionResultDTO result = new PermissionResultDTO();
result.setHasPermission(hasPermission);
return Result.success(result);
}
/**
* 查询用户信息
*/
@GetMapping("/api/internal/auth/users/{userId}")
public Result<InternalServiceDTOs.UserDTO> getUser(@PathVariable Long userId) {
log.info("内部接口-查询用户: userId={}", userId);
UserDTO user = userMapper.selectUserWithRolesById(userId);
if (user == null) {
return Result.success(null);
}
InternalServiceDTOs.UserDTO dto = new InternalServiceDTOs.UserDTO();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setRealName(user.getRealName());
dto.setPhone(user.getPhone());
dto.setOrgId(user.getOrgId());
dto.setOrgName(user.getOrgName());
// 角色编码列表
if (user.getRoles() != null) {
dto.setRoles(user.getRoles().stream()
.map(role -> role.getRoleCode())
.collect(Collectors.toList()));
}
// 权限编码列表
List<String> permissions = userMapper.selectPermissionCodesByUserId(userId);
dto.setPermissions(permissions);
return Result.success(dto);
}
}

View File

@ -0,0 +1,66 @@
package com.pms.auth.controller;
import com.pms.auth.dto.OrgDTO;
import com.pms.auth.dto.OrgSaveRequest;
import com.pms.auth.service.OrgService;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 组织管理控制器
* 路径前缀: /api/v1/auth/orgs
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/orgs")
@RequiredArgsConstructor
public class OrgController {
private final OrgService orgService;
/**
* 组织架构树
*/
@GetMapping
public Result<List<OrgDTO>> list(@RequestParam(required = false) Long parentId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long projectId) {
return Result.success(orgService.list(parentId, status, projectId));
}
/**
* 创建组织节点
*/
@PostMapping
public Result<Map<String, Long>> create(@Valid @RequestBody OrgSaveRequest request) {
Long id = orgService.create(request);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
return Result.success(data);
}
/**
* 更新组织节点
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody OrgSaveRequest request) {
orgService.update(id, request);
return Result.success();
}
/**
* 删除组织节点
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
orgService.delete(id);
return Result.success();
}
}

View File

@ -0,0 +1,39 @@
package com.pms.auth.controller;
import com.pms.auth.dto.PermissionTreeResult;
import com.pms.auth.service.PermissionService;
import com.pms.common.response.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 权限管理控制器
* 路径前缀: /api/v1/auth/permissions
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/permissions")
@RequiredArgsConstructor
public class PermissionController {
private final PermissionService permissionService;
/**
* 权限树查询
*/
@GetMapping
public Result<PermissionTreeResult> tree(@RequestParam(required = false) Long roleId,
@RequestParam(required = false) Integer permType) {
return Result.success(permissionService.getPermissionTree(roleId, permType));
}
/**
* 权限树查询/tree 别名
*/
@GetMapping("/tree")
public Result<PermissionTreeResult> treeAlias(@RequestParam(required = false) Long roleId,
@RequestParam(required = false) Integer permType) {
return Result.success(permissionService.getPermissionTree(roleId, permType));
}
}

View File

@ -0,0 +1,85 @@
package com.pms.auth.controller;
import com.pms.auth.dto.*;
import com.pms.auth.service.RoleService;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 角色管理控制器
* 路径前缀: /api/v1/auth/roles
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/roles")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
/**
* 角色列表
*/
@GetMapping
public Result<List<RoleDTO>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long projectId,
@RequestParam(required = false, defaultValue = "false") boolean all) {
return Result.success(roleService.list(keyword, status, projectId, all));
}
/**
* 创建角色
*/
@PostMapping
public Result<Map<String, Long>> create(@Valid @RequestBody RoleSaveRequest request) {
Long id = roleService.create(request);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
return Result.success(data);
}
/**
* 角色详情
*/
@GetMapping("/{id}")
public Result<RoleDTO> getById(@PathVariable Long id) {
return Result.success(roleService.getById(id));
}
/**
* 更新角色
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody RoleSaveRequest request) {
roleService.update(id, request);
return Result.success();
}
/**
* 删除角色
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
roleService.delete(id);
return Result.success();
}
/**
* 分配权限
*/
@PostMapping("/{id}/permissions")
public Result<Void> assignPermissions(@PathVariable Long id,
@Valid @RequestBody AssignPermissionsRequest request) {
roleService.assignPermissions(id, request);
return Result.success();
}
}

View File

@ -0,0 +1,89 @@
package com.pms.auth.controller;
import com.pms.auth.dto.*;
import com.pms.auth.service.UserService;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 用户管理控制器
* 路径前缀: /api/v1/auth/users
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 用户列表查询分页
*/
@GetMapping
public Result<PageResult<UserDTO>> list(UserQueryRequest request) {
return Result.success(userService.page(request));
}
/**
* 创建用户
*/
@PostMapping
public Result<Map<String, Long>> create(@Valid @RequestBody UserSaveRequest request) {
Long id = userService.create(request);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
return Result.success(data);
}
/**
* 用户详情
*/
@GetMapping("/{id}")
public Result<UserDTO> getById(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
/**
* 更新用户
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) {
userService.update(id, request);
return Result.success();
}
/**
* 删除用户
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
userService.delete(id);
return Result.success();
}
/**
* 更新用户状态
*/
@PutMapping("/{id}/status")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
userService.updateStatus(id, status);
return Result.success();
}
/**
* 分配角色
*/
@PostMapping("/{id}/roles")
public Result<Void> assignRoles(@PathVariable Long id, @Valid @RequestBody AssignRolesRequest request) {
userService.assignRoles(id, request);
return Result.success();
}
}

View File

@ -0,0 +1,18 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 分配权限请求
*/
@Data
public class AssignPermissionsRequest implements Serializable {
/** 权限 ID 列表 */
@NotNull(message = "permissionIds不能为null")
private List<Long> permissionIds;
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 分配角色请求
*/
@Data
public class AssignRolesRequest implements Serializable {
/** 角色 ID 列表(可空数组表示清空角色) */
@NotNull(message = "roleIds不能为null")
private List<Long> roleIds;
/** 项目ID不传则为全局角色 */
private Long projectId;
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 验证码响应
*/
@Data
public class CaptchaResponse implements Serializable {
/** 验证码 UUID登录时回传 */
private String captchaKey;
/** Base64 编码的 PNG 图片,不含前缀 */
private String captchaImg;
/** 有效期秒数 */
private long expireSeconds;
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 修改密码请求
*/
@Data
public class ChangePasswordRequest implements Serializable {
@NotBlank(message = "原密码不能为空")
private String oldPassword;
@NotBlank(message = "新密码不能为空")
@Size(min = 8, max = 20, message = "新密码长度8-20位")
private String newPassword;
}

View File

@ -0,0 +1,26 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serializable;
/**
* 登录请求
*/
@Data
public class LoginRequest implements Serializable {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String captchaKey;
private String captchaCode;
/** 指定登录的项目ID不传则使用默认项目 */
private Long projectId;
}

View File

@ -0,0 +1,27 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 登录响应
*/
@Data
public class LoginResponse implements Serializable {
/** 访问令牌 */
private String accessToken;
/** 刷新令牌 */
private String refreshToken;
/** 令牌类型 */
private String tokenType = "bearer";
/** accessToken 剩余秒数 */
private long expiresIn;
/** 用户信息 */
private UserInfoDTO userInfo;
}

View File

@ -0,0 +1,38 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 组织DTO树形结构
*/
@Data
public class OrgDTO implements Serializable {
private Long id;
private Long parentId;
private String orgCode;
private String orgName;
/** 类型1公司 2部门 3小组 4项目部 */
private Integer orgType;
private Long leaderId;
/** 负责人姓名 */
private String leaderName;
private String phone;
private Integer sort;
private Integer status;
/** 子组织列表 */
private List<OrgDTO> children;
}

View File

@ -0,0 +1,37 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 创建/更新组织请求
*/
@Data
public class OrgSaveRequest implements Serializable {
private Long parentId;
@NotBlank(message = "组织编码不能为空")
@Size(max = 64, message = "组织编码最长64位")
private String orgCode;
@NotBlank(message = "组织名称不能为空")
@Size(max = 128, message = "组织名称最长128位")
private String orgName;
@NotNull(message = "组织类型不能为空")
private Integer orgType;
private Long leaderId;
@Size(max = 20, message = "联系电话最长20位")
private String phone;
private Integer sort;
private Integer status;
}

View File

@ -0,0 +1,43 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 权限DTO树形结构
*/
@Data
public class PermissionDTO implements Serializable {
private Long id;
private Long parentId;
private String permCode;
private String permName;
/** 类型1菜单 2按钮 3接口 */
private Integer permType;
private String path;
private String component;
private String icon;
private String apiMethod;
private String apiPath;
private Integer sort;
private Integer isHidden;
private Integer status;
/** 子权限列表 */
private List<PermissionDTO> children;
}

View File

@ -0,0 +1,19 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 权限树查询结果
*/
@Data
public class PermissionTreeResult implements Serializable {
/** 权限树 */
private List<PermissionDTO> tree;
/** 已勾选权限 IDroleId 不为空时返回) */
private List<Long> checkedKeys;
}

View File

@ -0,0 +1,19 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 项目信息DTO用于用户详情中的项目列表
*/
@Data
public class ProjectInfoDTO implements Serializable {
private Long id;
private String projectName;
/** 是否默认项目0否 1是 */
private Integer isDefault;
}

View File

@ -0,0 +1,16 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serializable;
/**
* 刷新令牌请求
*/
@Data
public class RefreshTokenRequest implements Serializable {
@NotBlank(message = "refreshToken不能为空")
private String refreshToken;
}

View File

@ -0,0 +1,19 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
/**
* 重置密码请求
*/
@Data
public class ResetPasswordRequest implements Serializable {
@NotNull(message = "用户ID不能为空")
private Long userId;
/** 新密码,不传则使用系统默认密码 */
private String newPassword;
}

View File

@ -0,0 +1,40 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 角色DTO
*/
@Data
public class RoleDTO implements Serializable {
private Long id;
private String roleCode;
private String roleName;
/** 数据范围1全部 2本组织 3本组织及下级 4本人 5自定义 */
private Integer dataScope;
private Integer sort;
private Integer status;
private String remark;
private Long projectId;
private Long createdAt;
private Long updatedAt;
/** 关联用户数 */
private Integer userCount;
/** 权限ID列表角色详情时返回 */
private List<Long> permissionIds;
}

View File

@ -0,0 +1,40 @@
package com.pms.auth.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 创建/更新角色请求
*/
@Data
public class RoleSaveRequest implements Serializable {
@NotBlank(message = "角色编码不能为空")
@Size(max = 64, message = "角色编码最长64位")
private String roleCode;
@NotBlank(message = "角色名称不能为空")
@Size(max = 64, message = "角色名称最长64位")
private String roleName;
/** 数据范围默认1 */
private Integer dataScope = 1;
private Long projectId;
private Integer sort = 0;
private Integer status = 1;
@Size(max = 255, message = "备注最长255位")
private String remark;
/** 权限 ID 列表 */
private List<Long> permissionIds;
}

View File

@ -0,0 +1,20 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 令牌刷新响应
*/
@Data
public class TokenRefreshResponse implements Serializable {
private String accessToken;
private String refreshToken;
private String tokenType = "bearer";
private long expiresIn;
}

View File

@ -0,0 +1,62 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 用户信息DTO含角色组织等关联信息
*/
@Data
public class UserDTO implements Serializable {
private Long id;
private String username;
/** 密码(仅内部使用,不返回前端) */
private transient String password;
private String realName;
private String nickname;
private Integer gender;
private String phone;
private String email;
private String avatar;
private Long orgId;
/** 组织名称 */
private String orgName;
private Integer status;
private Integer accountType;
private Long lastLoginAt;
private String lastLoginIp;
private Integer failCount;
private Long lockUntil;
private String remark;
private Long projectId;
private Long createdAt;
private Long updatedAt;
/** 角色列表 */
private java.util.List<RoleDTO> roles;
/** 可访问项目列表 */
private java.util.List<ProjectInfoDTO> projects;
}

View File

@ -0,0 +1,46 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 当前用户信息含角色和权限
*/
@Data
public class UserInfoDTO implements Serializable {
private Long id;
private String username;
private String realName;
private String nickname;
private Integer gender;
private String phone;
private String email;
private String avatar;
private Long orgId;
private String orgName;
private Long projectId;
private String projectName;
/** 角色列表 */
private List<RoleDTO> roles;
/** 权限编码列表 */
private List<String> permissions;
/** 菜单树 */
private List<PermissionDTO> menus;
}

View File

@ -0,0 +1,30 @@
package com.pms.auth.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 用户列表查询参数
*/
@Data
public class UserQueryRequest implements Serializable {
/** 页码默认1 */
private Integer page = 1;
/** 每页条数默认20 */
private Integer size = 20;
/** 关键字(用户名/姓名/手机号模糊匹配) */
private String keyword;
/** 组织ID */
private Long orgId;
/** 状态0禁用 1启用 */
private Integer status;
/** 角色 ID筛选拥有该角色的用户 */
private Long roleId;
}

View File

@ -0,0 +1,46 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 创建用户请求
*/
@Data
public class UserSaveRequest implements Serializable {
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 64, message = "用户名长度4-64位")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
@NotBlank(message = "真实姓名不能为空")
@Size(max = 64, message = "真实姓名最长64位")
private String realName;
private String nickname;
private Integer gender;
@Size(max = 20, message = "手机号最长20位")
private String phone;
@Size(max = 128, message = "邮箱最长128位")
private String email;
private Long orgId;
@Size(max = 255, message = "备注最长255位")
private String remark;
/** 角色 ID 列表 */
private List<Long> roleIds;
/** 可访问项目 ID 列表 */
private List<Long> projectIds;
}

View File

@ -0,0 +1,34 @@
package com.pms.auth.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 用户更新请求
*/
@Data
public class UserUpdateRequest implements Serializable {
@Size(max = 64, message = "真实姓名最长64位")
private String realName;
@Size(max = 64, message = "昵称最长64位")
private String nickname;
private Integer gender;
@Size(max = 20, message = "手机号最长20位")
private String phone;
@Size(max = 128, message = "邮箱最长128位")
private String email;
private Long orgId;
private Integer status;
@Size(max = 255, message = "备注最长255位")
private String remark;
}

View File

@ -0,0 +1,39 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 组织架构实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_org")
public class Org extends BaseEntity {
/** 父组织ID0表示根节点 */
private Long parentId;
/** 组织编码 */
private String orgCode;
/** 组织名称 */
private String orgName;
/** 类型1公司 2部门 3小组 4项目部 */
private Integer orgType;
/** 负责人用户ID */
private Long leaderId;
/** 联系电话 */
private String phone;
/** 排序号 */
private Integer sort;
/** 状态0禁用 1启用 */
private Integer status;
}

View File

@ -0,0 +1,51 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 权限实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_permission")
public class Permission extends BaseEntity {
/** 父权限ID0表示根节点 */
private Long parentId;
/** 权限编码 */
private String permCode;
/** 权限名称 */
private String permName;
/** 类型1菜单 2按钮 3接口 */
private Integer permType;
/** 前端路由路径 */
private String path;
/** 前端组件路径 */
private String component;
/** 菜单图标 */
private String icon;
/** 接口方法GET/POST/PUT/DELETE */
private String apiMethod;
/** 接口路径 */
private String apiPath;
/** 排序号 */
private Integer sort;
/** 是否隐藏0否 1是 */
private Integer isHidden;
/** 状态0禁用 1启用 */
private Integer status;
}

View File

@ -0,0 +1,27 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 项目用户关联实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_project_user")
public class ProjectUser extends BaseEntity {
/** 用户ID */
private Long userId;
/** 组织ID */
private Long orgId;
/** 是否默认项目0否 1是 */
private Integer isDefault;
/** 状态0禁用 1启用 */
private Integer status;
}

View File

@ -0,0 +1,33 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_role")
public class Role extends BaseEntity {
/** 角色编码 */
private String roleCode;
/** 角色名称 */
private String roleName;
/** 数据范围1全部 2本组织 3本组织及下级 4本人 5自定义 */
private Integer dataScope;
/** 排序号 */
private Integer sort;
/** 状态0禁用 1启用 */
private Integer status;
/** 备注 */
private String remark;
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色权限关联实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_role_permission")
public class RolePermission extends BaseEntity {
/** 角色ID */
private Long roleId;
/** 权限ID */
private Long permissionId;
}

View File

@ -0,0 +1,63 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_user")
public class User extends BaseEntity {
/** 登录账号 */
private String username;
/** BCrypt加密密码 */
private String password;
/** 真实姓名 */
private String realName;
/** 昵称 */
private String nickname;
/** 性别0未知 1男 2女 */
private Integer gender;
/** 手机号 */
private String phone;
/** 邮箱 */
private String email;
/** 头像URL */
private String avatar;
/** 所属组织ID */
private Long orgId;
/** 状态0禁用 1启用 */
private Integer status;
/** 账号类型1普通 2系统管理员 */
private Integer accountType;
/** 最后登录时间(时间戳) */
private Long lastLoginAt;
/** 最后登录IP */
private String lastLoginIp;
/** 连续登录失败次数 */
private Integer failCount;
/** 锁定截止时间(时间戳) */
private Long lockUntil;
/** 备注 */
private String remark;
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户角色关联实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_user_role")
public class UserRole extends BaseEntity {
/** 用户ID */
private Long userId;
/** 角色ID */
private Long roleId;
}

View File

@ -0,0 +1,39 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.entity.Org;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 组织架构Mapper
*/
@Mapper
public interface OrgMapper extends BaseMapper<Org> {
/**
* 查询全部组织按排序号排序
*
* @param projectId 项目ID
* @return 组织列表
*/
List<Org> selectAllOrgs(@Param("projectId") Long projectId);
/**
* 统计子节点数量
*
* @param parentId 父节点ID
* @return 子节点数量
*/
int countChildren(@Param("parentId") Long parentId);
/**
* 统计组织下的用户数
*
* @param orgId 组织ID
* @return 用户数
*/
int countUsersByOrgId(@Param("orgId") Long orgId);
}

View File

@ -0,0 +1,47 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.entity.Permission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 权限Mapper
*/
@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {
/**
* 根据用户ID查询权限列表
*
* @param userId 用户ID
* @return 权限列表
*/
List<Permission> selectPermissionsByUserId(@Param("userId") Long userId);
/**
* 根据角色ID查询权限列表
*
* @param roleId 角色 ID
* @return 权限列表
*/
List<Permission> selectPermissionsByRoleId(@Param("roleId") Long roleId);
/**
* 根据角色ID查询权限编码列表
*
* @param roleId 角色 ID
* @return 权限编码列表
*/
List<String> selectPermissionCodesByRoleId(@Param("roleId") Long roleId);
/**
* 根据角色ID查询权限ID列表
*
* @param roleId 角色 ID
* @return 权限ID列表
*/
List<Long> selectPermissionIdsByRoleId(@Param("roleId") Long roleId);
}

View File

@ -0,0 +1,31 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.entity.ProjectUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 项目用户关联Mapper
*/
@Mapper
public interface ProjectUserMapper extends BaseMapper<ProjectUser> {
/**
* 根据用户ID查询关联项目列表
*
* @param userId 用户ID
* @return 项目用户关联列表
*/
List<ProjectUser> selectByUserId(@Param("userId") Long userId);
/**
* 根据用户ID删除所有项目关联
*
* @param userId 用户ID
* @return 删除条数
*/
int deleteByUserId(@Param("userId") Long userId);
}

View File

@ -0,0 +1,48 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.dto.RoleDTO;
import com.pms.auth.entity.Role;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 角色Mapper
*/
@Mapper
public interface RoleMapper extends BaseMapper<Role> {
/**
* 查询角色列表含关联用户数
*
* @param projectId 项目ID
* @return 角色列表
*/
List<RoleDTO> selectRoleListWithUserCount(@Param("projectId") Long projectId);
/**
* 根据用户ID查询角色列表
*
* @param userId 用户ID
* @return 角色列表
*/
List<Role> selectRolesByUserId(@Param("userId") Long userId);
/**
* 根据用户ID查询角色编码列表
*
* @param userId 用户ID
* @return 角色编码列表
*/
List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
/**
* 统计角色关联的用户数
*
* @param roleId 角色 ID
* @return 用户数
*/
int countUsersByRoleId(@Param("roleId") Long roleId);
}

View File

@ -0,0 +1,21 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.entity.RolePermission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 角色权限关联Mapper
*/
@Mapper
public interface RolePermissionMapper extends BaseMapper<RolePermission> {
/**
* 根据角色ID删除所有权限关联物理删除用于覆盖式分配
*
* @param roleId 角色 ID
* @return 删除条数
*/
int deleteByRoleId(@Param("roleId") Long roleId);
}

View File

@ -0,0 +1,67 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.pms.auth.dto.UserDTO;
import com.pms.auth.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户Mapper
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户含角色信息
*
* @param username 用户名
* @return 用户信息含角色列表
*/
UserDTO selectUserWithRoles(@Param("username") String username);
/**
* 根据用户ID查询用户含角色信息
*
* @param userId 用户ID
* @return 用户信息含角色列表
*/
UserDTO selectUserWithRolesById(@Param("userId") Long userId);
/**
* 分页查询用户列表含角色组织名称
*
* @param page 分页参数
* @param keyword 关键字用户名/姓名/手机号
* @param orgId 组织ID
* @param status 状态
* @param roleId 角色ID
* @param projectId 项目ID
* @return 分页结果
*/
IPage<UserDTO> selectUserPage(IPage<UserDTO> page,
@Param("keyword") String keyword,
@Param("orgId") Long orgId,
@Param("status") Integer status,
@Param("roleId") Long roleId,
@Param("projectId") Long projectId);
/**
* 获取用户权限编码列表
*
* @param userId 用户ID
* @return 权限编码列表
*/
List<String> selectPermissionCodesByUserId(@Param("userId") Long userId);
/**
* 获取用户角色编码列表
*
* @param userId 用户ID
* @return 角色编码列表
*/
List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
}

View File

@ -0,0 +1,31 @@
package com.pms.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pms.auth.entity.UserRole;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户角色关联Mapper
*/
@Mapper
public interface UserRoleMapper extends BaseMapper<UserRole> {
/**
* 根据用户ID删除所有角色关联物理删除用于覆盖式分配
*
* @param userId 用户ID
* @return 删除条数
*/
int deleteByUserId(@Param("userId") Long userId);
/**
* 根据角色ID查询关联的用户ID列表
*
* @param roleId 角色 ID
* @return 用户ID列表
*/
List<Long> selectUserIdsByRoleId(@Param("roleId") Long roleId);
}

View File

@ -0,0 +1,49 @@
package com.pms.auth.service;
import com.pms.auth.dto.*;
/**
* 认证服务接口
*/
public interface AuthService {
/**
* 登录
*/
LoginResponse login(LoginRequest request, String clientIp);
/**
* 注销
*/
void logout(String accessToken, String refreshToken);
/**
* 刷新令牌
*/
TokenRefreshResponse refreshToken(String refreshToken);
/**
* 获取验证码
*/
CaptchaResponse getCaptcha();
/**
* 修改密码
*/
void changePassword(Long userId, ChangePasswordRequest request);
/**
* 重置密码
*/
void resetPassword(ResetPasswordRequest request);
/**
* 获取当前用户信息
*/
UserInfoDTO getCurrentUserInfo();
/**
* 权限检查
*/
boolean checkPermission(Long userId, String permissionCode, Long projectId);
}

View File

@ -0,0 +1,32 @@
package com.pms.auth.service;
import com.pms.auth.dto.OrgDTO;
import com.pms.auth.dto.OrgSaveRequest;
import java.util.List;
/**
* 组织管理服务接口
*/
public interface OrgService {
/**
* 组织列表树形
*/
List<OrgDTO> list(Long parentId, Integer status, Long projectId);
/**
* 创建组织
*/
Long create(OrgSaveRequest request);
/**
* 更新组织
*/
void update(Long id, OrgSaveRequest request);
/**
* 删除组织
*/
void delete(Long id);
}

View File

@ -0,0 +1,19 @@
package com.pms.auth.service;
import com.pms.auth.dto.PermissionDTO;
import com.pms.auth.dto.PermissionTreeResult;
/**
* 权限管理服务接口
*/
public interface PermissionService {
/**
* 获取权限树
*
* @param roleId 角色 ID可选传入则返回已勾选权限ID列表
* @param permType 权限类型过滤可选
* @return 权限树结果
*/
PermissionTreeResult getPermissionTree(Long roleId, Integer permType);
}

View File

@ -0,0 +1,41 @@
package com.pms.auth.service;
import com.pms.auth.dto.*;
import java.util.List;
/**
* 角色管理服务接口
*/
public interface RoleService {
/**
* 角色列表
*/
List<RoleDTO> list(String keyword, Integer status, Long projectId, boolean all);
/**
* 角色详情含权限ID列表
*/
RoleDTO getById(Long id);
/**
* 创建角色
*/
Long create(RoleSaveRequest request);
/**
* 更新角色
*/
void update(Long id, RoleSaveRequest request);
/**
* 删除角色
*/
void delete(Long id);
/**
* 分配权限
*/
void assignPermissions(Long roleId, AssignPermissionsRequest request);
}

View File

@ -0,0 +1,45 @@
package com.pms.auth.service;
import com.pms.auth.dto.*;
import com.pms.common.response.PageResult;
/**
* 用户管理服务接口
*/
public interface UserService {
/**
* 分页查询用户列表
*/
PageResult<UserDTO> page(UserQueryRequest request);
/**
* 用户详情
*/
UserDTO getById(Long id);
/**
* 创建用户
*/
Long create(UserSaveRequest request);
/**
* 更新用户
*/
void update(Long id, UserUpdateRequest request);
/**
* 删除用户逻辑删除
*/
void delete(Long id);
/**
* 更新用户状态
*/
void updateStatus(Long id, Integer status);
/**
* 分配角色
*/
void assignRoles(Long userId, AssignRolesRequest request);
}

View File

@ -0,0 +1,617 @@
package com.pms.auth.service.impl;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.auth.constant.AuthConstants;
import com.pms.auth.dto.*;
import com.pms.auth.entity.Org;
import com.pms.auth.entity.Permission;
import com.pms.auth.entity.Role;
import com.pms.auth.entity.User;
import com.pms.auth.mapper.*;
import com.pms.auth.service.AuthService;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.security.JwtUtils;
import com.pms.common.security.UserContext;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.util.*;
import java.util.Base64;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 认证服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
private final OrgMapper orgMapper;
private final StringRedisTemplate stringRedisTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final JwtUtils jwtUtils;
private final PasswordEncoder passwordEncoder;
@Value("${captcha.debug:false}")
private boolean captchaDebug;
private static final int CAPTCHA_WIDTH = 120;
private static final int CAPTCHA_HEIGHT = 40;
@Override
@Transactional(rollbackFor = Exception.class)
public LoginResponse login(LoginRequest request, String clientIp) {
// 1. 验证码校验
validateCaptcha(request.getCaptchaKey(), request.getCaptchaCode());
// 2. 查询用户
UserDTO userDTO = userMapper.selectUserWithRoles(request.getUsername());
if (userDTO == null) {
throw new BusinessException(ErrorCode.USERNAME_OR_PASSWORD_ERROR);
}
User user = getUserEntity(userDTO.getId());
// 3. 账号状态检查
if (user.getStatus() == 0) {
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
}
// 4. 检查锁定
checkAccountLock(user);
// 5. 密码验证
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
// 记录失败次数
incrementFailCount(user);
throw new BusinessException(ErrorCode.USERNAME_OR_PASSWORD_ERROR);
}
// 6. 登录成功重置失败次数
resetFailCount(user, clientIp);
// 7. 收集角色和权限
List<String> roleCodes = collectRoleCodes(userDTO);
List<String> permissionCodes = collectPermissionCodes(user.getId());
// 8. 确定 projectId
Long projectId = request.getProjectId() != null ? request.getProjectId() : user.getProjectId();
// 9. 生成 JWT 令牌对
Map<String, Object> accessClaims = buildAccessClaims(user, roleCodes, permissionCodes, projectId);
Map<String, Object> refreshClaims = buildRefreshClaims(user);
String accessToken = jwtUtils.signAccessToken(accessClaims);
String refreshToken = jwtUtils.signRefreshToken(refreshClaims);
// 10. 构建响应
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(jwtUtils.getAccessTokenExpire());
UserInfoDTO userInfo = new UserInfoDTO();
userInfo.setId(user.getId());
userInfo.setUsername(user.getUsername());
userInfo.setRealName(user.getRealName());
userInfo.setNickname(user.getNickname());
userInfo.setAvatar(user.getAvatar());
userInfo.setProjectId(projectId);
response.setUserInfo(userInfo);
log.info("用户登录成功: userId={}, username={}", user.getId(), user.getUsername());
return response;
}
@Override
public void logout(String accessToken, String refreshToken) {
// accessToken 加入黑名单
if (accessToken != null && !accessToken.isBlank()) {
blacklistToken(accessToken, AuthConstants.JWT_BLACKLIST_ACCESS);
}
// refreshToken 加入黑名单
if (refreshToken != null && !refreshToken.isBlank()) {
blacklistToken(refreshToken, AuthConstants.JWT_BLACKLIST_REFRESH);
}
log.info("用户注销成功");
}
@Override
public TokenRefreshResponse refreshToken(String refreshToken) {
// 1. 验签
Claims claims;
try {
claims = jwtUtils.verify(refreshToken);
} catch (Exception e) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
}
// 2. 检查令牌类型
String tokenType = claims.get("type", String.class);
if (!JwtUtils.TOKEN_TYPE_REFRESH.equals(tokenType)) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
}
// 3. 检查黑名单
String jti = claims.getId();
if (jti != null) {
Boolean isBlacklisted = stringRedisTemplate.hasKey(AuthConstants.JWT_BLACKLIST_REFRESH + jti);
if (Boolean.TRUE.equals(isBlacklisted)) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
}
}
// 4. 获取用户ID
Long userId = Long.parseLong(claims.getSubject());
UserDTO userDTO = userMapper.selectUserWithRolesById(userId);
if (userDTO == null) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
User user = getUserEntity(userId);
if (user.getStatus() == 0) {
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
}
// 5. 重新签发令牌对
List<String> roleCodes = collectRoleCodes(userDTO);
List<String> permissionCodes = collectPermissionCodes(userId);
Long projectId = user.getProjectId();
Map<String, Object> accessClaims = buildAccessClaims(user, roleCodes, permissionCodes, projectId);
Map<String, Object> refreshClaims = buildRefreshClaims(user);
String newAccessToken = jwtUtils.signAccessToken(accessClaims);
String newRefreshToken = jwtUtils.signRefreshToken(refreshClaims);
// 6. refreshToken 加入黑名单
blacklistToken(refreshToken, AuthConstants.JWT_BLACKLIST_REFRESH);
TokenRefreshResponse response = new TokenRefreshResponse();
response.setAccessToken(newAccessToken);
response.setRefreshToken(newRefreshToken);
response.setExpiresIn(jwtUtils.getAccessTokenExpire());
log.info("令牌刷新成功: userId={}", userId);
return response;
}
@Override
public CaptchaResponse getCaptcha() {
// 生成验证码
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(CAPTCHA_WIDTH, CAPTCHA_HEIGHT, 4, 30);
String code = captcha.getCode();
// UUID 作为 key
String key = IdUtil.fastSimpleUUID();
// 存入 Redis5分钟过期
stringRedisTemplate.opsForValue().set(
AuthConstants.CAPTCHA_KEY + key,
code,
AuthConstants.CAPTCHA_EXPIRE,
TimeUnit.SECONDS);
// 转为 Base64
ByteArrayOutputStream bos = new ByteArrayOutputStream();
captcha.write(bos);
String base64Img = Base64.getEncoder().encodeToString(bos.toByteArray());
CaptchaResponse response = new CaptchaResponse();
response.setCaptchaKey(key);
response.setCaptchaImg(base64Img);
response.setExpireSeconds(AuthConstants.CAPTCHA_EXPIRE);
return response;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void changePassword(Long userId, ChangePasswordRequest request) {
User user = getUserEntity(userId);
// 验证原密码
if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) {
throw new BusinessException(ErrorCode.USERNAME_OR_PASSWORD_ERROR, "原密码错误");
}
// 更新密码
User update = new User();
update.setId(userId);
update.setPassword(passwordEncoder.encode(request.getNewPassword()));
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(userId);
userMapper.updateById(update);
// 清除用户缓存
evictUserCache(userId);
log.info("用户修改密码成功: userId={}", userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resetPassword(ResetPasswordRequest request) {
// 校验当前用户是否有重置密码权限
UserContext.CurrentUser currentUser = UserContext.get();
String roles = currentUser != null ? currentUser.getRoles() : null;
if (roles == null || (!roles.contains("ROLE_ADMIN") && !roles.contains("ROLE_PROPERTY"))) {
throw new BusinessException(ErrorCode.NO_PERMISSION, "无权限重置密码");
}
User user = getUserEntity(request.getUserId());
// 系统管理员账号不可重置
if (user.getAccountType() != null && user.getAccountType() == 2) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "系统管理员账号不可重置密码");
}
// 使用新密码或默认密码
String newPassword = request.getNewPassword();
if (newPassword == null || newPassword.isBlank()) {
newPassword = AuthConstants.DEFAULT_PASSWORD;
}
User update = new User();
update.setId(request.getUserId());
update.setPassword(passwordEncoder.encode(newPassword));
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(UserContext.getUserId());
userMapper.updateById(update);
// 清除用户缓存
evictUserCache(request.getUserId());
log.info("管理员重置用户密码: userId={}, operatorId={}", request.getUserId(), UserContext.getUserId());
}
@Override
public UserInfoDTO getCurrentUserInfo() {
Long userId = UserContext.getUserId();
if (userId == null) {
throw new BusinessException(com.pms.common.response.ResultCode.UNAUTHORIZED);
}
return buildUserInfo(userId);
}
@Override
public boolean checkPermission(Long userId, String permissionCode, Long projectId) {
List<String> permissions = userMapper.selectPermissionCodesByUserId(userId);
if (permissions == null || permissions.isEmpty()) {
return false;
}
// 超级管理员拥有全部权限
if (permissions.contains("*")) {
return true;
}
return permissions.contains(permissionCode);
}
// ====== 私有方法 ======
/**
* 验证验证码
*/
private void validateCaptcha(String captchaKey, String captchaCode) {
// 调试模式跳过验证码校验
if (captchaDebug) {
log.warn("验证码调试模式已开启,跳过校验");
return;
}
if (captchaKey == null || captchaCode == null) {
throw new BusinessException(ErrorCode.CAPTCHA_ERROR, "验证码不能为空");
}
String key = AuthConstants.CAPTCHA_KEY + captchaKey;
String cachedCode = stringRedisTemplate.opsForValue().get(key);
if (cachedCode == null) {
throw new BusinessException(ErrorCode.CAPTCHA_ERROR, "验证码已过期");
}
if (!cachedCode.equalsIgnoreCase(captchaCode)) {
throw new BusinessException(ErrorCode.CAPTCHA_ERROR);
}
// 验证成功后删除验证码
stringRedisTemplate.delete(key);
}
/**
* 检查账号是否被锁定
* 先检查 Redis 锁定状态再检查数据库 lockUntil
*/
private void checkAccountLock(User user) {
// 1. 检查 Redis 锁定状态
String lockKey = AuthConstants.LOGIN_LOCK_KEY + user.getUsername();
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(lockKey))) {
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定请15分钟后重试");
}
// 2. 检查数据库 lockUntil
if (user.getLockUntil() != null && user.getLockUntil() > System.currentTimeMillis()) {
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
}
/**
* 递增登录失败次数Redis INCR 原子操作
*/
private void incrementFailCount(User user) {
String failKey = AuthConstants.LOGIN_FAIL_KEY + user.getUsername();
Long count = stringRedisTemplate.opsForValue().increment(failKey);
// 首次失败时设置过期时间
if (count != null && count == 1) {
stringRedisTemplate.expire(failKey, AuthConstants.LOCK_DURATION, TimeUnit.SECONDS);
}
User update = new User();
update.setId(user.getId());
update.setFailCount(count != null ? count.intValue() : user.getFailCount() + 1);
update.setUpdatedAt(System.currentTimeMillis());
// 达到最大失败次数时锁定账号
if (count != null && count >= AuthConstants.MAX_FAIL_COUNT) {
// 设置 Redis 锁定状态
String lockKey = AuthConstants.LOGIN_LOCK_KEY + user.getUsername();
stringRedisTemplate.opsForValue().set(lockKey, "1", AuthConstants.LOCK_DURATION, TimeUnit.SECONDS);
// 更新数据库锁定状态
update.setLockUntil(System.currentTimeMillis() + AuthConstants.LOCK_DURATION * 1000);
}
userMapper.updateById(update);
log.warn("登录失败: username={}, failCount={}", user.getUsername(), count);
}
/**
* 重置失败次数并记录登录信息
* 登录成功后清除 Redis 失败计数和锁定状态
*/
private void resetFailCount(User user, String clientIp) {
// 清除 Redis 失败计数和锁定状态
stringRedisTemplate.delete(AuthConstants.LOGIN_FAIL_KEY + user.getUsername());
stringRedisTemplate.delete(AuthConstants.LOGIN_LOCK_KEY + user.getUsername());
User update = new User();
update.setId(user.getId());
update.setFailCount(0);
update.setLockUntil(null);
update.setLastLoginAt(System.currentTimeMillis());
update.setLastLoginIp(clientIp);
update.setUpdatedAt(System.currentTimeMillis());
userMapper.updateById(update);
}
/**
* 获取用户实体 Redis 缓存
* <p>
* 缓存策略
* - 命中缓存直接返回
* - 未命中查数据库空值缓存60秒防穿透
* - 写入缓存时 TTL 随机偏移防雪崩
*/
private User getUserEntity(Long userId) {
String cacheKey = AuthConstants.USER_INFO_CACHE_KEY + userId;
// 1. 查缓存
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if ("NULL".equals(cached)) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
return (User) cached;
}
// 2. 查数据库
User user = userMapper.selectById(userId);
if (user == null) {
// 防穿透缓存空值
redisTemplate.opsForValue().set(cacheKey, "NULL",
AuthConstants.NULL_CACHE_TTL, TimeUnit.SECONDS);
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
// 3. 写缓存TTL 随机偏移防雪崩
int ttl = AuthConstants.USER_CACHE_TTL + ThreadLocalRandom.current().nextInt(AuthConstants.USER_CACHE_TTL_JITTER);
redisTemplate.opsForValue().set(cacheKey, user, ttl, TimeUnit.SECONDS);
return user;
}
/**
* 清除用户信息缓存
*/
private void evictUserCache(Long userId) {
redisTemplate.delete(AuthConstants.USER_INFO_CACHE_KEY + userId);
}
/**
* 收集角色编码
*/
private List<String> collectRoleCodes(UserDTO userDTO) {
if (userDTO.getRoles() == null || userDTO.getRoles().isEmpty()) {
return Collections.emptyList();
}
return userDTO.getRoles().stream()
.map(RoleDTO::getRoleCode)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* 收集权限编码
*/
private List<String> collectPermissionCodes(Long userId) {
List<String> codes = userMapper.selectPermissionCodesByUserId(userId);
return codes != null ? codes : Collections.emptyList();
}
/**
* 构建 AccessToken claims
*/
private Map<String, Object> buildAccessClaims(User user, List<String> roleCodes,
List<String> permissionCodes, Long projectId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
claims.put("projectId", projectId);
claims.put("orgId", user.getOrgId());
claims.put("roles", roleCodes);
claims.put("permissions", permissionCodes);
claims.put("jti", IdUtil.fastSimpleUUID());
return claims;
}
/**
* 构建 RefreshToken claims
*/
private Map<String, Object> buildRefreshClaims(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
claims.put("jti", IdUtil.fastSimpleUUID());
return claims;
}
/**
* 将令牌加入黑名单
*/
private void blacklistToken(String token, String keyPrefix) {
try {
Claims claims = jwtUtils.verify(token);
String jti = claims.getId();
if (jti == null) {
jti = claims.get("jti", String.class);
}
if (jti != null) {
long expiration = claims.getExpiration().getTime();
long ttl = expiration - System.currentTimeMillis();
if (ttl > 0) {
stringRedisTemplate.opsForValue().set(
keyPrefix + jti,
"1",
ttl,
TimeUnit.MILLISECONDS);
}
}
} catch (Exception e) {
log.warn("令牌加入黑名单失败(可能已过期): {}", e.getMessage());
}
}
/**
* 构建用户信息 DTO
*/
private UserInfoDTO buildUserInfo(Long userId) {
UserDTO userDTO = userMapper.selectUserWithRolesById(userId);
if (userDTO == null) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
UserInfoDTO userInfo = new UserInfoDTO();
userInfo.setId(userDTO.getId());
userInfo.setUsername(userDTO.getUsername());
userInfo.setRealName(userDTO.getRealName());
userInfo.setNickname(userDTO.getNickname());
userInfo.setGender(userDTO.getGender());
userInfo.setPhone(userDTO.getPhone());
userInfo.setEmail(userDTO.getEmail());
userInfo.setAvatar(userDTO.getAvatar());
userInfo.setOrgId(userDTO.getOrgId());
userInfo.setOrgName(userDTO.getOrgName());
userInfo.setProjectId(UserContext.getProjectId() != null ? UserContext.getProjectId() : userDTO.getProjectId());
// 角色列表
if (userDTO.getRoles() != null) {
userInfo.setRoles(userDTO.getRoles());
}
// 权限编码列表
List<String> permissionCodes = collectPermissionCodes(userId);
userInfo.setPermissions(permissionCodes);
// 菜单树
List<Permission> permissions = permissionMapper.selectPermissionsByUserId(userId);
List<PermissionDTO> menuTree = buildPermissionTree(permissions, 1);
userInfo.setMenus(menuTree);
return userInfo;
}
/**
* 构建权限树仅菜单类型
*/
private List<PermissionDTO> buildPermissionTree(List<Permission> permissions, int permType) {
if (permissions == null || permissions.isEmpty()) {
return Collections.emptyList();
}
// 过滤指定类型
List<Permission> filtered = permissions.stream()
.filter(p -> permType == 0 || (p.getPermType() != null && p.getPermType() == permType))
.collect(Collectors.toList());
// parentId 分组
Map<Long, List<PermissionDTO>> parentMap = new LinkedHashMap<>();
List<PermissionDTO> roots = new ArrayList<>();
for (Permission p : filtered) {
PermissionDTO dto = toPermissionDTO(p);
Long parentId = p.getParentId() != null ? p.getParentId() : 0L;
parentMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(dto);
}
// 递归构建子节点
for (PermissionDTO root : parentMap.getOrDefault(0L, Collections.emptyList())) {
fillChildren(root, parentMap);
roots.add(root);
}
return roots;
}
/**
* 递归填充子节点
*/
private void fillChildren(PermissionDTO parent, Map<Long, List<PermissionDTO>> parentMap) {
List<PermissionDTO> children = parentMap.getOrDefault(parent.getId(), Collections.emptyList());
parent.setChildren(children);
for (PermissionDTO child : children) {
fillChildren(child, parentMap);
}
}
/**
* Permission PermissionDTO
*/
private PermissionDTO toPermissionDTO(Permission p) {
PermissionDTO dto = new PermissionDTO();
dto.setId(p.getId());
dto.setParentId(p.getParentId());
dto.setPermCode(p.getPermCode());
dto.setPermName(p.getPermName());
dto.setPermType(p.getPermType());
dto.setPath(p.getPath());
dto.setComponent(p.getComponent());
dto.setIcon(p.getIcon());
dto.setApiMethod(p.getApiMethod());
dto.setApiPath(p.getApiPath());
dto.setSort(p.getSort());
dto.setIsHidden(p.getIsHidden());
dto.setStatus(p.getStatus());
return dto;
}
}

View File

@ -0,0 +1,228 @@
package com.pms.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.auth.dto.OrgDTO;
import com.pms.auth.dto.OrgSaveRequest;
import com.pms.auth.entity.Org;
import com.pms.auth.mapper.OrgMapper;
import com.pms.auth.service.OrgService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 组织管理服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrgServiceImpl implements OrgService {
private final OrgMapper orgMapper;
@Override
public List<OrgDTO> list(Long parentId, Integer status, Long projectId) {
Long currentProjectId = projectId != null ? projectId : UserContext.getProjectId();
LambdaQueryWrapper<Org> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Org::getDeleted, 0);
if (status != null) {
wrapper.eq(Org::getStatus, status);
}
if (currentProjectId != null) {
wrapper.and(w -> w.eq(Org::getProjectId, currentProjectId).or().isNull(Org::getProjectId));
}
wrapper.orderByAsc(Org::getSort);
List<Org> orgs = orgMapper.selectList(wrapper);
// 构建树
List<OrgDTO> tree = buildTree(orgs);
// 如果指定了 parentId返回该节点的子树
if (parentId != null) {
tree = findSubTree(tree, parentId);
}
return tree;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(OrgSaveRequest request) {
// 检查组织编码是否已存在
LambdaQueryWrapper<Org> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Org::getOrgCode, request.getOrgCode());
Long existCount = orgMapper.selectCount(wrapper);
if (existCount > 0) {
throw new BusinessException(ErrorCode.RESOURCE_EXISTS, "组织编码已存在");
}
// 检查父节点是否存在
if (request.getParentId() != null && request.getParentId() != 0) {
Org parent = orgMapper.selectById(request.getParentId());
if (parent == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "父节点不存在");
}
}
Org org = new Org();
org.setParentId(request.getParentId() != null ? request.getParentId() : 0L);
org.setOrgCode(request.getOrgCode());
org.setOrgName(request.getOrgName());
org.setOrgType(request.getOrgType() != null ? request.getOrgType() : 1);
org.setLeaderId(request.getLeaderId());
org.setPhone(request.getPhone());
org.setSort(request.getSort() != null ? request.getSort() : 0);
org.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
org.setProjectId(UserContext.getProjectId());
long now = System.currentTimeMillis();
org.setCreatedAt(now);
org.setUpdatedAt(now);
org.setCreatedBy(UserContext.getUserId());
org.setUpdatedBy(UserContext.getUserId());
orgMapper.insert(org);
log.info("创建组织成功: orgId={}, orgCode={}", org.getId(), org.getOrgCode());
return org.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(Long id, OrgSaveRequest request) {
Org org = getOrgEntity(id);
Org update = new Org();
update.setId(id);
if (StringUtils.hasText(request.getOrgName())) {
update.setOrgName(request.getOrgName());
}
if (request.getOrgType() != null) {
update.setOrgType(request.getOrgType());
}
if (request.getLeaderId() != null) {
update.setLeaderId(request.getLeaderId());
}
update.setPhone(request.getPhone());
if (request.getSort() != null) {
update.setSort(request.getSort());
}
if (request.getStatus() != null) {
update.setStatus(request.getStatus());
}
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(UserContext.getUserId());
orgMapper.updateById(update);
log.info("更新组织成功: orgId={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Org org = getOrgEntity(id);
// 检查子节点
int childCount = orgMapper.countChildren(id);
if (childCount > 0) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "存在子节点,禁止删除");
}
// 检查关联用户
int userCount = orgMapper.countUsersByOrgId(id);
if (userCount > 0) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "存在关联用户,禁止删除");
}
orgMapper.deleteById(id);
log.info("删除组织成功: orgId={}", id);
}
// ====== 私有方法 ======
private Org getOrgEntity(Long id) {
Org org = orgMapper.selectById(id);
if (org == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "组织不存在");
}
return org;
}
/**
* 构建组织树
*/
private List<OrgDTO> buildTree(List<Org> orgs) {
if (orgs == null || orgs.isEmpty()) {
return Collections.emptyList();
}
Map<Long, List<OrgDTO>> parentMap = new LinkedHashMap<>();
for (Org org : orgs) {
OrgDTO dto = toDTO(org);
Long parentId = org.getParentId() != null ? org.getParentId() : 0L;
parentMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(dto);
}
List<OrgDTO> roots = parentMap.getOrDefault(0L, new ArrayList<>());
for (OrgDTO root : roots) {
fillChildren(root, parentMap);
}
return roots;
}
/**
* 递归填充子节点
*/
private void fillChildren(OrgDTO parent, Map<Long, List<OrgDTO>> parentMap) {
List<OrgDTO> children = parentMap.getOrDefault(parent.getId(), new ArrayList<>());
parent.setChildren(children);
for (OrgDTO child : children) {
fillChildren(child, parentMap);
}
}
/**
* 在树中查找指定节点的子树
*/
private List<OrgDTO> findSubTree(List<OrgDTO> tree, Long parentId) {
for (OrgDTO node : tree) {
if (node.getId().equals(parentId)) {
return node.getChildren() != null ? node.getChildren() : Collections.emptyList();
}
if (node.getChildren() != null) {
List<OrgDTO> found = findSubTree(node.getChildren(), parentId);
if (!found.isEmpty()) {
return found;
}
}
}
return Collections.emptyList();
}
/**
* Org OrgDTO
*/
private OrgDTO toDTO(Org org) {
OrgDTO dto = new OrgDTO();
dto.setId(org.getId());
dto.setParentId(org.getParentId());
dto.setOrgCode(org.getOrgCode());
dto.setOrgName(org.getOrgName());
dto.setOrgType(org.getOrgType());
dto.setLeaderId(org.getLeaderId());
dto.setPhone(org.getPhone());
dto.setSort(org.getSort());
dto.setStatus(org.getStatus());
return dto;
}
}

View File

@ -0,0 +1,112 @@
package com.pms.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.auth.dto.PermissionDTO;
import com.pms.auth.dto.PermissionTreeResult;
import com.pms.auth.entity.Permission;
import com.pms.auth.mapper.PermissionMapper;
import com.pms.auth.service.PermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 权限管理服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PermissionServiceImpl implements PermissionService {
private final PermissionMapper permissionMapper;
@Override
public PermissionTreeResult getPermissionTree(Long roleId, Integer permType) {
// 查询全部权限
LambdaQueryWrapper<Permission> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Permission::getStatus, 1);
wrapper.orderByAsc(Permission::getSort);
List<Permission> allPermissions = permissionMapper.selectList(wrapper);
// 类型过滤
if (permType != null) {
allPermissions = allPermissions.stream()
.filter(p -> permType.equals(p.getPermType()))
.collect(Collectors.toList());
}
// 构建树
List<PermissionDTO> tree = buildTree(allPermissions);
PermissionTreeResult result = new PermissionTreeResult();
result.setTree(tree);
// 如果传了 roleId返回已勾选的权限ID列表
if (roleId != null) {
List<Long> checkedKeys = permissionMapper.selectPermissionIdsByRoleId(roleId);
result.setCheckedKeys(checkedKeys);
}
return result;
}
/**
* 构建权限树
*/
private List<PermissionDTO> buildTree(List<Permission> permissions) {
if (permissions == null || permissions.isEmpty()) {
return Collections.emptyList();
}
// parentId 分组
Map<Long, List<PermissionDTO>> parentMap = new LinkedHashMap<>();
for (Permission p : permissions) {
PermissionDTO dto = toDTO(p);
Long parentId = p.getParentId() != null ? p.getParentId() : 0L;
parentMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(dto);
}
// 构建根节点列表
List<PermissionDTO> roots = parentMap.getOrDefault(0L, new ArrayList<>());
for (PermissionDTO root : roots) {
fillChildren(root, parentMap);
}
return roots;
}
/**
* 递归填充子节点
*/
private void fillChildren(PermissionDTO parent, Map<Long, List<PermissionDTO>> parentMap) {
List<PermissionDTO> children = parentMap.getOrDefault(parent.getId(), new ArrayList<>());
parent.setChildren(children);
for (PermissionDTO child : children) {
fillChildren(child, parentMap);
}
}
/**
* Permission PermissionDTO
*/
private PermissionDTO toDTO(Permission p) {
PermissionDTO dto = new PermissionDTO();
dto.setId(p.getId());
dto.setParentId(p.getParentId());
dto.setPermCode(p.getPermCode());
dto.setPermName(p.getPermName());
dto.setPermType(p.getPermType());
dto.setPath(p.getPath());
dto.setComponent(p.getComponent());
dto.setIcon(p.getIcon());
dto.setApiMethod(p.getApiMethod());
dto.setApiPath(p.getApiPath());
dto.setSort(p.getSort());
dto.setIsHidden(p.getIsHidden());
dto.setStatus(p.getStatus());
return dto;
}
}

View File

@ -0,0 +1,214 @@
package com.pms.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.auth.dto.*;
import com.pms.auth.entity.Role;
import com.pms.auth.entity.RolePermission;
import com.pms.auth.mapper.PermissionMapper;
import com.pms.auth.mapper.RoleMapper;
import com.pms.auth.mapper.RolePermissionMapper;
import com.pms.auth.mapper.UserRoleMapper;
import com.pms.auth.service.RoleService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 角色管理服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoleServiceImpl implements RoleService {
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final PermissionMapper permissionMapper;
private final UserRoleMapper userRoleMapper;
@Override
public List<RoleDTO> list(String keyword, Integer status, Long projectId, boolean all) {
Long currentProjectId = projectId != null ? projectId : UserContext.getProjectId();
List<RoleDTO> roles = roleMapper.selectRoleListWithUserCount(currentProjectId);
// 关键字过滤
if (StringUtils.hasText(keyword)) {
roles = roles.stream()
.filter(r -> (r.getRoleCode() != null && r.getRoleCode().contains(keyword))
|| (r.getRoleName() != null && r.getRoleName().contains(keyword)))
.toList();
}
// 状态过滤
if (status != null) {
roles = roles.stream()
.filter(r -> status.equals(r.getStatus()))
.toList();
}
return roles;
}
@Override
public RoleDTO getById(Long id) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "角色不存在");
}
RoleDTO dto = new RoleDTO();
dto.setId(role.getId());
dto.setRoleCode(role.getRoleCode());
dto.setRoleName(role.getRoleName());
dto.setDataScope(role.getDataScope());
dto.setSort(role.getSort());
dto.setStatus(role.getStatus());
dto.setRemark(role.getRemark());
dto.setProjectId(role.getProjectId());
dto.setCreatedAt(role.getCreatedAt());
dto.setUpdatedAt(role.getUpdatedAt());
// 查询权限ID列表
List<Long> permissionIds = permissionMapper.selectPermissionIdsByRoleId(id);
dto.setPermissionIds(permissionIds);
return dto;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(RoleSaveRequest request) {
// 检查角色编码是否已存在
LambdaQueryWrapper<Role> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Role::getRoleCode, request.getRoleCode());
Long existCount = roleMapper.selectCount(wrapper);
if (existCount > 0) {
throw new BusinessException(ErrorCode.RESOURCE_EXISTS, "角色编码已存在");
}
Role role = new Role();
role.setRoleCode(request.getRoleCode());
role.setRoleName(request.getRoleName());
role.setDataScope(request.getDataScope() != null ? request.getDataScope() : 1);
role.setProjectId(request.getProjectId() != null ? request.getProjectId() : UserContext.getProjectId());
role.setSort(request.getSort() != null ? request.getSort() : 0);
role.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
role.setRemark(request.getRemark());
long now = System.currentTimeMillis();
role.setCreatedAt(now);
role.setUpdatedAt(now);
role.setCreatedBy(UserContext.getUserId());
role.setUpdatedBy(UserContext.getUserId());
roleMapper.insert(role);
// 分配权限
if (request.getPermissionIds() != null && !request.getPermissionIds().isEmpty()) {
assignRolePermissions(role.getId(), request.getPermissionIds());
}
log.info("创建角色成功: roleId={}, roleCode={}", role.getId(), role.getRoleCode());
return role.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(Long id, RoleSaveRequest request) {
Role role = getRoleEntity(id);
Role update = new Role();
update.setId(id);
if (StringUtils.hasText(request.getRoleName())) {
update.setRoleName(request.getRoleName());
}
if (request.getDataScope() != null) {
update.setDataScope(request.getDataScope());
}
if (request.getSort() != null) {
update.setSort(request.getSort());
}
if (request.getStatus() != null) {
update.setStatus(request.getStatus());
}
update.setRemark(request.getRemark());
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(UserContext.getUserId());
roleMapper.updateById(update);
log.info("更新角色成功: roleId={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Role role = getRoleEntity(id);
// 检查是否存在关联用户
int userCount = roleMapper.countUsersByRoleId(id);
if (userCount > 0) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "角色下存在关联用户,禁止删除");
}
// 逻辑删除角色
roleMapper.deleteById(id);
// 删除权限关联
rolePermissionMapper.deleteByRoleId(id);
log.info("删除角色成功: roleId={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void assignPermissions(Long roleId, AssignPermissionsRequest request) {
// 检查角色存在
getRoleEntity(roleId);
// 先删除旧的权限关联
rolePermissionMapper.deleteByRoleId(roleId);
// 再分配新权限
if (request.getPermissionIds() != null && !request.getPermissionIds().isEmpty()) {
assignRolePermissions(roleId, request.getPermissionIds());
}
log.info("分配权限成功: roleId={}, permissionIds={}", roleId, request.getPermissionIds());
}
// ====== 私有方法 ======
private Role getRoleEntity(Long id) {
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "角色不存在");
}
return role;
}
/**
* 分配角色权限
*/
private void assignRolePermissions(Long roleId, List<Long> permissionIds) {
long now = System.currentTimeMillis();
for (Long permissionId : permissionIds) {
RolePermission rp = new RolePermission();
rp.setRoleId(roleId);
rp.setPermissionId(permissionId);
rp.setProjectId(UserContext.getProjectId());
rp.setCreatedAt(now);
rp.setUpdatedAt(now);
rp.setCreatedBy(UserContext.getUserId());
rp.setUpdatedBy(UserContext.getUserId());
rolePermissionMapper.insert(rp);
}
}
}

View File

@ -0,0 +1,285 @@
package com.pms.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.auth.dto.*;
import com.pms.auth.entity.ProjectUser;
import com.pms.auth.entity.User;
import com.pms.auth.entity.UserRole;
import com.pms.auth.mapper.*;
import com.pms.auth.service.UserService;
import com.pms.common.constant.CommonConstants;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 用户管理服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final UserRoleMapper userRoleMapper;
private final ProjectUserMapper projectUserMapper;
private final PasswordEncoder passwordEncoder;
@Override
public PageResult<UserDTO> page(UserQueryRequest request) {
int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : CommonConstants.DEFAULT_PAGE;
int size = request.getSize() != null && request.getSize() > 0 ? request.getSize() : CommonConstants.DEFAULT_SIZE;
size = Math.min(size, CommonConstants.MAX_SIZE);
Page<UserDTO> pageObj = new Page<>(page, size);
IPage<UserDTO> result = userMapper.selectUserPage(
pageObj,
request.getKeyword(),
request.getOrgId(),
request.getStatus(),
request.getRoleId(),
UserContext.getProjectId());
// 为每个用户填充角色信息
for (UserDTO dto : result.getRecords()) {
fillUserRoles(dto);
}
return new PageResult<>(result.getRecords(), result.getTotal(), page, size);
}
@Override
public UserDTO getById(Long id) {
UserDTO userDTO = userMapper.selectUserWithRolesById(id);
if (userDTO == null) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
// 清除密码
userDTO.setPassword(null);
// 填充项目列表
fillUserProjects(userDTO);
return userDTO;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(UserSaveRequest request) {
// 检查用户名是否已存在
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, request.getUsername());
Long existCount = userMapper.selectCount(wrapper);
if (existCount > 0) {
throw new BusinessException(ErrorCode.RESOURCE_EXISTS, "用户名已存在");
}
// 创建用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRealName(request.getRealName());
user.setNickname(request.getNickname());
user.setGender(request.getGender() != null ? request.getGender() : 0);
user.setPhone(request.getPhone());
user.setEmail(request.getEmail());
user.setOrgId(request.getOrgId());
user.setStatus(CommonConstants.STATUS_ENABLED);
user.setAccountType(1);
user.setFailCount(0);
user.setRemark(request.getRemark());
user.setProjectId(UserContext.getProjectId());
long now = System.currentTimeMillis();
user.setCreatedAt(now);
user.setUpdatedAt(now);
user.setCreatedBy(UserContext.getUserId());
user.setUpdatedBy(UserContext.getUserId());
userMapper.insert(user);
// 分配角色
if (request.getRoleIds() != null && !request.getRoleIds().isEmpty()) {
assignUserRoles(user.getId(), request.getRoleIds());
}
// 分配项目
if (request.getProjectIds() != null && !request.getProjectIds().isEmpty()) {
assignUserProjects(user.getId(), request.getProjectIds());
}
log.info("创建用户成功: userId={}, username={}", user.getId(), user.getUsername());
return user.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(Long id, UserUpdateRequest request) {
User user = getUserEntity(id);
User update = new User();
update.setId(id);
if (StringUtils.hasText(request.getRealName())) {
update.setRealName(request.getRealName());
}
update.setNickname(request.getNickname());
update.setGender(request.getGender());
update.setPhone(request.getPhone());
update.setEmail(request.getEmail());
if (request.getOrgId() != null) {
update.setOrgId(request.getOrgId());
}
if (request.getStatus() != null) {
update.setStatus(request.getStatus());
}
update.setRemark(request.getRemark());
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(UserContext.getUserId());
userMapper.updateById(update);
log.info("更新用户成功: userId={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
User user = getUserEntity(id);
// 系统管理员账号不可删除
if (user.getAccountType() != null && user.getAccountType() == 2) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "系统管理员账号不可删除");
}
// 逻辑删除用户
userMapper.deleteById(id);
// 删除角色关联
userRoleMapper.deleteByUserId(id);
// 删除项目关联
projectUserMapper.deleteByUserId(id);
log.info("删除用户成功: userId={}", id);
}
@Override
public void updateStatus(Long id, Integer status) {
User user = getUserEntity(id);
User update = new User();
update.setId(id);
update.setStatus(status);
update.setUpdatedAt(System.currentTimeMillis());
update.setUpdatedBy(UserContext.getUserId());
userMapper.updateById(update);
log.info("更新用户状态: userId={}, status={}", id, status);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void assignRoles(Long userId, AssignRolesRequest request) {
// 检查用户存在
getUserEntity(userId);
// 先删除旧的角色关联
userRoleMapper.deleteByUserId(userId);
// 再分配新角色
if (request.getRoleIds() != null && !request.getRoleIds().isEmpty()) {
assignUserRoles(userId, request.getRoleIds());
}
log.info("分配角色成功: userId={}, roleIds={}", userId, request.getRoleIds());
}
// ====== 私有方法 ======
private User getUserEntity(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND);
}
return user;
}
/**
* 分配用户角色
*/
private void assignUserRoles(Long userId, List<Long> roleIds) {
long now = System.currentTimeMillis();
for (Long roleId : roleIds) {
UserRole ur = new UserRole();
ur.setUserId(userId);
ur.setRoleId(roleId);
ur.setProjectId(UserContext.getProjectId());
ur.setCreatedAt(now);
ur.setUpdatedAt(now);
ur.setCreatedBy(UserContext.getUserId());
ur.setUpdatedBy(UserContext.getUserId());
userRoleMapper.insert(ur);
}
}
/**
* 分配用户项目
*/
private void assignUserProjects(Long userId, List<Long> projectIds) {
long now = System.currentTimeMillis();
for (int i = 0; i < projectIds.size(); i++) {
ProjectUser pu = new ProjectUser();
pu.setProjectId(projectIds.get(i));
pu.setUserId(userId);
pu.setIsDefault(i == 0 ? 1 : 0);
pu.setStatus(CommonConstants.STATUS_ENABLED);
pu.setCreatedAt(now);
pu.setUpdatedAt(now);
pu.setCreatedBy(UserContext.getUserId());
pu.setUpdatedBy(UserContext.getUserId());
projectUserMapper.insert(pu);
}
}
/**
* 填充用户角色
*/
private void fillUserRoles(UserDTO dto) {
List<com.pms.auth.entity.Role> roles = roleMapper.selectRolesByUserId(dto.getId());
List<RoleDTO> roleDTOs = new ArrayList<>();
for (com.pms.auth.entity.Role role : roles) {
RoleDTO r = new RoleDTO();
r.setId(role.getId());
r.setRoleCode(role.getRoleCode());
r.setRoleName(role.getRoleName());
r.setDataScope(role.getDataScope());
r.setStatus(role.getStatus());
roleDTOs.add(r);
}
dto.setRoles(roleDTOs);
}
/**
* 填充用户项目列表
*/
private void fillUserProjects(UserDTO dto) {
List<ProjectUser> projectUsers = projectUserMapper.selectByUserId(dto.getId());
List<ProjectInfoDTO> projects = new ArrayList<>();
for (ProjectUser pu : projectUsers) {
ProjectInfoDTO p = new ProjectInfoDTO();
p.setId(pu.getProjectId());
p.setIsDefault(pu.getIsDefault());
projects.add(p);
}
dto.setProjects(projects);
}
}

View File

@ -0,0 +1,115 @@
server:
port: 8081
spring:
application:
name: auth-service
profiles:
active: dev
# 数据源
datasource:
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/auth_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
# Flyway
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/auth_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
user: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
# Redis与 gateway 共用 database=0确保 JWT 黑名单可跨服务读取)
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 0
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
# RabbitMQ
rabbitmq:
host: ${RABBITMQ_HOST:localhost}
port: ${RABBITMQ_PORT:5672}
username: ${RABBITMQ_USERNAME:guest}
password: ${RABBITMQ_PASSWORD:guest}
virtual-host: /
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 10
cloud:
nacos:
discovery:
server-addr: ${NACOS_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:public}
file-extension: yaml
shared-configs:
- data-id: pms-common.yaml
refresh: true
openfeign:
client:
config:
default:
connect-timeout: 3000
read-timeout: 5000
# MyBatis-Plus
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.pms.auth.**.entity
global-config:
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
table-prefix: t_
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
# 验证码调试模式E2E测试时设为true生产环境必须为false
captcha:
debug: false
# JWT 配置(认证服务持有私钥用于签发)
jwt:
algorithm: RS256
private-key: ${JWT_PRIVATE_KEY:}
public-key: ${JWT_PUBLIC_KEY:}
access-token-expire: 7200
refresh-token-expire: 604800
issuer: ether-pms
audience: pms-client
# 日志
logging:
level:
com.pms: debug
# 端点暴露
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always

View File

@ -0,0 +1,250 @@
-- ========================================
-- auth_db 数据库初始化脚本7张表 + 种子数据)
-- 认证服务:用户、角色、权限、组织及关联关系
-- 技术约定:
-- 主键 BIGINT UNSIGNED雪花算法
-- 时间戳 BIGINT毫秒级时间戳
-- 唯一约束包含 project_id多租户安全
-- ========================================
-- ====================
-- 1. 用户表
-- ====================
CREATE TABLE IF NOT EXISTS `t_user` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '所属项目ID',
`username` VARCHAR(64) NOT NULL COMMENT '登录账号,唯一',
`password` VARCHAR(128) NOT NULL COMMENT 'BCrypt加密密码',
`real_name` VARCHAR(64) NOT NULL COMMENT '真实姓名',
`nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称',
`gender` TINYINT DEFAULT 0 COMMENT '性别0未知 1男 2女',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`org_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '所属组织ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
`account_type` TINYINT NOT NULL DEFAULT 1 COMMENT '账号类型1普通 2系统管理员',
`last_login_at` BIGINT DEFAULT NULL COMMENT '最后登录时间(时间戳)',
`last_login_ip` VARCHAR(64) DEFAULT NULL COMMENT '最后登录IP',
`fail_count` INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
`lock_until` BIGINT DEFAULT NULL COMMENT '锁定截止时间(时间戳)',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0未删 1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`, `deleted`) COMMENT '登录账号唯一索引',
KEY `idx_project_id` (`project_id`),
KEY `idx_phone` (`phone`),
KEY `idx_org_id` (`org_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- ====================
-- 2. 角色表
-- ====================
CREATE TABLE IF NOT EXISTS `t_role` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '所属项目IDNULL表示全局角色',
`role_code` VARCHAR(64) NOT NULL COMMENT '角色编码,唯一',
`role_name` VARCHAR(64) NOT NULL COMMENT '角色名称',
`data_scope` TINYINT NOT NULL DEFAULT 1 COMMENT '数据范围1全部 2本组织 3本组织及下级 4本人 5自定义',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_code` (`project_id`, `role_code`, `deleted`) COMMENT '角色编码唯一索引含项目ID',
KEY `idx_project_id` (`project_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';
-- ====================
-- 3. 权限表
-- ====================
CREATE TABLE IF NOT EXISTS `t_permission` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父权限ID0表示根节点',
`perm_code` VARCHAR(128) NOT NULL COMMENT '权限编码,唯一',
`perm_name` VARCHAR(64) NOT NULL COMMENT '权限名称',
`perm_type` TINYINT NOT NULL DEFAULT 1 COMMENT '类型1菜单 2按钮 3接口',
`path` VARCHAR(255) DEFAULT NULL COMMENT '前端路由路径',
`component` VARCHAR(255) DEFAULT NULL COMMENT '前端组件路径',
`icon` VARCHAR(64) DEFAULT NULL COMMENT '菜单图标',
`api_method` VARCHAR(10) DEFAULT NULL COMMENT '接口方法GET/POST/PUT/DELETE',
`api_path` VARCHAR(255) DEFAULT NULL COMMENT '接口路径',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`is_hidden` TINYINT NOT NULL DEFAULT 0 COMMENT '是否隐藏0否 1是',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_perm_code` (`perm_code`, `deleted`) COMMENT '权限编码唯一索引',
KEY `idx_parent_id` (`parent_id`),
KEY `idx_perm_type` (`perm_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限表';
-- ====================
-- 4. 用户角色关联表
-- ====================
CREATE TABLE IF NOT EXISTS `t_user_role` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '项目IDNULL表示全局授权',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`project_id`, `user_id`, `role_id`, `deleted`) COMMENT '防重复分配',
KEY `idx_user_id` (`user_id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色关联表';
-- ====================
-- 5. 角色权限关联表
-- ====================
CREATE TABLE IF NOT EXISTS `t_role_permission` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '项目ID',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID',
`permission_id` BIGINT UNSIGNED NOT NULL COMMENT '权限ID',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_perm` (`project_id`, `role_id`, `permission_id`, `deleted`) COMMENT '防重复分配',
KEY `idx_role_id` (`role_id`),
KEY `idx_permission_id` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色权限关联表';
-- ====================
-- 6. 组织架构表
-- ====================
CREATE TABLE IF NOT EXISTS `t_org` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '所属项目ID',
`parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父组织ID0表示根节点',
`org_code` VARCHAR(64) NOT NULL COMMENT '组织编码,唯一',
`org_name` VARCHAR(128) NOT NULL COMMENT '组织名称',
`org_type` TINYINT NOT NULL DEFAULT 1 COMMENT '类型1公司 2部门 3小组 4项目部',
`leader_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '负责人用户ID',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_org_code` (`project_id`, `org_code`, `deleted`) COMMENT '组织编码唯一索引含项目ID',
KEY `idx_parent_id` (`parent_id`),
KEY `idx_leader_id` (`leader_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织架构表';
-- ====================
-- 7. 项目用户关联表
-- ====================
CREATE TABLE IF NOT EXISTS `t_project_user` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
`project_id` BIGINT UNSIGNED NOT NULL COMMENT '物业项目ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`org_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '组织ID',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认项目0否 1是',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳)',
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳)',
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_project_user` (`project_id`, `user_id`, `deleted`) COMMENT '一个用户在一个项目仅一条记录',
KEY `idx_user_id` (`user_id`),
KEY `idx_is_default` (`user_id`, `is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目用户关联表';
-- ========================================
-- 种子数据
-- ========================================
-- 默认管理员用户(密码: Admin@123456 的 BCrypt 哈希)
INSERT INTO `t_user` (`id`, `project_id`, `username`, `password`, `real_name`, `nickname`, `gender`, `phone`, `email`, `avatar`, `org_id`, `status`, `account_type`, `remark`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`)
VALUES (1, NULL, 'admin', '$2a$10$82EB3SvbuxsvKRAtu1g3O.NpnU75LMcDppvBy26mJN7zH7X34Qq3.', '系统管理员', '管理员', 1, '13800000001', 'admin@etherpms.com', NULL, 1, 1, 2, '系统内置管理员账号', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0);
-- 默认角色
INSERT INTO `t_role` (`id`, `project_id`, `role_code`, `role_name`, `data_scope`, `sort`, `status`, `remark`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(1, NULL, 'ROLE_ADMIN', '系统管理员', 1, 1, 1, '拥有全部权限', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(2, NULL, 'ROLE_PROPERTY', '物业管理员', 2, 2, 1, '物业管理操作权限', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(3, NULL, 'ROLE_FINANCE', '财务专员', 4, 3, 1, '收费与对账权限', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(4, NULL, 'ROLE_VIEWER', '只读用户', 4, 4, 1, '仅查看权限', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0);
-- 权限数据(菜单+按钮+接口)
-- 一级菜单:系统管理
INSERT INTO `t_permission` (`id`, `parent_id`, `perm_code`, `perm_name`, `perm_type`, `path`, `component`, `icon`, `api_method`, `api_path`, `sort`, `is_hidden`, `status`, `created_at`, `updated_at`, `deleted`) VALUES
(101, 0, 'system', '系统管理', 1, '/system', 'Layout', 'setting', NULL, NULL, 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(102, 101, 'system:user', '用户管理', 1, '/system/user', 'system/user/index', 'user', NULL, NULL, 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(103, 102, 'system:user:list', '用户列表', 2, NULL, NULL, NULL, 'GET', '/api/v1/users', 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(104, 102, 'system:user:add', '新增用户', 2, NULL, NULL, NULL, 'POST', '/api/v1/users', 2, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(105, 102, 'system:user:edit', '编辑用户', 2, NULL, NULL, NULL, 'PUT', '/api/v1/users', 3, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(106, 102, 'system:user:delete', '删除用户', 2, NULL, NULL, NULL, 'DELETE', '/api/v1/users', 4, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(107, 102, 'system:user:assign', '分配角色', 2, NULL, NULL, NULL, 'POST', '/api/v1/users/*/roles', 5, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(108, 102, 'system:user:reset', '重置密码', 2, NULL, NULL, NULL, 'POST', '/api/v1/auth/reset-password', 6, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(111, 101, 'system:role', '角色管理', 1, '/system/role', 'system/role/index', 'peoples', NULL, NULL, 2, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(112, 111, 'system:role:list', '角色列表', 2, NULL, NULL, NULL, 'GET', '/api/v1/roles', 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(113, 111, 'system:role:add', '新增角色', 2, NULL, NULL, NULL, 'POST', '/api/v1/roles', 2, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(114, 111, 'system:role:edit', '编辑角色', 2, NULL, NULL, NULL, 'PUT', '/api/v1/roles', 3, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(115, 111, 'system:role:delete', '删除角色', 2, NULL, NULL, NULL, 'DELETE', '/api/v1/roles', 4, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(116, 111, 'system:role:assign', '分配权限', 2, NULL, NULL, NULL, 'POST', '/api/v1/roles/*/permissions', 5, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(121, 101, 'system:permission', '权限管理', 1, '/system/permission', 'system/permission/index', 'tree-table', NULL, NULL, 3, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(122, 121, 'system:permission:list', '权限树查询', 2, NULL, NULL, NULL, 'GET', '/api/v1/permissions', 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(131, 101, 'system:org', '组织管理', 1, '/system/org', 'system/org/index', 'organization', NULL, NULL, 4, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(132, 131, 'system:org:list', '组织树', 2, NULL, NULL, NULL, 'GET', '/api/v1/orgs', 1, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(133, 131, 'system:org:add', '新增组织', 2, NULL, NULL, NULL, 'POST', '/api/v1/orgs', 2, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(134, 131, 'system:org:edit', '编辑组织', 2, NULL, NULL, NULL, 'PUT', '/api/v1/orgs', 3, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(135, 131, 'system:org:delete', '删除组织', 2, NULL, NULL, NULL, 'DELETE', '/api/v1/orgs', 4, 0, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0);
-- 管理员角色关联全部权限
INSERT INTO `t_role_permission` (`id`, `project_id`, `role_id`, `permission_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(1, NULL, 1, 101, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(2, NULL, 1, 102, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(3, NULL, 1, 103, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(4, NULL, 1, 104, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(5, NULL, 1, 105, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(6, NULL, 1, 106, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(7, NULL, 1, 107, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(8, NULL, 1, 108, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(9, NULL, 1, 111, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(10, NULL, 1, 112, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(11, NULL, 1, 113, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(12, NULL, 1, 114, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(13, NULL, 1, 115, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(14, NULL, 1, 116, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(15, NULL, 1, 121, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(16, NULL, 1, 122, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(17, NULL, 1, 131, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(18, NULL, 1, 132, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(19, NULL, 1, 133, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(20, NULL, 1, 134, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(21, NULL, 1, 135, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0);
-- 管理员用户关联管理员角色
INSERT INTO `t_user_role` (`id`, `project_id`, `user_id`, `role_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(1, NULL, 1, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0);
-- 默认组织
INSERT INTO `t_org` (`id`, `project_id`, `parent_id`, `org_code`, `org_name`, `org_type`, `leader_id`, `phone`, `sort`, `status`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(1, NULL, 0, 'HQ', '物业公司总部', 1, 1, '021-88888888', 1, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0);

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pms.auth.mapper.OrgMapper">
<!-- 查询全部组织 -->
<select id="selectAllOrgs" resultType="com.pms.auth.entity.Org">
SELECT id, parent_id, org_code, org_name, org_type, leader_id, phone, sort, status,
project_id, created_at, updated_at, created_by, updated_by, deleted
FROM t_org
WHERE deleted = 0
<if test="projectId != null">
AND (project_id = #{projectId} OR project_id IS NULL)
</if>
ORDER BY sort ASC, created_at ASC
</select>
<!-- 统计子节点数量 -->
<select id="countChildren" resultType="int">
SELECT COUNT(1) FROM t_org WHERE parent_id = #{parentId} AND deleted = 0
</select>
<!-- 统计组织下的用户数 -->
<select id="countUsersByOrgId" resultType="int">
SELECT COUNT(1) FROM t_user WHERE org_id = #{orgId} AND deleted = 0
</select>
</mapper>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pms.auth.mapper.PermissionMapper">
<!-- 根据用户ID查询权限列表 -->
<select id="selectPermissionsByUserId" resultType="com.pms.auth.entity.Permission">
SELECT DISTINCT p.id, p.parent_id, p.perm_code, p.perm_name, p.perm_type,
p.path, p.component, p.icon, p.api_method, p.api_path,
p.sort, p.is_hidden, p.status,
p.created_at, p.updated_at, p.created_by, p.updated_by, p.deleted
FROM t_user_role ur
INNER JOIN t_role_permission rp ON ur.role_id = rp.role_id AND rp.deleted = 0
INNER JOIN t_permission p ON rp.permission_id = p.id AND p.deleted = 0
WHERE ur.user_id = #{userId} AND ur.deleted = 0 AND p.status = 1
ORDER BY p.sort ASC
</select>
<!-- 根据角色ID查询权限列表 -->
<select id="selectPermissionsByRoleId" resultType="com.pms.auth.entity.Permission">
SELECT DISTINCT p.id, p.parent_id, p.perm_code, p.perm_name, p.perm_type,
p.path, p.component, p.icon, p.api_method, p.api_path,
p.sort, p.is_hidden, p.status,
p.created_at, p.updated_at, p.created_by, p.updated_by, p.deleted
FROM t_role_permission rp
INNER JOIN t_permission p ON rp.permission_id = p.id AND p.deleted = 0
WHERE rp.role_id = #{roleId} AND rp.deleted = 0
ORDER BY p.sort ASC
</select>
<!-- 根据角色ID查询权限编码列表 -->
<select id="selectPermissionCodesByRoleId" resultType="java.lang.String">
SELECT DISTINCT p.perm_code
FROM t_role_permission rp
INNER JOIN t_permission p ON rp.permission_id = p.id AND p.deleted = 0
WHERE rp.role_id = #{roleId} AND rp.deleted = 0
</select>
<!-- 根据角色ID查询权限ID列表 -->
<select id="selectPermissionIdsByRoleId" resultType="java.lang.Long">
SELECT DISTINCT rp.permission_id
FROM t_role_permission rp
WHERE rp.role_id = #{roleId} AND rp.deleted = 0
</select>
</mapper>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pms.auth.mapper.ProjectUserMapper">
<!-- 根据用户ID查询关联项目列表 -->
<select id="selectByUserId" resultType="com.pms.auth.entity.ProjectUser">
SELECT id, project_id, user_id, org_id, is_default, status,
created_at, updated_at, created_by, updated_by, deleted
FROM t_project_user
WHERE user_id = #{userId} AND deleted = 0
</select>
<!-- 根据用户ID删除所有项目关联 -->
<delete id="deleteByUserId">
DELETE FROM t_project_user WHERE user_id = #{userId}
</delete>
</mapper>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pms.auth.mapper.RoleMapper">
<!-- 查询角色列表(含关联用户数) -->
<select id="selectRoleListWithUserCount" resultType="com.pms.auth.dto.RoleDTO">
SELECT r.id, r.role_code, r.role_name, r.data_scope, r.sort, r.status, r.remark,
r.project_id, r.created_at, r.updated_at,
(SELECT COUNT(1) FROM t_user_role ur WHERE ur.role_id = r.id AND ur.deleted = 0) AS userCount
FROM t_role r
WHERE r.deleted = 0
<if test="projectId != null">
AND (r.project_id = #{projectId} OR r.project_id IS NULL)
</if>
ORDER BY r.sort ASC, r.created_at ASC
</select>
<!-- 根据用户ID查询角色列表 -->
<select id="selectRolesByUserId" resultType="com.pms.auth.entity.Role">
SELECT r.id, r.role_code, r.role_name, r.data_scope, r.sort, r.status, r.remark,
r.project_id, r.created_at, r.updated_at, r.created_by, r.updated_by, r.deleted
FROM t_user_role ur
INNER JOIN t_role r ON ur.role_id = r.id AND r.deleted = 0
WHERE ur.user_id = #{userId} AND ur.deleted = 0
</select>
<!-- 根据用户ID查询角色编码列表 -->
<select id="selectRoleCodesByUserId" resultType="java.lang.String">
SELECT DISTINCT r.role_code
FROM t_user_role ur
INNER JOIN t_role r ON ur.role_id = r.id AND r.deleted = 0
WHERE ur.user_id = #{userId} AND ur.deleted = 0
</select>
<!-- 统计角色关联的用户数 -->
<select id="countUsersByRoleId" resultType="int">
SELECT COUNT(1) FROM t_user_role WHERE role_id = #{roleId} AND deleted = 0
</select>
</mapper>

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