Commit 0c09b3d1 authored by Sacen's avatar Sacen

初始化智汇平台统一身份认证门户

包含 Spring Boot OIDC 认证中心、Vue 3 门户、老系统 SSO Starter、两个示例子系统及接入文档。
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parents
{
"version": "0.0.1",
"configurations": [
{
"name": "zhihui-web",
"runtimeExecutable": "C:/nvm4w/nodejs/node.exe",
"runtimeArgs": [
"zhihui-web/node_modules/vite/bin/vite.js",
"zhihui-web",
"--host",
"127.0.0.1",
"--port",
"5173"
],
"port": 5173
},
{
"name": "zhihui-server",
"runtimeExecutable": "D:/install/JDK/JDK 22/jdk-22.0.2/bin/java.exe",
"runtimeArgs": [
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-jar",
"zhihui-server/target/zhihui-server-1.0.0.jar"
],
"port": 8080
},
{
"name": "zhihui-demo",
"runtimeExecutable": "D:/install/JDK/JDK 22/jdk-22.0.2/bin/java.exe",
"runtimeArgs": [
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-jar",
"zhihui-demo-app/target/zhihui-demo-app-1.0.0.jar"
],
"port": 8081
}
]
}
# 复制为 .env 后按需修改;该文件已被 .gitignore 忽略
# 数据库(本机 MySQL)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=zhihui
DB_USER=zhihui
DB_PASSWORD=zhihui123
# Redis(会话存储,使用 3 号库避免与其他项目冲突)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=3
# 门户对外访问地址:必须与浏览器地址栏完全一致
ZHIHUI_ISSUER=http://localhost:8080
# 首次启动创建的管理员密码(仅在用户不存在时生效;生产环境务必修改)
ZHIHUI_ADMIN_PASSWORD=123456
# 管理员查看应用 client_secret 时的加密密钥;生产环境务必改成独立强随机值
# 更换后已保存的旧密钥无法解密,需要在后台重置对应应用密钥
ZHIHUI_CLIENT_SECRET_KEY=zhihui-dev-client-secret-key-change-me
# 示例子系统客户端(与 zhihui-demo-app/application.yml 保持一致)
# 生产环境可设为 false,避免启动时自动创建演示应用
ZHIHUI_DEMO_ENABLED=true
ZHIHUI_DEMO_APP_URL=http://localhost:8081
ZHIHUI_DEMO_CLIENT_ID=demo-subsystem
ZHIHUI_DEMO_CLIENT_SECRET=demo-secret-2026
# 第二个示例子系统(运行 scripts/run-demo2.sh)
ZHIHUI_DEMO2_ENABLED=true
ZHIHUI_DEMO2_APP_URL=http://localhost:8082
ZHIHUI_DEMO2_CLIENT_ID=demo-subsystem-2
ZHIHUI_DEMO2_CLIENT_SECRET=demo-secret-2026-2
# 生产环境启用 HTTPS 后改为 true
ZHIHUI_COOKIE_SECURE=false
# 登录限流:同一账号+IP 连续失败次数、同一 IP 失败次数、统计窗口
ZHIHUI_LOGIN_RATE_LIMIT_ENABLED=true
ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP=5
ZHIHUI_LOGIN_MAX_FAILURES_PER_IP=20
ZHIHUI_LOGIN_FAILURE_WINDOW=15m
# 仅在可信反向代理之后设为 true,才会采信 X-Forwarded-For / X-Real-IP
ZHIHUI_TRUST_FORWARDED_HEADERS=false
# 使用 docker-compose 时改为 3307 / 6380
# DB_PORT=3307
# REDIS_PORT=6380
target/
node_modules/
dist/
*.log
*.iml
.idea/
.vscode/
.project
.settings/
.DS_Store
.env
logs/
.claude/settings.local.json
# 前端构建产物(由 zhihui-web 构建生成)
zhihui-server/src/main/resources/static/spa/
# IDE 本地元数据
.classpath
.factorypath
This diff is collapsed.
services:
mysql:
image: mysql:8.4
container_name: zhihui-mysql
restart: unless-stopped
ports:
- "3307:3306"
environment:
MYSQL_DATABASE: zhihui
MYSQL_USER: zhihui
MYSQL_PASSWORD: zhihui123
MYSQL_ROOT_PASSWORD: root123
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- zhihui-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
container_name: zhihui-redis
restart: unless-stopped
ports:
- "6380:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- zhihui-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
volumes:
zhihui-mysql-data:
zhihui-redis-data:
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.zhihui</groupId>
<artifactId>zhihui-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>zhihui</name>
<description>智汇平台 - 统一身份认证与门户系统</description>
<modules>
<module>zhihui-server</module>
<module>zhihui-sso-starter</module>
<module>zhihui-demo-app</module>
</modules>
<properties>
<java.version>21</java.version>
<poi.version>5.4.0</poi.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
</project>
#!/usr/bin/env bash
# 全量构建:先构建前端,再打包后端(前端产物会打进 zhihui-server 的 static/spa)
set -e
cd "$(dirname "$0")/.."
if [ ! -f zhihui-web/package-lock.json ]; then
(cd zhihui-web && npm install --no-audit --no-fund)
else
(cd zhihui-web && npm ci --no-audit --no-fund)
fi
(cd zhihui-web && npm run type-check && npm run build)
bash scripts/mvn.sh clean package -DskipTests
echo "构建完成:zhihui-server/target/zhihui-server-1.0.0.jar"
#!/usr/bin/env bash
# 用 JDK 22 运行 Maven(本机默认 java 是 1.8,Spring Boot 3 需要 17+)
set -e
export JAVA_HOME="${JAVA_HOME_ZHIHUI:-D:/install/JDK/JDK 22/jdk-22.0.2}"
if [ ! -x "$JAVA_HOME/bin/java" ]; then
echo "未找到 JDK:$JAVA_HOME" >&2
echo "请设置 JAVA_HOME_ZHIHUI 指向 JDK 21/22" >&2
exit 1
fi
export PATH="$JAVA_HOME/bin:$PATH"
cd "$(dirname "$0")/.."
exec mvn "$@"
#!/usr/bin/env bash
# 启动示例子系统
set -e
export JAVA_HOME="${JAVA_HOME_ZHIHUI:-D:/install/JDK/JDK 22/jdk-22.0.2}"
cd "$(dirname "$0")/.."
exec "$JAVA_HOME/bin/java" -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 \
-jar zhihui-demo-app/target/zhihui-demo-app-1.0.0.jar "$@"
#!/usr/bin/env bash
# 启动第二个示例子系统(端口 8082,使用独立的 client 和会话 Cookie)
set -e
export JAVA_HOME="${JAVA_HOME_ZHIHUI:-D:/install/JDK/JDK 22/jdk-22.0.2}"
cd "$(dirname "$0")/.."
export SERVER_PORT=8082
export ZHIHUI_DEMO_NAME=示例子系统二
export ZHIHUI_DEMO_CLIENT_ID=demo-subsystem-2
export ZHIHUI_DEMO_CLIENT_SECRET=demo-secret-2026-2
export ZHIHUI_DEMO_REDIRECT_URI=http://localhost:8082/sso/callback
export ZHIHUI_DEMO_POST_LOGOUT_URI=http://localhost:8082/
export ZHIHUI_DEMO_SESSION_COOKIE=ZHIHUI_DEMO2_SESSION
exec "$JAVA_HOME/bin/java" -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 \
-jar zhihui-demo-app/target/zhihui-demo-app-1.0.0.jar "$@"
#!/usr/bin/env bash
# 启动门户 + 统一认证中心
set -e
export JAVA_HOME="${JAVA_HOME_ZHIHUI:-D:/install/JDK/JDK 22/jdk-22.0.2}"
cd "$(dirname "$0")/.."
exec "$JAVA_HOME/bin/java" -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 \
-jar zhihui-server/target/zhihui-server-1.0.0.jar "$@"
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zhihui</groupId>
<artifactId>zhihui-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>zhihui-demo-app</artifactId>
<name>zhihui-demo-app</name>
<description>示例子系统:演示一次登录、处处访问</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.zhihui</groupId>
<artifactId>zhihui-sso-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package com.zhihui.demo;
import com.zhihui.sso.SsoSession;
import com.zhihui.sso.SsoUser;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.ControllerAdvice;
import java.util.List;
import java.util.Map;
@Controller
public class DemoController {
@GetMapping("/")
public String dashboard() {
return "index";
}
@GetMapping("/cases")
public String cases(Model model) {
model.addAttribute("cases", List.of(
Map.of("no", "AJ-2026-0182", "title", "跨境电信网络诈骗线索核查",
"owner", "李晨", "status", "办理中", "updated", "2026-10-22 16:40"),
Map.of("no", "AJ-2026-0177", "title", "重点区域异常资金流向分析",
"owner", "周敏", "status", "待审核", "updated", "2026-10-22 10:12"),
Map.of("no", "AJ-2026-0164", "title", "涉案账户关联人员落地核查",
"owner", "王凯", "status", "已办结", "updated", "2026-10-21 18:05"),
Map.of("no", "AJ-2026-0159", "title", "跨区域协作线索移交",
"owner", "陈曦", "status", "办理中", "updated", "2026-10-21 09:33")
));
return "cases";
}
@GetMapping("/alerts")
public String alerts(Model model) {
model.addAttribute("alerts", List.of(
Map.of("level", "高", "title", "同一号码 30 分钟内触发 18 次高危预警",
"source", "反诈预警平台", "time", "2026-10-23 09:18"),
Map.of("level", "中", "title", "重点人员活动轨迹出现跨区域异常",
"source", "轨迹分析服务", "time", "2026-10-23 08:42"),
Map.of("level", "中", "title", "涉案账户夜间交易频次明显上升",
"source", "资金分析服务", "time", "2026-10-22 23:07"),
Map.of("level", "低", "title", "协作单位反馈材料已签收",
"source", "协作平台", "time", "2026-10-22 17:31")
));
return "alerts";
}
@GetMapping("/no-local-user")
public String noLocalUser() {
return "no-local-user";
}
@ControllerAdvice
static class SsoModelAdvice {
@Value("${zhihui.demo.name:示例子系统}")
private String demoName;
@ModelAttribute("demoName")
public String demoName() {
return demoName;
}
@ModelAttribute("ssoUser")
public SsoUser ssoUser(HttpServletRequest request) {
return SsoSession.getUser(request);
}
@ModelAttribute("localUser")
public DemoLocalUser localUser(HttpServletRequest request) {
return (DemoLocalUser) request.getAttribute(LocalUserValidationInterceptor.LOCAL_USER_ATTRIBUTE);
}
@ModelAttribute("hasAccessToken")
public boolean hasAccessToken(HttpServletRequest request) {
return SsoSession.getAccessToken(request) != null;
}
}
}
package com.zhihui.demo;
public class DemoLocalUser {
private String username;
private String policeNumber;
private String idCardNumber;
private String phoneNumber;
private String displayName;
private String role;
private boolean enabled = true;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPoliceNumber() {
return policeNumber;
}
public void setPoliceNumber(String policeNumber) {
this.policeNumber = policeNumber;
}
public String getIdCardNumber() {
return idCardNumber;
}
public void setIdCardNumber(String idCardNumber) {
this.idCardNumber = idCardNumber;
}
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
package com.zhihui.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "zhihui.demo")
public class DemoLocalUserProperties {
private List<DemoLocalUser> localUsers = new ArrayList<>();
public List<DemoLocalUser> getLocalUsers() {
return localUsers;
}
public void setLocalUsers(List<DemoLocalUser> localUsers) {
this.localUsers = localUsers;
}
}
package com.zhihui.demo;
import com.zhihui.sso.SsoUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class DemoLocalUserService {
private static final Logger log = LoggerFactory.getLogger(DemoLocalUserService.class);
private final List<DemoLocalUser> localUsers;
public DemoLocalUserService(DemoLocalUserProperties properties) {
List<DemoLocalUser> users = new ArrayList<>();
for (DemoLocalUser user : properties.getLocalUsers()) {
if (user == null || !hasAnyIdentifier(user)) {
log.warn("本地用户配置缺少警号、身份证号或手机号,已忽略");
continue;
}
users.add(user);
}
this.localUsers = List.copyOf(users);
}
public Optional<DemoLocalUser> findActive(SsoUser ssoUser) {
if (ssoUser == null || !hasAnyIdentifier(ssoUser)) {
return Optional.empty();
}
List<DemoLocalUser> matches = localUsers.stream()
.filter(DemoLocalUser::isEnabled)
.filter(user -> matches(user, ssoUser))
.toList();
if (matches.size() > 1) {
log.warn("多个本地用户命中同一登录身份,拒绝访问: username={}, matches={}",
ssoUser.getUsername(), matches.size());
return Optional.empty();
}
return matches.stream().findFirst();
}
private static boolean hasAnyIdentifier(DemoLocalUser user) {
return StringUtils.hasText(user.getPoliceNumber())
|| StringUtils.hasText(user.getIdCardNumber())
|| StringUtils.hasText(user.getPhoneNumber());
}
private static boolean hasAnyIdentifier(SsoUser user) {
return StringUtils.hasText(user.getPoliceNumber())
|| StringUtils.hasText(user.getIdCardNumber())
|| StringUtils.hasText(user.getPhoneNumber());
}
private static boolean matches(DemoLocalUser localUser, SsoUser ssoUser) {
return matchesText(localUser.getPoliceNumber(), ssoUser.getPoliceNumber())
|| matchesText(localUser.getIdCardNumber(), ssoUser.getIdCardNumber())
|| matchesText(localUser.getPhoneNumber(), ssoUser.getPhoneNumber());
}
private static boolean matchesText(String localValue, String ssoValue) {
if (!StringUtils.hasText(localValue) || !StringUtils.hasText(ssoValue)) {
return false;
}
return localValue.trim().toLowerCase(Locale.ROOT)
.equals(ssoValue.trim().toLowerCase(Locale.ROOT));
}
}
package com.zhihui.demo;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class DemoWebConfig implements WebMvcConfigurer {
private final LocalUserValidationInterceptor localUserValidationInterceptor;
public DemoWebConfig(LocalUserValidationInterceptor localUserValidationInterceptor) {
this.localUserValidationInterceptor = localUserValidationInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localUserValidationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/sso/**",
"/no-local-user",
"/css/**",
"/img/**",
"/favicon.ico",
"/error");
}
}
package com.zhihui.demo;
import com.zhihui.sso.SsoSession;
import com.zhihui.sso.SsoUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@Component
public class LocalUserValidationInterceptor implements HandlerInterceptor {
public static final String LOCAL_USER_ATTRIBUTE = "zhihui.demo.localUser";
private static final Logger log = LoggerFactory.getLogger(LocalUserValidationInterceptor.class);
private final DemoLocalUserService localUserService;
public LocalUserValidationInterceptor(DemoLocalUserService localUserService) {
this.localUserService = localUserService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws IOException {
SsoUser ssoUser = SsoSession.getUser(request);
if (ssoUser == null) {
response.sendRedirect(request.getContextPath() + "/sso/login");
return false;
}
Optional<DemoLocalUser> localUser = localUserService.findActive(ssoUser);
if (localUser.isEmpty()) {
log.warn("本地用户校验未通过: username={}, hasPoliceNumber={}, hasIdCardNumber={}, hasPhoneNumber={}",
ssoUser.getUsername(),
ssoUser.getPoliceNumber() != null,
ssoUser.getIdCardNumber() != null,
ssoUser.getPhoneNumber() != null);
if (acceptsJson(request)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"local_user_not_found\",\"message\":\"当前账号未在本地用户库开通\"}");
return false;
}
response.sendRedirect(request.getContextPath() + "/no-local-user");
return false;
}
request.setAttribute(LOCAL_USER_ATTRIBUTE, localUser.get());
return true;
}
private static boolean acceptsJson(HttpServletRequest request) {
String accept = request.getHeader("Accept");
return accept != null && accept.contains("application/json");
}
}
package com.zhihui.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(DemoLocalUserProperties.class)
public class ZhihuiDemoApplication {
public static void main(String[] args) {
SpringApplication.run(ZhihuiDemoApplication.class, args);
}
}
server:
port: ${SERVER_PORT:8081}
servlet:
session:
timeout: 8h
cookie:
name: ${ZHIHUI_DEMO_SESSION_COOKIE:ZHIHUI_DEMO_SESSION}
http-only: true
same-site: lax
spring:
application:
name: zhihui-demo-app
thymeleaf:
cache: false
zhihui:
demo:
name: ${ZHIHUI_DEMO_NAME:示例子系统}
local-users:
- username: operator
police-number: '${ZHIHUI_DEMO_LOCAL_POLICE_NUMBER:SMK660909}'
id-card-number: '${ZHIHUI_DEMO_LOCAL_ID_CARD_NUMBER:110101199001011234}'
phone-number: '${ZHIHUI_DEMO_LOCAL_PHONE_NUMBER:13800138000}'
display-name: 演示操作员
role: 案件查询
enabled: true
sso:
issuer: ${ZHIHUI_ISSUER:http://localhost:8080}
client-id: ${ZHIHUI_DEMO_CLIENT_ID:demo-subsystem}
client-secret: ${ZHIHUI_DEMO_CLIENT_SECRET:demo-secret-2026}
redirect-uri: ${ZHIHUI_DEMO_REDIRECT_URI:http://localhost:8081/sso/callback}
post-logout-redirect-uri: ${ZHIHUI_DEMO_POST_LOGOUT_URI:http://localhost:8081/}
logging:
level:
com.zhihui.sso: DEBUG
This diff is collapsed.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="示例子系统">
<rect x="3" y="3" width="42" height="42" rx="8" fill="#17324f"/>
<path d="M15 35V23a9 9 0 0 1 18 0v12" fill="none" stroke="#e9e2d3" stroke-width="2.6"/>
<circle cx="24" cy="25" r="3" fill="#b08a4f"/>
</svg>
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments :: head('告警中心')}"></head>
<body>
<div class="shell">
<aside th:replace="~{fragments :: sidebar('alerts')}"></aside>
<div class="main">
<header th:replace="~{fragments :: topbar('告警中心')}"></header>
<main class="content">
<section class="page-head">
<div>
<p class="eyebrow">实时监测</p>
<h1>告警中心</h1>
</div>
<span class="page-note">共 <strong th:text="${#lists.size(alerts)}">4</strong> 条待关注</span>
</section>
<ul class="alert-list">
<li class="alert-item" th:each="row : ${alerts}">
<span class="severity"
th:classappend="${row.level == '高'} ? 'sev-high' : (${row.level == '中'} ? 'sev-medium' : 'sev-low')"
th:text="${row.level}">高</span>
<div class="alert-body">
<h2 th:text="${row.title}">告警标题</h2>
<p>
<span th:text="${row.source}">来源</span>
<span class="dot-sep">·</span>
<span th:text="${row.time}">时间</span>
</p>
</div>
</li>
</ul>
</main>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments :: head('案件管理')}"></head>
<body>
<div class="shell">
<aside th:replace="~{fragments :: sidebar('cases')}"></aside>
<div class="main">
<header th:replace="~{fragments :: topbar('案件管理')}"></header>
<main class="content">
<section class="page-head">
<div>
<p class="eyebrow">业务办理</p>
<h1>案件管理</h1>
</div>
<span class="page-note">当前身份:<strong th:text="${ssoUser.displayName}">演示用户</strong></span>
</section>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>案件编号</th>
<th>案件名称</th>
<th>负责人</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${cases}">
<td class="mono" th:text="${row.no}">AJ-2026-0182</td>
<td th:text="${row.title}">案件名称</td>
<td th:text="${row.owner}">负责人</td>
<td>
<span class="status" th:classappend="${row.status == '已办结'} ? 'is-done' : (${row.status == '待审核'} ? 'is-pending' : 'is-active')"
th:text="${row.status}">办理中</span>
</td>
<td class="mono muted" th:text="${row.updated}">2026-10-22 16:40</td>
</tr>
</tbody>
</table>
</div>
</main>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${title} + ' · ' + ${demoName}">示例子系统</title>
<link rel="stylesheet" th:href="@{/css/app.css}">
<link rel="icon" th:href="@{/img/logo.svg}" type="image/svg+xml">
</head>
<body>
<aside class="sidebar" th:fragment="sidebar(active)">
<div class="brand">
<img th:src="@{/img/logo.svg}" alt="" class="brand-mark">
<div>
<div class="brand-title" th:text="${demoName}">示例子系统</div>
<div class="brand-sub">SSO DEMO</div>
</div>
</div>
<nav class="nav" aria-label="主导航">
<a th:href="@{/}" class="nav-item" th:classappend="${active == 'home'} ? 'is-active' : ''">工作台</a>
<a th:href="@{/cases}" class="nav-item" th:classappend="${active == 'cases'} ? 'is-active' : ''">案件管理</a>
<a th:href="@{/alerts}" class="nav-item" th:classappend="${active == 'alerts'} ? 'is-active' : ''">告警中心</a>
</nav>
<div class="sidebar-foot">
<span class="pulse" aria-hidden="true"></span>
统一认证已接入
</div>
</aside>
<header class="topbar" th:fragment="topbar(title)">
<div class="topbar-title" th:text="${title}">工作台</div>
<div class="topbar-user" th:if="${ssoUser != null}">
<span class="avatar" th:text="${#strings.substring(ssoUser.displayName, 0, 1)}">示</span>
<div class="user-meta">
<span class="user-name" th:text="${ssoUser.displayName}">演示用户</span>
<span class="user-sub" th:text="'@' + ${ssoUser.username}">@demo</span>
</div>
<a class="logout" th:href="@{/sso/logout}">退出登录</a>
</div>
</header>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments :: head('工作台')}"></head>
<body>
<div class="shell">
<aside th:replace="~{fragments :: sidebar('home')}"></aside>
<div class="main">
<header th:replace="~{fragments :: topbar('工作台')}"></header>
<main class="content">
<section class="hero">
<div>
<p class="eyebrow">统一身份认证 · 已生效</p>
<h1 th:text="'你好,' + ${ssoUser.displayName}">你好</h1>
<p class="lede">
你已在智汇平台完成登录。本系统通过统一认证自动识别你的身份,
无需再次输入账号密码;退出时也会同步结束门户会话。
</p>
</div>
<div class="identity-card">
<div class="identity-row">
<span>门户账号</span>
<strong th:text="${ssoUser.username}">operator</strong>
</div>
<div class="identity-row">
<span>警号</span>
<strong th:text="${#strings.isEmpty(ssoUser.policeNumber) ? '未提供' : ssoUser.policeNumber}">SMK660909</strong>
</div>
<div class="identity-row">
<span>身份证号</span>
<strong th:text="${#strings.isEmpty(ssoUser.idCardNumber) ? '未提供' : '****' + #strings.substring(ssoUser.idCardNumber, 14)}">****1234</strong>
</div>
<div class="identity-row">
<span>手机号</span>
<strong th:text="${#strings.isEmpty(ssoUser.phoneNumber) ? '未提供' : '****' + #strings.substring(ssoUser.phoneNumber, 7)}">****8000</strong>
</div>
<div class="identity-row">
<span>本地账号</span>
<strong th:if="${localUser != null}"
th:text="${localUser.displayName + ' · ' + localUser.role}">演示用户 · 案件查询</strong>
<strong th:if="${localUser == null}">未校验</strong>
</div>
<div class="identity-row">
<span>角色</span>
<strong th:text="${#strings.isEmpty(ssoUser.roles) ? '普通用户' : #strings.listJoin(ssoUser.roles, ' / ')}">USER</strong>
</div>
<div class="identity-row">
<span>身份来源</span>
<strong>智汇平台 OIDC</strong>
</div>
<div class="identity-row">
<span>访问令牌</span>
<strong th:text="${hasAccessToken ? '已签发' : '仅会话'}">已签发</strong>
</div>
</div>
</section>
<section class="stat-row" aria-label="工作概览">
<div class="stat">
<div class="stat-value">12</div>
<div class="stat-label">今日待办</div>
</div>
<div class="stat">
<div class="stat-value">3</div>
<div class="stat-label">进行中案件</div>
</div>
<div class="stat">
<div class="stat-value">4</div>
<div class="stat-label">未读告警</div>
</div>
</section>
<section class="module-grid" aria-label="业务模块">
<a class="module" th:href="@{/cases}">
<span class="module-tag">业务办理</span>
<h2>案件管理</h2>
<p>查看、流转和归档案件,所有操作自动关联当前统一身份。</p>
</a>
<a class="module" th:href="@{/alerts}">
<span class="module-tag">实时监测</span>
<h2>告警中心</h2>
<p>汇总各业务服务推送的预警信息,按风险等级集中处置。</p>
</a>
</section>
<section class="note">
<h2>这个页面如何拿到你的身份?</h2>
<p>
接入包拦下未登录请求并跳转智汇平台;门户确认已有登录态后返回一次性授权码;
本系统在服务端换取 ID Token,校验签名、签发方、受众与 nonce 后写入本地会话。
浏览器全程不接触 client secret,也不需要第二次输入密码。
</p>
</section>
<section class="note">
<h2>本地用户校验</h2>
<p>
统一认证只确认“你是谁”。本系统随后用门户提供的警号、身份证号、手机号查询自己的本地用户表,
任一标识匹配且账号启用才放行;本地账号停用或标识不匹配都会被拦截。
admin、demo 是门户管理账号,不进入子系统。
演示账号配置在 <code>application.yml</code> 的 <code>zhihui.demo.local-users</code>。
</p>
</section>
</main>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments :: head('本地账号未开通')}"></head>
<body>
<div class="shell">
<aside th:replace="~{fragments :: sidebar('')}"></aside>
<div class="main">
<header th:replace="~{fragments :: topbar('本地用户校验')}"></header>
<main class="content">
<section class="hero">
<div>
<p class="eyebrow">统一认证已通过 · 本地校验未通过</p>
<h1>当前账号未开通本系统</h1>
<p class="lede">
智汇平台已经确认你的身份,但本系统的本地用户库中没有找到有效账号。
请联系系统管理员在本地用户表中开通账号后再重新进入。
</p>
</div>
<div class="identity-card">
<div class="identity-row">
<span>门户账号</span>
<strong th:text="${ssoUser != null ? ssoUser.username : '—'}">operator</strong>
</div>
<div class="identity-row">
<span>警号</span>
<strong th:text="${ssoUser == null || #strings.isEmpty(ssoUser.policeNumber) ? '未提供' : ssoUser.policeNumber}">SMK660909</strong>
</div>
<div class="identity-row">
<span>身份证号</span>
<strong th:text="${ssoUser == null || #strings.isEmpty(ssoUser.idCardNumber) ? '未提供' : '****' + #strings.substring(ssoUser.idCardNumber, 14)}">****1234</strong>
</div>
<div class="identity-row">
<span>手机号</span>
<strong th:text="${ssoUser == null || #strings.isEmpty(ssoUser.phoneNumber) ? '未提供' : '****' + #strings.substring(ssoUser.phoneNumber, 7)}">****8000</strong>
</div>
<div class="identity-row">
<span>校验结果</span>
<strong>本地无有效账号</strong>
</div>
</div>
</section>
<section class="note">
<h2>为什么会被拦截?</h2>
<p>
统一认证只负责确认“你是谁”,本系统还会用门户提供的警号、身份证号、手机号查询自己的本地用户表。
本地账号不存在、已停用,或三个标识都无法匹配时,都会被拦截。
</p>
<p>
<a class="logout" th:href="@{/sso/logout}">退出当前账号</a>
</p>
</section>
</main>
</div>
</div>
</body>
</html>
package com.zhihui.demo;
import com.zhihui.sso.SsoUser;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DemoLocalUserServiceTest {
@Test
void identifiesOrdinaryUserByAnyOfThreeIdentifiers() {
DemoLocalUserService service = service(localUser("operator", "SMK660909",
"110101199001011234", "13800138000", true));
assertTrue(service.findActive(ssoUser("any-name", "SMK660909", null, null, "USER")).isPresent());
assertTrue(service.findActive(ssoUser("any-name", null, "110101199001011234", null, "USER")).isPresent());
assertTrue(service.findActive(ssoUser("any-name", null, null, "13800138000", "USER")).isPresent());
assertTrue(service.findActive(ssoUser("any-name", "smk660909", null, null, "USER")).isPresent());
assertTrue(service.findActive(ssoUser("any-name", null, "110101199001011234", null, "USER")).isPresent());
}
@Test
void usernameAndRoleDoNotGrantAccess() {
DemoLocalUserService service = service(localUser("operator", "SMK660909",
null, null, true));
assertFalse(service.findActive(ssoUser("operator", null, null, null, "USER")).isPresent());
assertFalse(service.findActive(ssoUser("admin", null, null, null, "ADMIN")).isPresent());
assertFalse(service.findActive(ssoUser("demo", "OTHER-POLICE", null, null, "USER")).isPresent());
}
@Test
void disabledLocalUserIsRejected() {
DemoLocalUserService service = service(localUser("operator", "SMK660909",
null, null, false));
assertFalse(service.findActive(ssoUser("operator", "SMK660909", null, null, "USER")).isPresent());
}
@Test
void ambiguousMatchesAreRejected() {
DemoLocalUserService service = service(
localUser("police-user", "SMK660909", null, null, true),
localUser("phone-user", null, null, "13800138000", true));
assertFalse(service.findActive(ssoUser("renamed", "SMK660909", null,
"13800138000", "USER")).isPresent());
}
@Test
void localUserWithoutIdentifierIsIgnored() {
DemoLocalUserService service = service(localUser("operator", null, null, null, true));
assertFalse(service.findActive(ssoUser("operator", "SMK660909", null, null, "USER")).isPresent());
}
private static DemoLocalUserService service(DemoLocalUser... users) {
DemoLocalUserProperties properties = new DemoLocalUserProperties();
properties.setLocalUsers(List.of(users));
return new DemoLocalUserService(properties);
}
private static DemoLocalUser localUser(String username, String policeNumber, String idCardNumber,
String phoneNumber, boolean enabled) {
DemoLocalUser user = new DemoLocalUser();
user.setUsername(username);
user.setPoliceNumber(policeNumber);
user.setIdCardNumber(idCardNumber);
user.setPhoneNumber(phoneNumber);
user.setDisplayName(username);
user.setRole("测试角色");
user.setEnabled(enabled);
return user;
}
private static SsoUser ssoUser(String username, String policeNumber, String idCardNumber,
String phoneNumber, String role) {
return new SsoUser("sub-" + username, username, username, policeNumber,
idCardNumber, phoneNumber, null, List.of(role));
}
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zhihui</groupId>
<artifactId>zhihui-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>zhihui-server</artifactId>
<name>zhihui-server</name>
<description>智汇平台服务端:门户 + 统一认证中心(OIDC)</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package com.zhihui.portal;
import com.zhihui.portal.config.PortalSecurityProperties;
import com.zhihui.portal.config.ZhihuiProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class})
public class ZhihuiApplication {
public static void main(String[] args) {
SpringApplication.run(ZhihuiApplication.class, args);
}
}
package com.zhihui.portal.app;
import com.zhihui.portal.app.dto.AppDtos.AdminStats;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
private final SysUserRepository userRepository;
private final PortalAppRepository appRepository;
public AdminController(SysUserRepository userRepository, PortalAppRepository appRepository) {
this.userRepository = userRepository;
this.appRepository = appRepository;
}
@GetMapping("/stats")
public AdminStats stats() {
return new AdminStats(userRepository.count(), appRepository.count(), appRepository.countByStatus(1));
}
}
package com.zhihui.portal.app;
import com.zhihui.portal.app.dto.AppDtos.AppResponse;
import com.zhihui.portal.app.dto.AppDtos.CreateAppRequest;
import com.zhihui.portal.app.dto.AppDtos.CreatedAppResponse;
import com.zhihui.portal.app.dto.AppDtos.SecretResponse;
import com.zhihui.portal.app.dto.AppDtos.UpdateAppRequest;
import com.zhihui.portal.app.dto.AppDtos.UpdateIntegrationRequest;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.user.UserService;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/apps")
public class AppAdminController {
private final AppService appService;
private final AuditLogService auditLogService;
private final UserService userService;
public AppAdminController(AppService appService, AuditLogService auditLogService,
UserService userService) {
this.appService = appService;
this.auditLogService = auditLogService;
this.userService = userService;
}
@GetMapping
public List<AppResponse> list() {
return appService.listAll();
}
@PostMapping
public CreatedAppResponse create(@Valid @RequestBody CreateAppRequest request) {
CreatedAppResponse created = appService.create(request);
auditLogService.recordCurrent(AuditEvents.APP_CREATE, "APP",
String.valueOf(created.app().id()),
"appCode=" + created.app().appCode() + "; appName=" + created.app().appName()
+ "; appUrl=" + created.app().appUrl(), true);
return created;
}
@PutMapping("/{id}")
public AppResponse update(@PathVariable Long id, @Valid @RequestBody UpdateAppRequest request) {
AppResponse updated = appService.update(id, request);
auditLogService.recordCurrent(AuditEvents.APP_UPDATE, "APP", String.valueOf(id),
"appCode=" + updated.appCode() + "; appName=" + updated.appName()
+ "; status=" + updated.status(), true);
return updated;
}
@PutMapping("/{id}/integration")
public AppResponse updateIntegration(@PathVariable Long id,
@Valid @RequestBody UpdateIntegrationRequest request,
@AuthenticationPrincipal SecurityUser currentUser) {
try {
userService.verifyAdminPassword(currentUser.getId(), request.adminPassword());
} catch (BizException exception) {
auditLogService.recordCurrent(AuditEvents.APP_INTEGRATION_UPDATE, "APP", String.valueOf(id),
"管理员密码校验失败", false);
throw exception;
}
AppResponse updated = appService.updateIntegration(id, request);
auditLogService.recordCurrent(AuditEvents.APP_INTEGRATION_UPDATE, "APP", String.valueOf(id),
"appCode=" + updated.appCode() + "; clientId=" + updated.clientId()
+ "; clientSecretChanged=" + StringUtils.hasText(request.clientSecret()), true);
return updated;
}
@PostMapping("/{id}/rotate-secret")
public SecretResponse rotateSecret(@PathVariable Long id) {
SecretResponse rotated = appService.rotateSecret(id);
auditLogService.recordCurrent(AuditEvents.APP_SECRET_ROTATE, "APP", String.valueOf(id),
"clientId=" + rotated.clientId(), true);
return rotated;
}
@GetMapping("/{id}/client-secret")
public ResponseEntity<SecretResponse> revealSecret(@PathVariable Long id) {
SecretResponse secret = appService.revealSecret(id);
auditLogService.recordCurrent(AuditEvents.APP_SECRET_VIEW, "APP", String.valueOf(id),
"clientId=" + secret.clientId(), true);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(secret);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
PortalApp app = appService.find(id);
appService.delete(id);
auditLogService.recordCurrent(AuditEvents.APP_DELETE, "APP", String.valueOf(id),
"appCode=" + app.getAppCode() + "; appName=" + app.getAppName(), true);
}
}
package com.zhihui.portal.app;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
@Component
public class ClientSecretCipher {
private static final SecureRandom RANDOM = new SecureRandom();
private static final int IV_LENGTH = 12;
private static final int TAG_BITS = 128;
private final SecretKeySpec key;
public ClientSecretCipher(
@Value("${zhihui.security.client-secret-key:zhihui-dev-client-secret-key-change-me}")
String keyMaterial) {
try {
byte[] keyBytes = MessageDigest.getInstance("SHA-256")
.digest(keyMaterial.getBytes(StandardCharsets.UTF_8));
this.key = new SecretKeySpec(keyBytes, "AES");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("AES-256 is unavailable", exception);
}
}
public String encrypt(String plaintext) {
if (plaintext == null) {
return null;
}
try {
byte[] iv = new byte[IV_LENGTH];
RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] packed = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, packed, 0, iv.length);
System.arraycopy(encrypted, 0, packed, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(packed);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("无法加密客户端密钥", exception);
}
}
public String decrypt(String encoded) {
if (encoded == null || encoded.isBlank()) {
return null;
}
try {
byte[] packed = Base64.getDecoder().decode(encoded);
if (packed.length <= IV_LENGTH) {
throw new IllegalArgumentException("客户端密钥密文格式不正确");
}
byte[] iv = Arrays.copyOfRange(packed, 0, IV_LENGTH);
byte[] encrypted = Arrays.copyOfRange(packed, IV_LENGTH, packed.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
} catch (GeneralSecurityException | IllegalArgumentException exception) {
throw new IllegalStateException("无法解密客户端密钥,请重置密钥", exception);
}
}
}
package com.zhihui.portal.app;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_app")
public class PortalApp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "app_code", nullable = false, unique = true, length = 64)
private String appCode;
@Column(name = "app_name", nullable = false, length = 100)
private String appName;
@Column(name = "app_url", nullable = false, length = 500)
private String appUrl;
@Column(name = "icon_url", length = 500)
private String iconUrl;
@Column(name = "description", length = 500)
private String description;
@Column(name = "client_id", nullable = false, unique = true, length = 100)
private String clientId;
@Column(name = "client_secret_cipher", length = 512)
private String clientSecretCipher;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "status", nullable = false)
private Integer status = 1;
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private LocalDateTime updatedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getAppCode() {
return appCode;
}
public void setAppCode(String appCode) {
this.appCode = appCode;
}
public String getAppName() {
return appName;
}
public void setAppName(String appName) {
this.appName = appName;
}
public String getAppUrl() {
return appUrl;
}
public void setAppUrl(String appUrl) {
this.appUrl = appUrl;
}
public String getIconUrl() {
return iconUrl;
}
public void setIconUrl(String iconUrl) {
this.iconUrl = iconUrl;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getClientSecretCipher() {
return clientSecretCipher;
}
public void setClientSecretCipher(String clientSecretCipher) {
this.clientSecretCipher = clientSecretCipher;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}
package com.zhihui.portal.app;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PortalAppRepository extends JpaRepository<PortalApp, Long> {
Optional<PortalApp> findByAppCode(String appCode);
boolean existsByAppCode(String appCode);
List<PortalApp> findAllByStatusOrderBySortOrderAscIdAsc(Integer status);
List<PortalApp> findAllByOrderBySortOrderAscIdAsc();
long countByStatus(Integer status);
}
package com.zhihui.portal.app.dto;
import com.zhihui.portal.app.PortalApp;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import java.time.format.DateTimeFormatter;
import java.util.List;
public final class AppDtos {
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private AppDtos() {
}
public record AppResponse(Long id, String appCode, String appName, String appUrl, String iconUrl,
String description, String clientId, Integer sortOrder, Integer status,
String createdAt, List<String> redirectUris,
List<String> postLogoutRedirectUris) {
public static AppResponse from(PortalApp app) {
return from(app, null);
}
public static AppResponse from(PortalApp app, RegisteredClient client) {
return new AppResponse(
app.getId(),
app.getAppCode(),
app.getAppName(),
app.getAppUrl(),
app.getIconUrl(),
app.getDescription(),
app.getClientId(),
app.getSortOrder(),
app.getStatus(),
app.getCreatedAt() == null ? null : app.getCreatedAt().format(DATE_TIME),
client == null ? List.of() : List.copyOf(client.getRedirectUris()),
client == null ? List.of() : List.copyOf(client.getPostLogoutRedirectUris())
);
}
}
public record AppCard(Long id, String appCode, String appName, String appUrl, String iconUrl,
String description, Integer position) {
public static AppCard from(PortalApp app, int position) {
return new AppCard(app.getId(), app.getAppCode(), app.getAppName(), app.getAppUrl(),
app.getIconUrl(), app.getDescription(), position);
}
}
public record MarketApp(Long id, String appCode, String appName, String appUrl, String iconUrl,
String description, boolean added) {
public static MarketApp from(PortalApp app, boolean added) {
return new MarketApp(app.getId(), app.getAppCode(), app.getAppName(), app.getAppUrl(),
app.getIconUrl(), app.getDescription(), added);
}
}
public record CreateAppRequest(
@NotBlank(message = "应用编码不能为空")
@Pattern(regexp = "^[a-z][a-z0-9-]{2,31}$", message = "应用编码需为 3-32 位小写字母、数字或中划线,且以字母开头")
String appCode,
@NotBlank(message = "应用名称不能为空")
@Size(max = 100, message = "应用名称不能超过 100 个字符")
String appName,
@NotBlank(message = "访问地址不能为空")
@Pattern(regexp = "^https?://.+", message = "访问地址必须以 http:// 或 https:// 开头")
@Size(max = 500, message = "访问地址不能超过 500 个字符")
String appUrl,
@Size(max = 500, message = "图标地址不能超过 500 个字符")
String iconUrl,
@Size(max = 500, message = "应用描述不能超过 500 个字符")
String description,
Integer sortOrder,
List<@Pattern(regexp = "^https?://.+", message = "回调地址必须以 http:// 或 https:// 开头") String> redirectUris,
List<@Pattern(regexp = "^https?://.+", message = "登出回调地址必须以 http:// 或 https:// 开头") String> postLogoutRedirectUris
) {
}
public record UpdateAppRequest(
@NotBlank(message = "应用名称不能为空")
@Size(max = 100, message = "应用名称不能超过 100 个字符")
String appName,
@NotBlank(message = "访问地址不能为空")
@Pattern(regexp = "^https?://.+", message = "访问地址必须以 http:// 或 https:// 开头")
@Size(max = 500, message = "访问地址不能超过 500 个字符")
String appUrl,
@Size(max = 500, message = "图标地址不能超过 500 个字符")
String iconUrl,
@Size(max = 500, message = "应用描述不能超过 500 个字符")
String description,
Integer sortOrder,
Integer status,
List<@Pattern(regexp = "^https?://.+", message = "回调地址必须以 http:// 或 https:// 开头") String> redirectUris,
List<@Pattern(regexp = "^https?://.+", message = "登出回调地址必须以 http:// 或 https:// 开头") String> postLogoutRedirectUris
) {
}
public record UpdateIntegrationRequest(
@NotBlank(message = "client_id 不能为空")
@Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]{2,99}$",
message = "client_id 需为 3-100 位字母、数字、点、下划线或中划线")
String clientId,
@Pattern(regexp = "^$|^.{8,200}$", message = "client_secret 留空或长度需为 8-200 位")
@Size(max = 200, message = "client_secret 不能超过 200 个字符")
String clientSecret,
List<@Pattern(regexp = "^https?://.+", message = "回调地址必须以 http:// 或 https:// 开头") String> redirectUris,
List<@Pattern(regexp = "^https?://.+", message = "登出回调地址必须以 http:// 或 https:// 开头") String> postLogoutRedirectUris,
@NotBlank(message = "请输入当前管理员密码")
@Size(max = 128, message = "管理员密码过长")
String adminPassword
) {
}
public record CreatedAppResponse(AppResponse app, String clientSecret) {
}
public record SecretResponse(String clientId, String clientSecret) {
}
public record ReorderAppsRequest(
@NotEmpty(message = "应用布局不能为空")
@Size(max = 200, message = "应用布局不能超过 200 个格子")
List<Long> slots
) {
}
public record AdminStats(long userCount, long appCount, long activeAppCount) {
}
}
package com.zhihui.portal.audit;
public final class AuditDtos {
private AuditDtos() {
}
public record AuditLogEntry(Long id, String eventType, Long userId, String username,
String clientIp, String userAgent, String targetType,
String targetId, String detail, boolean success, String createdAt) {
}
public record LoginRecord(Long id, String clientIp, String userAgent, String createdAt) {
}
}
package com.zhihui.portal.audit;
public final class AuditEvents {
public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS";
public static final String LOGIN_FAILURE = "LOGIN_FAILURE";
public static final String LOGIN_BLOCKED = "LOGIN_BLOCKED";
public static final String LOGOUT = "LOGOUT";
public static final String USER_CREATE = "USER_CREATE";
public static final String USER_UPDATE = "USER_UPDATE";
public static final String USER_PASSWORD_RESET = "USER_PASSWORD_RESET";
public static final String USER_DELETE = "USER_DELETE";
public static final String USER_IMPORT = "USER_IMPORT";
public static final String USER_BATCH_DELETE = "USER_BATCH_DELETE";
public static final String APP_CREATE = "APP_CREATE";
public static final String APP_UPDATE = "APP_UPDATE";
public static final String APP_INTEGRATION_UPDATE = "APP_INTEGRATION_UPDATE";
public static final String APP_DELETE = "APP_DELETE";
public static final String APP_SECRET_ROTATE = "APP_SECRET_ROTATE";
public static final String APP_SECRET_VIEW = "APP_SECRET_VIEW";
public static final String APP_ADD = "APP_ADD";
public static final String APP_REMOVE = "APP_REMOVE";
private AuditEvents() {
}
}
package com.zhihui.portal.audit;
import com.zhihui.portal.audit.AuditDtos.AuditLogEntry;
import com.zhihui.portal.common.PageResponse;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/admin/audit-logs")
public class AuditLogController {
private final AuditLogService auditLogService;
public AuditLogController(AuditLogService auditLogService) {
this.auditLogService = auditLogService;
}
@GetMapping
public PageResponse<AuditLogEntry> list(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "") String eventType,
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return auditLogService.list(keyword, eventType, startTime, endTime, page, size);
}
}
package com.zhihui.portal.auth;
import com.zhihui.portal.security.LoginIdentifierResolver;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class DbUserDetailsService implements UserDetailsService {
private static final String BAD_CREDENTIALS = "账号或密码错误";
private final SysUserRepository userRepository;
public DbUserDetailsService(SysUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
String normalized = StringUtils.hasText(loginId) ? loginId.trim() : "";
SysUser user;
if (PortalAccounts.isUsernameAccount(normalized)) {
user = userRepository.findByUsername(normalized)
.orElseThrow(() -> new UsernameNotFoundException(BAD_CREDENTIALS));
} else {
LoginIdentifierResolver.Type type = LoginIdentifierResolver.resolve(normalized);
String lookup = LoginIdentifierResolver.normalizeForLookup(normalized, type);
user = switch (type) {
case ID_CARD -> userRepository.findByIdCardNumber(lookup)
.orElseThrow(() -> new UsernameNotFoundException(BAD_CREDENTIALS));
case PHONE -> userRepository.findByPhone(lookup)
.orElseThrow(() -> new UsernameNotFoundException(BAD_CREDENTIALS));
case POLICE_NUMBER -> userRepository.findByPoliceNumber(lookup)
.orElseThrow(() -> new UsernameNotFoundException(BAD_CREDENTIALS));
};
if (PortalAccounts.isUsernameAccount(user.getUsername())) {
throw new UsernameNotFoundException(BAD_CREDENTIALS);
}
}
return new SecurityUser(
user.getId(),
user.getUsername(),
user.getPassword(),
user.getDisplayName(),
user.getPoliceNumber(),
user.getIdCardNumber(),
user.getPhone(),
user.getEmail(),
user.getRole(),
user.getStatus() != null && user.getStatus() == 1
);
}
}
package com.zhihui.portal.auth;
import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Set;
/**
* 门户内置用户名账号。这些账号只用于门户管理,不允许进入子系统。
*/
public final class PortalAccounts {
private static final Set<String> USERNAME_ACCOUNTS = Set.of("admin", "demo");
private PortalAccounts() {
}
public static boolean isUsernameAccount(String username) {
return StringUtils.hasText(username)
&& USERNAME_ACCOUNTS.contains(username.trim().toLowerCase(Locale.ROOT));
}
}
package com.zhihui.portal.auth;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serial;
import java.util.Collection;
import java.util.List;
/**
* 注意:该对象会被 Spring Authorization Server 序列化进 oauth2_authorization 表,
* 因此需要用 Jackson 注解声明可反序列化,且绝不能序列化密码。
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class SecurityUser implements UserDetails {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String username;
private final String password;
private final String displayName;
private final String policeNumber;
private final String idCardNumber;
private final String phoneNumber;
private final String email;
private final String role;
private final boolean enabled;
public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled) {
this.id = id;
this.username = username;
this.password = password;
this.displayName = displayName;
this.policeNumber = policeNumber;
this.idCardNumber = idCardNumber;
this.phoneNumber = phoneNumber;
this.email = email;
this.role = role;
this.enabled = enabled;
}
@JsonCreator
public SecurityUser(@JsonProperty("id") Long id,
@JsonProperty("username") String username,
@JsonProperty("displayName") String displayName,
@JsonProperty("policeNumber") String policeNumber,
@JsonProperty("idCardNumber") String idCardNumber,
@JsonProperty("phoneNumber") String phoneNumber,
@JsonProperty("email") String email,
@JsonProperty("role") String role,
@JsonProperty("enabled") boolean enabled) {
this(id, username, null, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled);
}
public Long getId() {
return id;
}
public String getDisplayName() {
return displayName;
}
public String getPoliceNumber() {
return policeNumber;
}
public String getIdCardNumber() {
return idCardNumber;
}
public String getPhoneNumber() {
return phoneNumber;
}
public String getEmail() {
return email;
}
public String getRole() {
return role;
}
@JsonIgnore
public List<String> getRoleList() {
return List.of(role);
}
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
}
@Override
@JsonIgnore
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return true;
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
package com.zhihui.portal.bootstrap;
import com.zhihui.portal.app.AppService;
import com.zhihui.portal.config.ZhihuiProperties;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class DataInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final SysUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AppService appService;
private final ZhihuiProperties properties;
public DataInitializer(SysUserRepository userRepository, PasswordEncoder passwordEncoder,
AppService appService, ZhihuiProperties properties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.appService = appService;
this.properties = properties;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
boolean createdAdmin = createUserIfAbsent("admin", properties.getAdminPassword(), "系统管理员", null, "ADMIN");
boolean createdDemo = createUserIfAbsent("demo", "Demo@123", "演示用户", "000002", "USER");
if (properties.getDemo().isEnabled()) {
ZhihuiProperties.Demo demo = properties.getDemo();
appService.ensureSystemApp(
"demo",
"示例子系统",
demo.getAppUrl(),
"/img/app-demo.svg",
"演示一次登录后直接进入的业务子系统",
10,
demo.getClientId(),
demo.getClientSecret());
if (demo.isSecondEnabled()) {
appService.ensureSystemApp(
"demo2",
"示例子系统二",
demo.getSecondAppUrl(),
"/img/app-demo-2.svg",
"第二个演示子系统,用于测试单点登录和个人排序",
20,
demo.getSecondClientId(),
demo.getSecondClientSecret());
}
if (createdAdmin) {
appService.addAppToUser("admin", "demo");
if (demo.isSecondEnabled()) {
appService.addAppToUser("admin", "demo2");
}
}
if (createdDemo) {
appService.addAppToUser("demo", "demo");
if (demo.isSecondEnabled()) {
appService.addAppToUser("demo", "demo2");
}
}
}
if (createdAdmin) {
log.warn("================================================================");
log.warn("已创建默认管理员:admin / {},请登录后立即修改密码!", properties.getAdminPassword());
log.warn("演示用户:demo / Demo@123");
log.warn("================================================================");
}
log.info("统一认证发行方:{}", properties.getIssuer());
}
private boolean createUserIfAbsent(String username, String password, String displayName,
String policeNumber, String role) {
if (userRepository.existsByUsername(username)) {
return false;
}
SysUser user = new SysUser();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setDisplayName(displayName);
user.setPoliceNumber(policeNumber);
user.setRole(role);
user.setStatus(1);
userRepository.save(user);
return true;
}
}
package com.zhihui.portal.common;
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}
package com.zhihui.portal.common;
import java.util.List;
public record PageResponse<T>(List<T> content, long total, int page, int size) {
}
This diff is collapsed.
UPDATE sys_user
SET police_number = NULL
WHERE username = 'admin';
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment