14 Commits

Author SHA1 Message Date
张翔 db517a2da8 fix: 更新测试用例适配 API 路径变更和 userType 参数
- Gateway 测试路径从 /api/auth/ 更新为 /api/admin/auth/
- 移除 /api/checkIn/ 公开路径测试,替换为 /api/admin/auth/ 前缀测试
- SysAuthHandlerTest generateToken stub 增加 ADMIN userType 参数
2026-06-03 11:59:15 +08:00
张翔 5237dfc1cb feat: Web 管理后台及 e2e 测试适配 API 路径变更
- e2e-tests API 路径统一更新为 /api/admin/ 和 /api/member/ 前缀
- Gateway isPublicPath 更新为 /api/admin/auth/ 和 /api/member/auth/
- Gateway 签名白名单路径更新
- 移除已废弃的 /api/checkIn/ 和 /api/auth/login 公开路径
2026-06-03 11:51:47 +08:00
张翔 981d8ef211 feat(security): SecurityConfig 路径规则适配 admin/member 前缀
- /api/auth/** 拆分为 /api/admin/auth/** 和 /api/member/auth/**
- 移除 /** 全放行规则,收紧安全策略
- 诊断路径更新为 /api/admin/diagnostic/**
2026-06-03 11:44:44 +08:00
张翔 244c599a82 feat(router): API 路径规范化,统一 admin/member 前缀
- 后台管理 API 统一前缀 /api/admin/**
- 前台会员 API 统一前缀 /api/member/**
- 签到路由从 /api/checkIn 迁移到 /api/member/checkIn
- 会员卡/交易路由统一到 /api/admin/ 下
2026-06-03 11:43:09 +08:00
张翔 c822719f51 feat(auth): MemberHandler 按 userType 校验区分管理员与会员
- admin 方法使用 getAdminUserIdOrThrow 校验 ADMIN 身份
- 会员自身方法使用 getMemberUserIdOrThrow 校验 MEMBER 身份
2026-06-03 11:41:00 +08:00
张翔 9753d7ebf5 feat(auth): AuthUtil 增加 getAdminUserIdOrThrow 和 getMemberUserIdOrThrow
- getAdminUserIdOrThrow: 校验 userType=ADMIN,否则返回 403
- getMemberUserIdOrThrow: 校验 userType=MEMBER,否则返回 403
- 保留 getMemberIdOrThrow 向后兼容
2026-06-03 11:29:47 +08:00
张翔 5c5bc6419a feat(auth): 内部 JwtAuthenticationFilter 增加 userType 传递
- 从 Token 解析 userType 并存入 authentication.details
- 供下游 AuthUtil 获取 userType 进行权限校验
2026-06-03 11:28:40 +08:00
张翔 47e9a65497 feat(auth): WechatAuthServiceImpl 生成 MEMBER 类型 Token,WechatLoginVO 增加 userType
- 微信登录时显式传入 userType=MEMBER 生成 Token
- WechatLoginVO 增加 userType 字段,登录响应返回 userType=MEMBER
2026-06-03 11:27:47 +08:00
张翔 1a58ee63d2 feat(auth): SysAuthHandler 生成 ADMIN 类型 Token,AuthResponse 增加 userType
- SysAuthHandler 登录时显式传入 userType=ADMIN
- AuthResponse 增加 userType 字段及四参数构造函数
- 旧三参数构造函数默认 userType=ADMIN
2026-06-03 11:25:52 +08:00
张翔 0e7918b31e feat(auth): Gateway JwtAuthenticationFilter 增加路径-userType 校验
- /api/admin/** 路径只允许 userType=ADMIN 访问
- /api/member/** 路径只允许 userType=MEMBER 访问
- 越权访问返回 403 Forbidden
- 请求头增加 X-User-Type 传递 userType
- isPublicPath 统一 /api/member/auth/ 为公开路径
- 补充 userType 路径校验相关单元测试
2026-06-03 11:24:24 +08:00
张翔 0e73bd4520 feat(auth): Gateway JwtUtil 增加 userType 解析支持
- 新增三参数 generateToken 方法,支持传入 userType
- 旧方法默认 userType=ADMIN,保持向后兼容
- 新增 getUserTypeFromToken 方法解析 Token 中的 userType
2026-06-03 11:22:09 +08:00
张翔 f66ff5c8f8 feat(auth): JwtTokenProvider 增加 userType 字段,支持 ADMIN/MEMBER 区分
- 新增四参数 generateToken 方法,支持传入 userType
- 旧方法默认 userType=ADMIN,保持向后兼容
- 新增 getUserTypeFromToken 方法解析 Token 中的 userType
- 补充 userType 相关单元测试
2026-06-03 11:21:20 +08:00
张翔 005c09c99c feat(auth): 添加 UserType 枚举常量,区分 ADMIN 和 MEMBER 用户类型 2026-06-03 11:17:25 +08:00
future 08cf82ac83 签到模块 2026-06-02 09:56:37 +08:00
55 changed files with 1429 additions and 231 deletions
+7 -7
View File
@@ -5,7 +5,7 @@ test.describe('认证和授权测试', () => {
let userId: number; let userId: number;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', { const response = await request.post('http://localhost:8080/api/admin/auth/login', {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
@@ -28,7 +28,7 @@ test.describe('认证和授权测试', () => {
}); });
await test.step('发送登录请求', async () => { await test.step('发送登录请求', async () => {
const response = await page.request.post('http://localhost:8080/api/auth/login', { const response = await page.request.post('http://localhost:8080/api/admin/auth/login', {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
@@ -78,7 +78,7 @@ test.describe('认证和授权测试', () => {
}); });
await test.step('查询指定用户信息', async () => { await test.step('查询指定用户信息', async () => {
const response = await page.request.get(`http://localhost:8080/api/users/${userId}`, { const response = await page.request.get(`http://localhost:8080/api/admin/users/${userId}`, {
headers: { headers: {
'Authorization': `Bearer ${authToken}` 'Authorization': `Bearer ${authToken}`
} }
@@ -98,10 +98,10 @@ test.describe('认证和授权测试', () => {
test('权限验证测试', async ({ page }) => { test('权限验证测试', async ({ page }) => {
await test.step('测试访问受保护的API', async () => { await test.step('测试访问受保护的API', async () => {
const protectedEndpoints = [ const protectedEndpoints = [
'/api/users', '/api/admin/users',
'/api/roles', '/api/admin/roles',
'/api/menus', '/api/admin/menus',
'/api/config' '/api/admin/config'
]; ];
for (const endpoint of protectedEndpoints) { for (const endpoint of protectedEndpoints) {
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('参数配置功能测试', () => {
let authToken: string; let authToken: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', { const response = await request.post('http://localhost:8080/api/admin/auth/login', {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('字典管理功能测试', () => {
let authToken: string; let authToken: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', { const response = await request.post('http://localhost:8080/api/admin/auth/login', {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
+6 -6
View File
@@ -269,7 +269,7 @@ async function verifyAllServices(): Promise<void> {
console.log(' 验证网关到后端的连通性...'); console.log(' 验证网关到后端的连通性...');
try { try {
const response = await fetch('http://localhost:8080/api/auth/login', { const response = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }), body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -316,7 +316,7 @@ async function waitForBackendReady(): Promise<void> {
console.log(`✅ 后端服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`); console.log(`✅ 后端服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try { try {
const loginTest = await fetch('http://localhost:8084/api/auth/login', { const loginTest = await fetch('http://localhost:8084/api/admin/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }), body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -364,7 +364,7 @@ async function waitForGatewayReady(): Promise<void> {
console.log(`✅ 网关服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`); console.log(`✅ 网关服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try { try {
const loginTest = await fetch('http://localhost:8080/api/auth/login', { const loginTest = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }), body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -425,7 +425,7 @@ async function waitForFrontendReady(): Promise<void> {
async function cleanupTestData(): Promise<void> { async function cleanupTestData(): Promise<void> {
try { try {
// 登录获取token(通过网关) // 登录获取token(通过网关)
const loginResponse = await fetch('http://localhost:8080/api/auth/login', { const loginResponse = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -458,7 +458,7 @@ async function cleanupTestData(): Promise<void> {
for (const user of users) { for (const user of users) {
if (user.id > 10) { if (user.id > 10) {
try { try {
await fetch(`http://localhost:8080/api/users/${user.id}`, { await fetch(`http://localhost:8080/api/admin/users/${user.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
@@ -486,7 +486,7 @@ async function cleanupTestData(): Promise<void> {
for (const role of roles) { for (const role of roles) {
if (role.id > 4) { if (role.id > 4) {
try { try {
await fetch(`http://localhost:8080/api/roles/${role.id}`, { await fetch(`http://localhost:8080/api/admin/roles/${role.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
@@ -50,7 +50,7 @@ test.describe('管理员完整工作流', () => {
await test.step('提交表单', async () => { await test.step('提交表单', async () => {
const [response] = await Promise.all([ const [response] = await Promise.all([
page.waitForResponse(resp => page.waitForResponse(resp =>
resp.url().includes('/api/roles') && resp.request().method() === 'POST', resp.url().includes('/api/admin/roles') && resp.request().method() === 'POST',
{ timeout: 10000 } { timeout: 10000 }
).catch(() => null), ).catch(() => null),
page.locator('.el-dialog button:has-text("确定")').click() page.locator('.el-dialog button:has-text("确定")').click()
@@ -112,7 +112,7 @@ test.describe('用户权限边界验证', () => {
}); });
await test.step('尝试访问受限API', async () => { await test.step('尝试访问受限API', async () => {
const response = await page.request.get('/api/users?page=0&size=10'); const response = await page.request.get('/api/admin/users?page=0&size=10');
expect([200, 401, 403]).toContain(response.status()); expect([200, 401, 403]).toContain(response.status());
}); });
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('菜单管理功能测试', () => {
let authToken: string; let authToken: string;
test.beforeAll(async ({ request }) => { test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', { const response = await request.post('http://localhost:8080/api/admin/auth/login', {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
+10 -10
View File
@@ -10,7 +10,7 @@ export class ApiClient {
} }
async login(username: string, password: string): Promise<{ token: string; userId: number }> { async login(username: string, password: string): Promise<{ token: string; userId: number }> {
const response = await this.request.post(`${this.baseURL}/api/auth/login`, { const response = await this.request.post(`${this.baseURL}/api/admin/auth/login`, {
data: { data: {
username, username,
password, password,
@@ -29,7 +29,7 @@ export class ApiClient {
} }
async logout(token: string): Promise<void> { async logout(token: string): Promise<void> {
await this.request.post(`${this.baseURL}/api/auth/logout`, { await this.request.post(`${this.baseURL}/api/admin/auth/logout`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -37,7 +37,7 @@ export class ApiClient {
} }
async getUsers(token: string): Promise<any[]> { async getUsers(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/users`, { const response = await this.request.get(`${this.baseURL}/api/admin/users`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -51,7 +51,7 @@ export class ApiClient {
} }
async createUser(token: string, userData: any): Promise<any> { async createUser(token: string, userData: any): Promise<any> {
const response = await this.request.post(`${this.baseURL}/api/users`, { const response = await this.request.post(`${this.baseURL}/api/admin/users`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -66,7 +66,7 @@ export class ApiClient {
} }
async updateUser(token: string, userId: number, userData: any): Promise<any> { async updateUser(token: string, userId: number, userData: any): Promise<any> {
const response = await this.request.put(`${this.baseURL}/api/users/${userId}`, { const response = await this.request.put(`${this.baseURL}/api/admin/users/${userId}`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -81,7 +81,7 @@ export class ApiClient {
} }
async deleteUser(token: string, userId: number): Promise<void> { async deleteUser(token: string, userId: number): Promise<void> {
const response = await this.request.delete(`${this.baseURL}/api/users/${userId}`, { const response = await this.request.delete(`${this.baseURL}/api/admin/users/${userId}`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -93,7 +93,7 @@ export class ApiClient {
} }
async getRoles(token: string): Promise<any[]> { async getRoles(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/roles`, { const response = await this.request.get(`${this.baseURL}/api/admin/roles`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -107,7 +107,7 @@ export class ApiClient {
} }
async createRole(token: string, roleData: any): Promise<any> { async createRole(token: string, roleData: any): Promise<any> {
const response = await this.request.post(`${this.baseURL}/api/roles`, { const response = await this.request.post(`${this.baseURL}/api/admin/roles`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -122,7 +122,7 @@ export class ApiClient {
} }
async deleteRole(token: string, roleId: number): Promise<void> { async deleteRole(token: string, roleId: number): Promise<void> {
const response = await this.request.delete(`${this.baseURL}/api/roles/${roleId}`, { const response = await this.request.delete(`${this.baseURL}/api/admin/roles/${roleId}`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -134,7 +134,7 @@ export class ApiClient {
} }
async getMenus(token: string): Promise<any[]> { async getMenus(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/menus`, { const response = await this.request.get(`${this.baseURL}/api/admin/menus`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
+4 -4
View File
@@ -55,7 +55,7 @@ export class TestDataManager {
} }
static async createTestUser(request: APIRequestContext, userData: TestUser): Promise<any> { static async createTestUser(request: APIRequestContext, userData: TestUser): Promise<any> {
const response = await request.post(`${this.apiBaseUrl}/api/users`, { const response = await request.post(`${this.apiBaseUrl}/api/admin/users`, {
data: userData, data: userData,
}); });
@@ -75,7 +75,7 @@ export class TestDataManager {
} }
static async createTestRole(request: APIRequestContext, roleData: TestRole): Promise<any> { static async createTestRole(request: APIRequestContext, roleData: TestRole): Promise<any> {
const response = await request.post(`${this.apiBaseUrl}/api/roles`, { const response = await request.post(`${this.apiBaseUrl}/api/admin/roles`, {
data: roleData, data: roleData,
}); });
@@ -100,7 +100,7 @@ export class TestDataManager {
return; return;
} }
const response = await request.delete(`${this.apiBaseUrl}/api/users/${userData.id}`); const response = await request.delete(`${this.apiBaseUrl}/api/admin/users/${userData.id}`);
if (!response.ok()) { if (!response.ok()) {
console.warn(`Failed to delete test user ${username}: ${await response.text()}`); console.warn(`Failed to delete test user ${username}: ${await response.text()}`);
} }
@@ -114,7 +114,7 @@ export class TestDataManager {
return; return;
} }
const response = await request.delete(`${this.apiBaseUrl}/api/roles/${roleData.id}`); const response = await request.delete(`${this.apiBaseUrl}/api/admin/roles/${roleData.id}`);
if (!response.ok()) { if (!response.ok()) {
console.warn(`Failed to delete test role ${roleKey}: ${await response.text()}`); console.warn(`Failed to delete test role ${roleKey}: ${await response.text()}`);
} }
+48
View File
@@ -0,0 +1,48 @@
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# Virtual machine crash logs
hs_err_pid*
replay_pid*
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# IDE
.idea/
*.iml
.vscode/
.settings/
.classpath
.project
# OS
.DS_Store
Thumbs.db
+242
View File
@@ -0,0 +1,242 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-checkIn</artifactId>
<packaging>jar</packaging>
<name>Gym CheckIn</name>
<description>Check-In Management Module - Member Attendance Services</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</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>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-reactor</artifactId>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>r2dbc-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- 添加 ZXing JavaSE 扩展(用于生成图片) -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>default-jar</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.6.0</version>
<dependencies>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs</artifactId>
<version>4.8.6</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>spotbugs-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<effort>Max</effort>
<threshold>High</threshold>
<failOnError>true</failOnError>
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,46 @@
package cn.novalon.gym.manage.checkIn.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "qr.config")
public class QRCodeConfig {
/**
* 二维码宽度(像素)
*/
private Integer width = 300;
/**
* 二维码高度(像素)
*/
private Integer height = 300;
/**
* 白边宽度
*/
private Integer margin = 1;
/**
* 容错率:L, M, Q, H
*/
private String errorCorrection = "M";
/**
* 图片格式:png, jpg
*/
private String format = "png";
/**
* 是否启用Logo
*/
private Boolean logoEnabled = false;
/**
* Logo路径
*/
private String logoPath = "";
}
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.checkIn.config;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class WebSocketConfig {
@Autowired
private MyWebSocketHandler myWebSocketHandler;
/**
* 注册 WebSocket 路由映射
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
*/
@Bean
public HandlerMapping webSocketMapping() {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/webSocket/checkIn", myWebSocketHandler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(10); // 设置优先级
return mapping;
}
/**
* 注册 WebSocket 处理器适配器(必须)
*/
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.checkIn.constant;
import java.time.LocalDate;
import java.util.UUID;
/**
* 打卡模块 Redis 键常量
*
* @author 付嘉
* @date 2026-05-30
*/
public final class QRRedisKey {
private static final String SEPARATOR = ":";
private static final String QRCODE_USER_DAILY = "qrcode:user:daily";
private static final String QRCODE_CONTENT = "QR_";
private QRRedisKey() {
// 私有构造,防止实例化
}
/**
* 用户当日二维码
* 格式:qrcode:user:daily:{userId}:{date}
* 示例:qrcode:user:daily:1001:2026-05-30
*/
public static String qrcodeUserDaily(Long userId, LocalDate date) {
return QRCODE_USER_DAILY + SEPARATOR + userId + SEPARATOR + date;
}
/**
* 用户当日二维码(今天)
*/
public static String qrcodeUserToday(Long userId) {
return qrcodeUserDaily(userId, LocalDate.now());
}
/**
* 生成二维码内容(每个用户每次调用都不同)
*/
public static String generateQrcodeContent() {
return QRCODE_CONTENT + UUID.randomUUID().toString().replace("-", "");
}
}
@@ -0,0 +1,13 @@
package cn.novalon.gym.manage.checkIn.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class QRCodeDto {
private String qrContent;
private boolean isUsed;
}
@@ -0,0 +1,41 @@
package cn.novalon.gym.manage.checkIn.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("sign_in_record")
public class SignInRecord {
@Id
private Long id;
// 会员ID
@Column("member_id")
private Long memberId;
// 签到日期
@Column("sign_in_date")
private LocalDate signInDate;
// 签到时间
@Column("sign_in_time")
private LocalDateTime signInTime;
// 创建时间
@CreatedDate
@Column("created_at")
private LocalDateTime createdAt;
}
@@ -0,0 +1,69 @@
package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class CheckInHandler {
private final AuthUtil authUtil;
private final CheckServiceImpl checkService;
/**
* 签到
*
* POST /api/checkIn
*
*/
public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
return request.bodyToMono(Map.class)
.flatMap(body -> {
String qrContent = (String) body.get("qrContent");
log.info("收到签到请求, memberId: {}, qrContent: {}", memberId, qrContent);
return checkService.checkIn(memberId, qrContent)
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "签到成功")));
})
.onErrorResume(e -> {
log.error("签到失败", e);
return ServerResponse.status(HttpStatus.BAD_REQUEST)
.bodyValue(Map.of("code", 400, "message", e.getMessage()));
});
}
/**
* 获取二维码
*
* GET /api/checkin/qrcode
*
*/
public Mono<ServerResponse> getQRCode(ServerRequest request) {
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
log.info("收到用户{}获取二维码请求", memberId);
return checkService.getQRCode(memberId)
.flatMap(qrCodeVo -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(qrCodeVo));
}
}
@@ -0,0 +1,11 @@
package cn.novalon.gym.manage.checkIn.service;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import reactor.core.publisher.Mono;
public interface ICheckInService {
Mono<QRCodeVo> getQRCode(Long memberId);
Mono<String> checkIn(Long memberId, String qrContent);
}
@@ -0,0 +1,97 @@
package cn.novalon.gym.manage.checkIn.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import cn.hutool.json.JSONUtil;
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
import cn.novalon.gym.manage.checkIn.service.ICheckInService;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class CheckServiceImpl implements ICheckInService {
@Autowired
private final QRCodeConfig qrCodeConfig;
private final RedisUtil redisUtil;
@Override
public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始查询会员信息");
// TODO: 获取会员信息 - 查会员卡有效期/剩余次数,过期返回,先查缓存,缓存不存在则查数据库
// if (member有效期过了) throw new RuntimeException("会员有效期已过,拒绝生成二维码");
log.info("会员信息查询完成");
log.info("开始生成二维码");
String qrContent = QRRedisKey.generateQrcodeContent();
Map<String, Object> redisMap = new HashMap<>();
redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false);
return redisUtil.setWithExpire(
RedisKeyConstants.QRCODE_USER_DAILY+memberId+LocalDate.now(),
redisMap,
getSecondsUntilEndOfDay()
)
.then(Mono.fromSupplier(() -> {
String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent,
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
return new QRCodeVo(qrCodeBase64,false,qrCodeConfig.getWidth(),qrCodeConfig.getHeight());
}));
}
@Override
public Mono<String> checkIn(Long memberId, String qrContent) {
String key = RedisKeyConstants.QRCODE_USER_DAILY+memberId+LocalDate.now();
return redisUtil.get(key)
.flatMap(cachedQrContent -> {
if (cachedQrContent != null) {
// 匹配成功,执行签到逻辑
Map<String, Object> map = JSONUtil.parseObj(cachedQrContent);
if(map.get("qrContent").equals(qrContent)){
if((boolean)map.get("isUsed")){
log.error("重复签到");
throw new RuntimeException("您已经在"+map.get("checkInTime")+"完成签到,请勿重复签到");
}
log.info("二维码匹配成功,memberId: {}", memberId);
// TODO查会员卡缓存,按照卡有效期进行扣减次数,没有缓存查数据库
map.put("isUsed", true);
map.put("checkInTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return redisUtil.set(key,map).
then(Mono.just("签到成功"));
}
}
throw new RuntimeException("二维码无效");
})
.switchIfEmpty(Mono.error(new RuntimeException("二维码已过期或不存在")));
}
private long getSecondsUntilEndOfDay() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
if (now.isAfter(endOfDay)) return 1;
return ChronoUnit.SECONDS.between(now, endOfDay);
}
}
@@ -0,0 +1,17 @@
package cn.novalon.gym.manage.checkIn.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class QRCodeVo {
private String qrCodeBase64;
private boolean isUsed;
private Integer width;
private Integer height;
}
@@ -0,0 +1,98 @@
package cn.novalon.gym.manage.checkIn.websocket;
import cn.hutool.json.JSONUtil;
import cn.novalon.gym.manage.checkIn.dto.QRCodeDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class MyWebSocketHandler implements WebSocketHandler {
// 存储所有连接
private static final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@Override
public Mono<Void> handle(WebSocketSession session) {
String sessionId = session.getId();
// 连接建立
sessions.put(sessionId, session);
log.info("WebSocket 连接建立,sessionId{},当前连接数:{}", sessionId, sessions.size());
// 处理接收到的消息
Flux<WebSocketMessage> output = session.receive()
.doOnNext(message -> {
String payload = message.getPayloadAsText();
log.info("收到消息:{}", payload);
})
.map(message -> {
String payload = message.getPayloadAsText();
String response = processMessage(payload, sessionId);
return session.textMessage(response);
});
// 连接关闭时清理
return session.send(output)
.doFinally(signalType -> {
sessions.remove(sessionId);
log.info("WebSocket 连接关闭,sessionId{},剩余连接数:{}", sessionId, sessions.size());
});
}
/**
* 处理消息逻辑
*/
private String processMessage(String message, String sessionId) {
try {
// 解析 QRCodeDto
QRCodeDto qrCodeDto = JSONUtil.toBean(message, QRCodeDto.class);
String response;
// 判断二维码是否有效
if (qrCodeDto.getQrContent() != null
&& !qrCodeDto.getQrContent().isEmpty()
&& !qrCodeDto.isUsed()) {
// 有效:qrContent 有值且 isUsed 为 false
response = "正在进行签到";
// 可选:将二维码标记为已使用(需要调用后端服务)
// checkInService.handleCheckIn(qrCodeDto.getQrContent());
log.info("二维码有效,sessionId{}qrContent{}", sessionId, qrCodeDto.getQrContent());
} else {
// 无效:qrContent 为空 或 isUsed 为 true
String reason = "";
if (qrCodeDto.getQrContent() == null || qrCodeDto.getQrContent().isEmpty()) {
reason = "二维码内容为空";
} else if (qrCodeDto.isUsed()) {
reason = "二维码已被使用";
}
response = "二维码无效:" + reason;
log.warn("二维码无效,sessionId{},原因:{}", sessionId, reason);
}
return response;
} catch (Exception e) {
log.error("解析消息失败,sessionId{}", sessionId, e);
return "消息格式错误";
}
}
/**
* 获取当前在线连接数
*/
public static int getOnlineCount() {
return sessions.size();
}
}
@@ -0,0 +1,10 @@
# 二维码配置
qr:
config:
width: 300 # 二维码宽度(像素)
height: 300 # 二维码高度(像素)
margin: 1 # 白边宽度(像素)
format: png # 图片格式:png / jpg
error-correction: L #容错率:L, M, Q, H,如果启用Logologo-enabled: true),必须设置为 H
logo-enabled: false # 是否启用Logo(启用时error-correction必须为H
# logo-path: static/logo.png # Logo图片路径(支持相对路径或绝对路径)
@@ -0,0 +1,12 @@
package cn.novalon.gym.manage.checkin;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class CheckInModuleTest {
@Test
public void contextLoads() {
}
}
@@ -0,0 +1 @@
# Test Configuration
@@ -43,7 +43,7 @@ public class MemberHandler {
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) { public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("获取会员信息, memberId: {}", memberId); log.info("获取会员信息, memberId: {}", memberId);
@@ -56,7 +56,7 @@ public class MemberHandler {
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息") @Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
public Mono<ServerResponse> updateMemberInfo(ServerRequest request) { public Mono<ServerResponse> updateMemberInfo(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("更新会员信息, memberId: {}", memberId); log.info("更新会员信息, memberId: {}", memberId);
@@ -70,7 +70,7 @@ public class MemberHandler {
@Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号") @Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号")
public Mono<ServerResponse> bindPhone(ServerRequest request) { public Mono<ServerResponse> bindPhone(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberUserIdOrThrow(request);
String phoneCode = request.queryParam("phoneCode").orElse(""); String phoneCode = request.queryParam("phoneCode").orElse("");
@@ -87,7 +87,7 @@ public class MemberHandler {
@Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号") @Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号")
public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) { public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("查询服务号关注状态, memberId: {}", memberId); log.info("查询服务号关注状态, memberId: {}", memberId);
@@ -102,7 +102,7 @@ public class MemberHandler {
@Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号") @Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号")
public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) { public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id"); String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -134,7 +134,7 @@ public class MemberHandler {
@Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息") @Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息")
public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id"); String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -162,7 +162,7 @@ public class MemberHandler {
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息") @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id"); String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -181,7 +181,7 @@ public class MemberHandler {
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
public Mono<ServerResponse> searchMembers(ServerRequest request) { public Mono<ServerResponse> searchMembers(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getAdminUserIdOrThrow(request);
String keyword = request.queryParam("searchValue").orElse(null); String keyword = request.queryParam("searchValue").orElse(null);
Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
@@ -212,7 +212,7 @@ public class MemberHandler {
@Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表") @Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表")
public Mono<ServerResponse> getAllMembers(ServerRequest request) { public Mono<ServerResponse> getAllMembers(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getAdminUserIdOrThrow(request);
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
@@ -3,7 +3,7 @@ package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.service.IMemberCardRecordService; import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl; package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction; import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
@@ -15,7 +16,7 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService; import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
import cn.novalon.gym.manage.member.util.RedisUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -17,7 +17,7 @@ import cn.novalon.gym.manage.member.service.MemberService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.BeanConvertUtil; import cn.novalon.gym.manage.member.util.BeanConvertUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils; import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO; import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
import cn.novalon.gym.manage.member.vo.MemberDetailVO; import cn.novalon.gym.manage.member.vo.MemberDetailVO;
import cn.novalon.gym.manage.member.vo.MemberInfoVO; import cn.novalon.gym.manage.member.vo.MemberInfoVO;
@@ -5,7 +5,7 @@ import cn.novalon.gym.manage.member.entity.RefundApplication;
import cn.novalon.gym.manage.member.enums.RefundStatus; import cn.novalon.gym.manage.member.enums.RefundStatus;
import cn.novalon.gym.manage.member.repository.RefundApplicationRepository; import cn.novalon.gym.manage.member.repository.RefundApplicationRepository;
import cn.novalon.gym.manage.member.service.IRefundApplicationService; import cn.novalon.gym.manage.member.service.IRefundApplicationService;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -4,7 +4,7 @@ import cn.novalon.gym.manage.common.exception.ErrorCode;
import cn.novalon.gym.manage.common.exception.SystemException; import cn.novalon.gym.manage.common.exception.SystemException;
import cn.novalon.gym.manage.member.config.WechatProperties; import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.service.WechatApiService; import cn.novalon.gym.manage.member.service.WechatApiService;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -16,7 +16,7 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils; import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.MemberNoGenerator; import cn.novalon.gym.manage.member.util.MemberNoGenerator;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil; import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.member.vo.WechatLoginVO; import cn.novalon.gym.manage.member.vo.WechatLoginVO;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider; import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
@@ -303,9 +303,9 @@ public class WechatAuthServiceImpl implements WechatAuthService {
} }
List<String> roles = new ArrayList<>(); List<String> roles = new ArrayList<>();
String accessToken = jwtTokenProvider.generateToken(String.valueOf(member.getId()), member.getId(), roles); String accessToken = jwtTokenProvider.generateToken(String.valueOf(member.getId()), member.getId(), roles, "MEMBER");
log.info("JWT Token 生成成功, memberId: {}", member.getId()); log.info("JWT Token 生成成功, memberId: {}, userType=MEMBER", member.getId());
int expiresIn = 86400; int expiresIn = 86400;
@@ -316,6 +316,7 @@ public class WechatAuthServiceImpl implements WechatAuthService {
.expiresIn(expiresIn) .expiresIn(expiresIn)
.isNewUser(isNewUser) .isNewUser(isNewUser)
.needCompleteInfo(needCompleteInfo) .needCompleteInfo(needCompleteInfo)
.userType("MEMBER")
.build(); .build();
} }
} }
@@ -8,7 +8,7 @@ import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository; import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.service.WechatOfficialService; import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.EsSyncUtils; import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.vo.WechatUserInfoVO; import cn.novalon.gym.manage.member.vo.WechatUserInfoVO;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -35,4 +35,7 @@ public class WechatLoginVO {
// 是否需要补全信息(昵称、手机号等) // 是否需要补全信息(昵称、手机号等)
private Boolean needCompleteInfo; private Boolean needCompleteInfo;
// 用户类型(MEMBER
private String userType;
} }
+5
View File
@@ -43,6 +43,11 @@
<artifactId>gym-member</artifactId> <artifactId>gym-member</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-checkIn</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -1,6 +1,7 @@
package cn.novalon.gym.manage.app.config; package cn.novalon.gym.manage.app.config;
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
import cn.novalon.gym.manage.file.handler.SysFileHandler; import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
@@ -33,7 +34,7 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r
* *
* 文件定义:配置WebFlux函数式路由,将HTTP请求映射到对应的Handler方法 * 文件定义:配置WebFlux函数式路由,将HTTP请求映射到对应的Handler方法
* 涉及业务:用户、角色、字典、菜单、公告、文件等所有RESTful API路由 * 涉及业务:用户、角色、字典、菜单、公告、文件等所有RESTful API路由
* 算法:使用RouterFunctions.route()构建函数式路由规则 * 路由规范:后台管理 API 统一前缀 /api/admin/**,前台会员 API 统一前缀 /api/member/**
* *
* @author 张翔 * @author 张翔
* @date 2026-03-13 * @date 2026-03-13
@@ -62,154 +63,155 @@ public class SystemRouter {
PasswordDiagnosticHandler passwordDiagnosticHandler, PasswordDiagnosticHandler passwordDiagnosticHandler,
MemberCardHandler memberCardHandler, MemberCardHandler memberCardHandler,
MemberCardRecordHandler memberCardRecordHandler, MemberCardRecordHandler memberCardRecordHandler,
MemberCardTransactionHandler memberCardTransactionHandler) { MemberCardTransactionHandler memberCardTransactionHandler,
CheckInHandler checkInHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由(管理端) ==========
.GET("/api/diagnostic/password", passwordDiagnosticHandler::diagnose) .GET("/api/admin/diagnostic/password", passwordDiagnosticHandler::diagnose)
// ========== 字典路由 ========== // ========== 字典路由(管理端) ==========
.GET("/api/dictionaries", dictionaryHandler::getAllDictionaries) .GET("/api/admin/dictionaries", dictionaryHandler::getAllDictionaries)
.GET("/api/dictionaries/{id}", dictionaryHandler::getDictionaryById) .GET("/api/admin/dictionaries/{id}", dictionaryHandler::getDictionaryById)
.GET("/api/dictionaries/type/{type}", dictionaryHandler::getDictionariesByType) .GET("/api/admin/dictionaries/type/{type}", dictionaryHandler::getDictionariesByType)
.GET("/api/dictionaries/check/exists", dictionaryHandler::checkTypeAndCodeExists) .GET("/api/admin/dictionaries/check/exists", dictionaryHandler::checkTypeAndCodeExists)
.POST("/api/dictionaries", dictionaryHandler::createDictionary) .POST("/api/admin/dictionaries", dictionaryHandler::createDictionary)
.PUT("/api/dictionaries/{id}", dictionaryHandler::updateDictionary) .PUT("/api/admin/dictionaries/{id}", dictionaryHandler::updateDictionary)
.DELETE("/api/dictionaries/{id}", dictionaryHandler::deleteDictionary) .DELETE("/api/admin/dictionaries/{id}", dictionaryHandler::deleteDictionary)
// ========== 用户路由 ========== // ========== 用户路由(管理端) ==========
.GET("/api/users", userHandler::getAllUsers) .GET("/api/admin/users", userHandler::getAllUsers)
.GET("/api/users/page", userHandler::getUsersByPage) .GET("/api/admin/users/page", userHandler::getUsersByPage)
.GET("/api/users/count", userHandler::getUserCount) .GET("/api/admin/users/count", userHandler::getUserCount)
.GET("/api/users/username/{username}", userHandler::getUserByUsername) .GET("/api/admin/users/username/{username}", userHandler::getUserByUsername)
.GET("/api/users/check/username", userHandler::checkUsernameExists) .GET("/api/admin/users/check/username", userHandler::checkUsernameExists)
.GET("/api/users/check/email", userHandler::checkEmailExists) .GET("/api/admin/users/check/email", userHandler::checkEmailExists)
.POST("/api/users", userHandler::createUser) .POST("/api/admin/users", userHandler::createUser)
.GET("/api/users/{id}", userHandler::getUserById) .GET("/api/admin/users/{id}", userHandler::getUserById)
.PUT("/api/users/{id}", userHandler::updateUser) .PUT("/api/admin/users/{id}", userHandler::updateUser)
.DELETE("/api/users/{id}", userHandler::deleteUser) .DELETE("/api/admin/users/{id}", userHandler::deleteUser)
.POST("/api/users/{id}/action/change-password", userHandler::changePassword) .POST("/api/admin/users/{id}/action/change-password", userHandler::changePassword)
.POST("/api/users/{id}/action/logical-delete", userHandler::logicalDeleteUser) .POST("/api/admin/users/{id}/action/logical-delete", userHandler::logicalDeleteUser)
.POST("/api/users/logical-delete", userHandler::logicalDeleteUsers) .POST("/api/admin/users/logical-delete", userHandler::logicalDeleteUsers)
.POST("/api/users/action/restore", userHandler::restoreUsers) .POST("/api/admin/users/action/restore", userHandler::restoreUsers)
.POST("/api/users/{id}/action/restore", userHandler::restoreUser) .POST("/api/admin/users/{id}/action/restore", userHandler::restoreUser)
.GET("/api/users/{id}/roles", userHandler::getUserRoles) .GET("/api/admin/users/{id}/roles", userHandler::getUserRoles)
.POST("/api/users/{id}/roles", userHandler::assignRoles) .POST("/api/admin/users/{id}/roles", userHandler::assignRoles)
// ========== 菜单路由 ========== // ========== 菜单路由(管理端) ==========
.GET("/api/menus", menuHandler::getAllMenus) .GET("/api/admin/menus", menuHandler::getAllMenus)
.GET("/api/menus/tree", menuHandler::getMenuTree) .GET("/api/admin/menus/tree", menuHandler::getMenuTree)
.GET("/api/menus/{id}", menuHandler::getMenuById) .GET("/api/admin/menus/{id}", menuHandler::getMenuById)
.POST("/api/menus", menuHandler::createMenu) .POST("/api/admin/menus", menuHandler::createMenu)
.PUT("/api/menus/{id}", menuHandler::updateMenu) .PUT("/api/admin/menus/{id}", menuHandler::updateMenu)
.DELETE("/api/menus/{id}", menuHandler::deleteMenu) .DELETE("/api/admin/menus/{id}", menuHandler::deleteMenu)
// ========== 角色路由 ========== // ========== 角色路由(管理端) ==========
.GET("/api/roles", roleHandler::getAllRoles) .GET("/api/admin/roles", roleHandler::getAllRoles)
.GET("/api/roles/page", roleHandler::getRolesByPage) .GET("/api/admin/roles/page", roleHandler::getRolesByPage)
.GET("/api/roles/count", roleHandler::getRoleCount) .GET("/api/admin/roles/count", roleHandler::getRoleCount)
.GET("/api/roles/name/{roleName}", roleHandler::getRoleByName) .GET("/api/admin/roles/name/{roleName}", roleHandler::getRoleByName)
.GET("/api/roles/check-name", roleHandler::checkNameExists) .GET("/api/admin/roles/check-name", roleHandler::checkNameExists)
.GET("/api/roles/{id}", roleHandler::getRoleById) .GET("/api/admin/roles/{id}", roleHandler::getRoleById)
.POST("/api/roles", roleHandler::createRole) .POST("/api/admin/roles", roleHandler::createRole)
.PUT("/api/roles/{id}", roleHandler::updateRole) .PUT("/api/admin/roles/{id}", roleHandler::updateRole)
.DELETE("/api/roles/{id}", roleHandler::deleteRole) .DELETE("/api/admin/roles/{id}", roleHandler::deleteRole)
.POST("/api/roles/{id}/restore", roleHandler::restoreRole) .POST("/api/admin/roles/{id}/restore", roleHandler::restoreRole)
.GET("/api/roles/{id}/permissions", permissionHandler::getPermissionsByRoleId) .GET("/api/admin/roles/{id}/permissions", permissionHandler::getPermissionsByRoleId)
.POST("/api/roles/{id}/permissions", permissionHandler::assignPermissionsToRole) .POST("/api/admin/roles/{id}/permissions", permissionHandler::assignPermissionsToRole)
// ========== 配置路由 ========== // ========== 配置路由(管理端) ==========
.GET("/api/config", configHandler::getAllConfigs) .GET("/api/admin/config", configHandler::getAllConfigs)
.GET("/api/config/{id}", configHandler::getConfigById) .GET("/api/admin/config/{id}", configHandler::getConfigById)
.GET("/api/config/key/{configKey}", configHandler::getConfigByKey) .GET("/api/admin/config/key/{configKey}", configHandler::getConfigByKey)
.POST("/api/config", configHandler::createConfig) .POST("/api/admin/config", configHandler::createConfig)
.PUT("/api/config/{id}", configHandler::updateConfig) .PUT("/api/admin/config/{id}", configHandler::updateConfig)
.DELETE("/api/config/{id}", configHandler::deleteConfig) .DELETE("/api/admin/config/{id}", configHandler::deleteConfig)
// ========== 日志路由 ========== // ========== 日志路由(管理端) ==========
.GET("/api/logs/login", logHandler::getAllLoginLogs) .GET("/api/admin/logs/login", logHandler::getAllLoginLogs)
.GET("/api/logs/login/page", logHandler::getLoginLogsByPage) .GET("/api/admin/logs/login/page", logHandler::getLoginLogsByPage)
.GET("/api/logs/login/count", logHandler::getLoginLogCount) .GET("/api/admin/logs/login/count", logHandler::getLoginLogCount)
.GET("/api/logs/login/today/count", logHandler::getTodayLoginCount) .GET("/api/admin/logs/login/today/count", logHandler::getTodayLoginCount)
.GET("/api/logs/login/recent", logHandler::getRecentLoginLogs) .GET("/api/admin/logs/login/recent", logHandler::getRecentLoginLogs)
.GET("/api/logs/login/{id}", logHandler::getLoginLogById) .GET("/api/admin/logs/login/{id}", logHandler::getLoginLogById)
.POST("/api/logs/login", logHandler::createLoginLog) .POST("/api/admin/logs/login", logHandler::createLoginLog)
.GET("/api/logs/exception", logHandler::getAllExceptionLogs) .GET("/api/admin/logs/exception", logHandler::getAllExceptionLogs)
.GET("/api/logs/exception/page", logHandler::getExceptionLogsByPage) .GET("/api/admin/logs/exception/page", logHandler::getExceptionLogsByPage)
.GET("/api/logs/exception/count", logHandler::getExceptionLogCount) .GET("/api/admin/logs/exception/count", logHandler::getExceptionLogCount)
.GET("/api/logs/exception/{id}", logHandler::getExceptionLogById) .GET("/api/admin/logs/exception/{id}", logHandler::getExceptionLogById)
.POST("/api/logs/exception", logHandler::createExceptionLog) .POST("/api/admin/logs/exception", logHandler::createExceptionLog)
.GET("/api/logs/operation", operationLogHandler::getAllOperationLogs) .GET("/api/admin/logs/operation", operationLogHandler::getAllOperationLogs)
.GET("/api/logs/operation/export", operationLogHandler::exportOperationLogs) .GET("/api/admin/logs/operation/export", operationLogHandler::exportOperationLogs)
.GET("/api/logs/operation/page", operationLogHandler::getOperationLogsByPage) .GET("/api/admin/logs/operation/page", operationLogHandler::getOperationLogsByPage)
.GET("/api/logs/operation/count", operationLogHandler::getOperationLogCount) .GET("/api/admin/logs/operation/count", operationLogHandler::getOperationLogCount)
.GET("/api/logs/operation/{id}", operationLogHandler::getOperationLogById) .GET("/api/admin/logs/operation/{id}", operationLogHandler::getOperationLogById)
.POST("/api/logs/operation", operationLogHandler::createOperationLog) .POST("/api/admin/logs/operation", operationLogHandler::createOperationLog)
// ========== 认证路由 ========== // ========== 认证路由(管理端) ==========
.POST("/api/auth/login", authHandler::login) .POST("/api/admin/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register) .POST("/api/admin/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout) .POST("/api/admin/auth/logout", authHandler::logout)
// ========== 统计路由 ========== // ========== 统计路由(管理端) ==========
.GET("/api/stats/overview", statsHandler::getOverview) .GET("/api/admin/stats/overview", statsHandler::getOverview)
// ========== 数据字典路由 ========== // ========== 数据字典路由(管理端) ==========
.GET("/api/dict/types", dictHandler::getAllDictTypes) .GET("/api/admin/dict/types", dictHandler::getAllDictTypes)
.GET("/api/dict/types/{id}", dictHandler::getDictTypeById) .GET("/api/admin/dict/types/{id}", dictHandler::getDictTypeById)
.GET("/api/dict/types/type/{dictType}", dictHandler::getDictTypeByType) .GET("/api/admin/dict/types/type/{dictType}", dictHandler::getDictTypeByType)
.POST("/api/dict/types", dictHandler::createDictType) .POST("/api/admin/dict/types", dictHandler::createDictType)
.PUT("/api/dict/types/{id}", dictHandler::updateDictType) .PUT("/api/admin/dict/types/{id}", dictHandler::updateDictType)
.DELETE("/api/dict/types/{id}", dictHandler::deleteDictType) .DELETE("/api/admin/dict/types/{id}", dictHandler::deleteDictType)
.GET("/api/dict/data", dictHandler::getAllDictData) .GET("/api/admin/dict/data", dictHandler::getAllDictData)
.GET("/api/dict/data/type/{dictType}", dictHandler::getDictDataByType) .GET("/api/admin/dict/data/type/{dictType}", dictHandler::getDictDataByType)
.GET("/api/dict/data/{id}", dictHandler::getDictDataById) .GET("/api/admin/dict/data/{id}", dictHandler::getDictDataById)
.POST("/api/dict/data", dictHandler::createDictData) .POST("/api/admin/dict/data", dictHandler::createDictData)
.PUT("/api/dict/data/{id}", dictHandler::updateDictData) .PUT("/api/admin/dict/data/{id}", dictHandler::updateDictData)
.DELETE("/api/dict/data/{id}", dictHandler::deleteDictData) .DELETE("/api/admin/dict/data/{id}", dictHandler::deleteDictData)
// ========== 公告路由 ========== // ========== 公告路由(管理端) ==========
.GET("/api/notices", noticeHandler::getAllNotices) .GET("/api/admin/notices", noticeHandler::getAllNotices)
.GET("/api/notices/{id}", noticeHandler::getNoticeById) .GET("/api/admin/notices/{id}", noticeHandler::getNoticeById)
.GET("/api/notices/status/{status}", noticeHandler::getNoticesByStatus) .GET("/api/admin/notices/status/{status}", noticeHandler::getNoticesByStatus)
.POST("/api/notices", noticeHandler::createNotice) .POST("/api/admin/notices", noticeHandler::createNotice)
.PUT("/api/notices/{id}", noticeHandler::updateNotice) .PUT("/api/admin/notices/{id}", noticeHandler::updateNotice)
.DELETE("/api/notices/{id}", noticeHandler::deleteNotice) .DELETE("/api/admin/notices/{id}", noticeHandler::deleteNotice)
// ========== 消息路由 ========== // ========== 消息路由(管理端) ==========
.GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser) .GET("/api/admin/messages/user/{userId}", messageHandler::getMessagesByUser)
.GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount) .GET("/api/admin/messages/user/{userId}/unread", messageHandler::getUnreadCount)
.GET("/api/messages/user/{userId}/unread/list", messageHandler::getUnreadList) .GET("/api/admin/messages/user/{userId}/unread/list", messageHandler::getUnreadList)
.POST("/api/messages", messageHandler::createMessage) .POST("/api/admin/messages", messageHandler::createMessage)
.PUT("/api/messages/{id}/read", messageHandler::markAsRead) .PUT("/api/admin/messages/{id}/read", messageHandler::markAsRead)
.DELETE("/api/messages/{id}", messageHandler::deleteMessage) .DELETE("/api/admin/messages/{id}", messageHandler::deleteMessage)
// ========== 文件路由 ========== // ========== 文件路由(管理端) ==========
.GET("/api/files", fileHandler::getAllFiles) .GET("/api/admin/files", fileHandler::getAllFiles)
.GET("/api/files/{id}", fileHandler::getFileById) .GET("/api/admin/files/{id}", fileHandler::getFileById)
.POST("/api/files/upload", fileHandler::uploadFile) .POST("/api/admin/files/upload", fileHandler::uploadFile)
.GET("/api/files/{id}/download", fileHandler::downloadFile) .GET("/api/admin/files/{id}/download", fileHandler::downloadFile)
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName) .GET("/api/admin/files/download/{fileName}", fileHandler::downloadFileByName)
.GET("/api/files/{id}/preview", fileHandler::previewFile) .GET("/api/admin/files/{id}/preview", fileHandler::previewFile)
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .GET("/api/admin/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile) .DELETE("/api/admin/files/{id}", fileHandler::deleteFile)
// ========== 权限路由 ========== // ========== 权限路由(管理端) ==========
.GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/admin/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById) .GET("/api/admin/permissions/{id}", permissionHandler::getPermissionById)
.GET("/api/permissions/code/{code}", permissionHandler::getPermissionByCode) .GET("/api/admin/permissions/code/{code}", permissionHandler::getPermissionByCode)
.GET("/api/permissions/check-code", permissionHandler::checkCodeExists) .GET("/api/admin/permissions/check-code", permissionHandler::checkCodeExists)
.GET("/api/permissions/count", permissionHandler::getPermissionCount) .GET("/api/admin/permissions/count", permissionHandler::getPermissionCount)
.POST("/api/permissions", permissionHandler::createPermission) .POST("/api/admin/permissions", permissionHandler::createPermission)
.PUT("/api/permissions/{id}", permissionHandler::updatePermission) .PUT("/api/admin/permissions/{id}", permissionHandler::updatePermission)
.DELETE("/api/permissions/{id}", permissionHandler::deletePermission) .DELETE("/api/admin/permissions/{id}", permissionHandler::deletePermission)
// ========== 会员模块路由 - 微信认证 ========== // ========== 会员模块路由 - 微信认证(前台公开) ==========
.POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin) .POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin)
.GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature) .GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature)
.POST("/api/member/auth/mp/callback", wechatAuthHandler::mpCallback) .POST("/api/member/auth/mp/callback", wechatAuthHandler::mpCallback)
// ========== 会员模块路由 - 会员信息 ========== // ========== 会员模块路由 - 会员信息(前台) ==========
.GET("/api/member/info", memberHandler::getMemberInfo) .GET("/api/member/info", memberHandler::getMemberInfo)
.PUT("/api/member/info", memberHandler::updateMemberInfo) .PUT("/api/member/info", memberHandler::updateMemberInfo)
.POST("/api/member/phone/bind", memberHandler::bindPhone) .POST("/api/member/phone/bind", memberHandler::bindPhone)
@@ -224,32 +226,35 @@ public class SystemRouter {
// ======================================== // ========================================
// ========== 会员卡管理路由 ============== // ========== 会员卡管理路由(管理端) ==============
// ======================================== // ========================================
// ===== 会员卡类型管理 ===== // ===== 会员卡类型管理 =====
.GET("/api/member-cards/active", memberCardHandler::getActiveCards) .GET("/api/admin/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById) .GET("/api/admin/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/member-cards", memberCardHandler::createMemberCard) .POST("/api/admin/member-cards", memberCardHandler::createMemberCard)
// ===== 会员卡记录管理(核心业务)===== // ===== 会员卡记录管理(核心业务)=====
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard) .POST("/api/admin/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
.POST("/api/member-card-records/{recordId}/renew", memberCardRecordHandler::renewCard) .POST("/api/admin/member-card-records/{recordId}/renew", memberCardRecordHandler::renewCard)
.POST("/api/member-card-records/{recordId}/use", memberCardRecordHandler::useCard) .POST("/api/admin/member-card-records/{recordId}/use", memberCardRecordHandler::useCard)
.POST("/api/member-card-records/{recordId}/refund", memberCardRecordHandler::refundCard) .POST("/api/admin/member-card-records/{recordId}/refund", memberCardRecordHandler::refundCard)
.GET("/api/member-card-records/my-cards/{memberId}", memberCardRecordHandler::getMyCards) .GET("/api/admin/member-card-records/my-cards/{memberId}", memberCardRecordHandler::getMyCards)
.GET("/api/member-card-records/{recordId}", memberCardRecordHandler::getMemberCardRecordById) .GET("/api/admin/member-card-records/{recordId}", memberCardRecordHandler::getMemberCardRecordById)
.POST("/api/member-card-records/process-expired", memberCardRecordHandler::processExpiredCards) .POST("/api/admin/member-card-records/process-expired", memberCardRecordHandler::processExpiredCards)
// ===== 会员卡交易流水管理 ===== // ===== 会员卡交易流水管理 =====
.POST("/api/member-card-transactions", memberCardTransactionHandler::insertTransaction) .POST("/api/admin/member-card-transactions", memberCardTransactionHandler::insertTransaction)
.GET("/api/member-card-transactions", memberCardTransactionHandler::getTransactionsWithConditions) .GET("/api/admin/member-card-transactions", memberCardTransactionHandler::getTransactionsWithConditions)
.GET("/api/member-card-transactions/member/{memberId}", memberCardTransactionHandler::getMemberTransactions) .GET("/api/admin/member-card-transactions/member/{memberId}", memberCardTransactionHandler::getMemberTransactions)
.GET("/api/member-card-transactions/card/{cardId}", memberCardTransactionHandler::getTransactionsByCardId) .GET("/api/admin/member-card-transactions/card/{cardId}", memberCardTransactionHandler::getTransactionsByCardId)
.GET("/api/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId) .GET("/api/admin/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId)
.GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange) .GET("/api/admin/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange)
.GET("/api/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember) .GET("/api/admin/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember)
// ========= 签到路由(前台会员) ==========
.POST("/api/member/checkIn", checkInHandler::checkIn)
.GET("/api/member/checkIn/qrcode", checkInHandler::getQRCode)
.build(); .build();
} }
} }
+4
View File
@@ -56,6 +56,10 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -1,4 +1,4 @@
package cn.novalon.gym.manage.member.config; package cn.novalon.gym.manage.common.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -0,0 +1,64 @@
package cn.novalon.gym.manage.common.constant;
/**
* Redis 缓存 Key 常量类
* 统一管理项目中所有 Redis 缓存的 key 前缀
*
* @author auto-generated
* @date 2026-05-30
*/
public final class RedisKeyConstants {
private RedisKeyConstants() {
}
// ==================== 会员模块 ====================
/**
* 会员信息缓存
* 格式:member:info:{memberId}
*/
public static final String MEMBER_INFO = "member:info:";
/**
* 会员详情缓存
* 格式:member:detail:{memberId}
*/
public static final String MEMBER_DETAIL = "member:detail:";
/**
* 会员卡类型缓存
* 格式:member:card:{memberCardId}
*/
public static final String MEMBER_CARD = "member:card:";
/**
* 会员卡记录缓存(包含剩余次数/金额)
* 格式:member:card:record:{recordId}
*/
public static final String MEMBER_CARD_RECORD = "member:card:record:";
/**
* 会员退款申请缓存
* 格式:member:refund:{recordId}
*/
public static final String MEMBER_REFUND = "member:refund:";
// ==================== 签到模块 ====================
/**
* 用户当日二维码缓存
* 格式:qrcode:user:daily:{userId}:{date}
* 示例:qrcode:user:daily:1:2026-05-30
*/
public static final String QRCODE_USER_DAILY = "qrcode:user:daily:";
// ==================== 微信模块 ====================
/**
* 微信 access_token 缓存
* 格式:wechat:access_token:{appType}
* appType: miniapp(小程序), mp(公众号)
*/
public static final String WECHAT_ACCESS_TOKEN = "wechat:access_token:";
}
@@ -0,0 +1,22 @@
package cn.novalon.gym.manage.common.constants;
/**
* 用户类型枚举
* 用于区分后台管理用户和前台会员用户
*/
public enum UserType {
ADMIN,
MEMBER;
public static UserType fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("userType 不能为空");
}
for (UserType type : values()) {
if (type.name().equalsIgnoreCase(value)) {
return type;
}
}
throw new IllegalArgumentException("未知的用户类型: " + value);
}
}
@@ -1,4 +1,4 @@
package cn.novalon.gym.manage.member.util; package cn.novalon.gym.manage.common.util;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveRedisTemplate;
@@ -42,6 +42,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
return exchange.getResponse().setComplete(); return exchange.getResponse().setComplete();
} }
// 路径-userType 校验:防止越权访问
String userType = jwtUtil.getUserTypeFromToken(token);
if (!isUserTypeAllowedForPath(path, userType)) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
String username = jwtUtil.getUsernameFromToken(token); String username = jwtUtil.getUsernameFromToken(token);
Long userId = jwtUtil.getUserIdFromToken(token); Long userId = jwtUtil.getUserIdFromToken(token);
@@ -49,18 +56,34 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
.header("X-User-Id", String.valueOf(userId)) .header("X-User-Id", String.valueOf(userId))
.header("X-Member-Id", String.valueOf(userId)) .header("X-Member-Id", String.valueOf(userId))
.header("X-Username", username) .header("X-Username", username)
.header("X-User-Type", userType != null ? userType : "UNKNOWN")
.build(); .build();
return chain.filter(exchange.mutate().request(modifiedRequest).build()); return chain.filter(exchange.mutate().request(modifiedRequest).build());
}; };
} }
/**
* 校验路径与 userType 是否匹配
* - /api/admin/** 路径只允许 userType=ADMIN
* - /api/member/** 路径只允许 userType=MEMBER
* - 其他路径不做 userType 校验
*/
private boolean isUserTypeAllowedForPath(String path, String userType) {
if (path.startsWith("/api/admin/")) {
return "ADMIN".equals(userType);
}
if (path.startsWith("/api/member/")) {
return "MEMBER".equals(userType);
}
// 非特定前缀路径不做 userType 校验
return true;
}
private boolean isPublicPath(String path) { private boolean isPublicPath(String path) {
return path.startsWith("/api/auth/") || return path.startsWith("/api/admin/auth/") ||
path.equals("/actuator/health") || path.equals("/actuator/health") ||
path.equals("/api/member/auth/miniapp/login") || path.startsWith("/api/member/auth/") ||
path.equals("/api/member/auth/mp/callback") ||
path.equals("/api/auth/login") ||
path.startsWith("/actuator/info"); path.startsWith("/actuator/info");
} }
@@ -61,9 +61,10 @@ public class RbacAuthorizationFilter extends AbstractGatewayFilterFactory<RbacAu
} }
private boolean isPublicPath(String path) { private boolean isPublicPath(String path) {
return path.startsWith("/api/auth/") || return path.startsWith("/api/auth/") ||
path.equals("/actuator/health") || path.equals("/actuator/health") ||
path.startsWith("/actuator/info"); path.startsWith("/actuator/info") ||
path.startsWith("/api/checkIn/");
} }
public static class Config { public static class Config {
@@ -28,6 +28,10 @@ public class JwtUtil {
} }
public String generateToken(String username, Long userId) { public String generateToken(String username, Long userId) {
return generateToken(username, userId, "ADMIN");
}
public String generateToken(String username, Long userId, String userType) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration); Date expiryDate = new Date(now.getTime() + expiration);
@@ -35,13 +39,14 @@ public class JwtUtil {
String token = Jwts.builder() String token = Jwts.builder()
.setSubject(username) .setSubject(username)
.claim("userId", userId) .claim("userId", userId)
.claim("userType", userType)
.claim("keyVersion", jwtKeyService.getCurrentKeyVersion()) .claim("keyVersion", jwtKeyService.getCurrentKeyVersion())
.setIssuedAt(now) .setIssuedAt(now)
.setExpiration(expiryDate) .setExpiration(expiryDate)
.signWith(getSigningKey()) .signWith(getSigningKey())
.compact(); .compact();
logger.debug("Generated JWT token for user: {}, userId: {}", username, userId); logger.debug("Generated JWT token for user: {}, userId: {}, userType: {}", username, userId, userType);
return token; return token;
} catch (Exception e) { } catch (Exception e) {
@@ -74,6 +79,11 @@ public class JwtUtil {
return claims.get("userId", Long.class); return claims.get("userId", Long.class);
} }
public String getUserTypeFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("userType", String.class);
}
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
parseToken(token); parseToken(token);
@@ -64,7 +64,7 @@ signature:
max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5} max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5}
nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000} nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000}
whitelist: whitelist:
paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/auth/login,/api/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback} paths: ${SIGNATURE_WHITELIST_PATHS:/actuator/health,/actuator/info,/api/admin/auth/login,/api/admin/auth/register,/api/member/auth/miniapp/login,/api/member/auth/mp/callback}
resilience: resilience:
enabled: ${RESILIENCE_ENABLED:true} enabled: ${RESILIENCE_ENABLED:true}
@@ -39,7 +39,7 @@ class GatewayJwtAuthenticationFilterTest {
@Test @Test
void testPublicPath_AllowAccess() { void testPublicPath_AllowAccess() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/auth/login").build(); MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/auth/login").build();
exchange = MockServerWebExchange.from(request); exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty()); when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -56,7 +56,7 @@ class GatewayJwtAuthenticationFilterTest {
@Test @Test
void testPublicPath_Register() { void testPublicPath_Register() {
MockServerHttpRequest request = MockServerHttpRequest.post("/api/auth/register").build(); MockServerHttpRequest request = MockServerHttpRequest.post("/api/admin/auth/register").build();
exchange = MockServerWebExchange.from(request); exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty()); when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -105,6 +105,40 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil, never()).validateToken(anyString()); verify(jwtUtil, never()).validateToken(anyString());
} }
@Test
void testPublicPath_MemberAuth() {
MockServerHttpRequest request = MockServerHttpRequest.post("/api/member/auth/miniapp/login").build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
verify(chain).filter(any(ServerWebExchange.class));
verify(jwtUtil, never()).validateToken(anyString());
}
@Test
void testPublicPath_AdminAuthPrefix() {
MockServerHttpRequest request = MockServerHttpRequest.post("/api/admin/auth/refresh").build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
verify(chain).filter(any(ServerWebExchange.class));
verify(jwtUtil, never()).validateToken(anyString());
}
@Test @Test
void testProtectedPath_NoAuthHeader() { void testProtectedPath_NoAuthHeader() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/users").build(); MockServerHttpRequest request = MockServerHttpRequest.get("/api/users").build();
@@ -152,6 +186,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false); when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser"); when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L); when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config()) Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain); .filter(exchange, chain);
@@ -163,6 +198,7 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).isTokenExpired(validToken); verify(jwtUtil).isTokenExpired(validToken);
verify(jwtUtil).getUsernameFromToken(validToken); verify(jwtUtil).getUsernameFromToken(validToken);
verify(jwtUtil).getUserIdFromToken(validToken); verify(jwtUtil).getUserIdFromToken(validToken);
verify(jwtUtil).getUserTypeFromToken(validToken);
verify(chain).filter(any(ServerWebExchange.class)); verify(chain).filter(any(ServerWebExchange.class));
} }
@@ -224,6 +260,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false); when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser"); when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L); when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config()) Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain); .filter(exchange, chain);
@@ -235,6 +272,7 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).isTokenExpired(validToken); verify(jwtUtil).isTokenExpired(validToken);
verify(jwtUtil).getUsernameFromToken(validToken); verify(jwtUtil).getUsernameFromToken(validToken);
verify(jwtUtil).getUserIdFromToken(validToken); verify(jwtUtil).getUserIdFromToken(validToken);
verify(jwtUtil).getUserTypeFromToken(validToken);
verify(chain).filter(any(ServerWebExchange.class)); verify(chain).filter(any(ServerWebExchange.class));
} }
@@ -251,6 +289,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false); when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser"); when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L); when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config()) Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain); .filter(exchange, chain);
@@ -263,11 +302,12 @@ class GatewayJwtAuthenticationFilterTest {
ServerHttpRequest modifiedRequest = exchangeCaptor.getValue().getRequest(); ServerHttpRequest modifiedRequest = exchangeCaptor.getValue().getRequest();
assert modifiedRequest.getHeaders().getFirst("X-User-Id").equals("1"); assert modifiedRequest.getHeaders().getFirst("X-User-Id").equals("1");
assert modifiedRequest.getHeaders().getFirst("X-Username").equals("testuser"); assert modifiedRequest.getHeaders().getFirst("X-Username").equals("testuser");
assert modifiedRequest.getHeaders().getFirst("X-User-Type").equals("ADMIN");
} }
@Test @Test
void testMixedPath_AuthPath() { void testMixedPath_AdminAuthPath() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/auth/logout").build(); MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/auth/logout").build();
exchange = MockServerWebExchange.from(request); exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty()); when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -295,6 +335,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false); when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser"); when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L); when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config()) Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain); .filter(exchange, chain);
@@ -308,4 +349,123 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).getUserIdFromToken(validToken); verify(jwtUtil).getUserIdFromToken(validToken);
verify(chain).filter(any(ServerWebExchange.class)); verify(chain).filter(any(ServerWebExchange.class));
} }
// ========== userType 路径校验测试 ==========
@Test
void testAdminPath_WithAdminToken_ShouldPass() {
String adminToken = "admin.jwt.token";
MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/users")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
.build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(jwtUtil.validateToken(adminToken)).thenReturn(true);
when(jwtUtil.isTokenExpired(adminToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(adminToken)).thenReturn("admin");
when(jwtUtil.getUserIdFromToken(adminToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(adminToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
verify(chain).filter(any(ServerWebExchange.class));
}
@Test
void testAdminPath_WithMemberToken_ShouldBeForbidden() {
String memberToken = "member.jwt.token";
MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/users")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + memberToken)
.build();
exchange = MockServerWebExchange.from(request);
when(jwtUtil.validateToken(memberToken)).thenReturn(true);
when(jwtUtil.isTokenExpired(memberToken)).thenReturn(false);
when(jwtUtil.getUserTypeFromToken(memberToken)).thenReturn("MEMBER");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
assert exchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
verify(chain, never()).filter(any(ServerWebExchange.class));
}
@Test
void testMemberPath_WithMemberToken_ShouldPass() {
String memberToken = "member.jwt.token";
MockServerHttpRequest request = MockServerHttpRequest.get("/api/member/info")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + memberToken)
.build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(jwtUtil.validateToken(memberToken)).thenReturn(true);
when(jwtUtil.isTokenExpired(memberToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(memberToken)).thenReturn("123");
when(jwtUtil.getUserIdFromToken(memberToken)).thenReturn(123L);
when(jwtUtil.getUserTypeFromToken(memberToken)).thenReturn("MEMBER");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
verify(chain).filter(any(ServerWebExchange.class));
}
@Test
void testMemberPath_WithAdminToken_ShouldBeForbidden() {
String adminToken = "admin.jwt.token";
MockServerHttpRequest request = MockServerHttpRequest.get("/api/member/info")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
.build();
exchange = MockServerWebExchange.from(request);
when(jwtUtil.validateToken(adminToken)).thenReturn(true);
when(jwtUtil.isTokenExpired(adminToken)).thenReturn(false);
when(jwtUtil.getUserTypeFromToken(adminToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
assert exchange.getResponse().getStatusCode() == HttpStatus.FORBIDDEN;
verify(chain, never()).filter(any(ServerWebExchange.class));
}
@Test
void testNonPrefixedPath_NoUserTypeCheck() {
String adminToken = "admin.jwt.token";
// /api/users 不以 /api/admin/ 或 /api/member/ 开头,不做 userType 校验
MockServerHttpRequest request = MockServerHttpRequest.get("/api/users")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
.build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(jwtUtil.validateToken(adminToken)).thenReturn(true);
when(jwtUtil.isTokenExpired(adminToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(adminToken)).thenReturn("admin");
when(jwtUtil.getUserIdFromToken(adminToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(adminToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
StepVerifier.create(result)
.verifyComplete();
verify(chain).filter(any(ServerWebExchange.class));
}
} }
@@ -47,7 +47,8 @@ public class SecurityConfig {
.addFilterBefore(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION) .addFilterBefore(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterAfter(operationLogWebFilter, SecurityWebFiltersOrder.AUTHORIZATION) .addFilterAfter(operationLogWebFilter, SecurityWebFiltersOrder.AUTHORIZATION)
.authorizeExchange(spec -> { .authorizeExchange(spec -> {
spec.pathMatchers("/api/auth/**").permitAll() spec.pathMatchers("/api/admin/auth/**").permitAll()
.pathMatchers("/api/member/auth/**").permitAll()
.pathMatchers("/api/public/**").permitAll() .pathMatchers("/api/public/**").permitAll()
.pathMatchers("/ws/**").permitAll() .pathMatchers("/ws/**").permitAll()
.pathMatchers("/actuator/**").permitAll(); .pathMatchers("/actuator/**").permitAll();
@@ -59,7 +60,7 @@ public class SecurityConfig {
.pathMatchers("/v3/api-docs/**").permitAll() .pathMatchers("/v3/api-docs/**").permitAll()
.pathMatchers("/swagger-resources/**").permitAll() .pathMatchers("/swagger-resources/**").permitAll()
.pathMatchers("/webjars/**").permitAll() .pathMatchers("/webjars/**").permitAll()
.pathMatchers("/api/diagnostic/**").permitAll(); .pathMatchers("/api/admin/diagnostic/**").permitAll();
logger.info("SecurityConfig: Swagger路径和诊断端点已放行"); logger.info("SecurityConfig: Swagger路径和诊断端点已放行");
} }
@@ -20,6 +20,9 @@ public class AuthResponse {
@Schema(description = "用户名", example = "admin") @Schema(description = "用户名", example = "admin")
private String username; private String username;
@Schema(description = "用户类型", example = "ADMIN")
private String userType;
public AuthResponse() { public AuthResponse() {
} }
@@ -27,6 +30,14 @@ public class AuthResponse {
this.token = token; this.token = token;
this.userId = userId; this.userId = userId;
this.username = username; this.username = username;
this.userType = "ADMIN";
}
public AuthResponse(String token, Long userId, String username, String userType) {
this.token = token;
this.userId = userId;
this.username = username;
this.userType = userType;
} }
public String getToken() { public String getToken() {
@@ -52,4 +63,12 @@ public class AuthResponse {
public void setUsername(String username) { public void setUsername(String username) {
this.username = username; this.username = username;
} }
public String getUserType() {
return userType;
}
public void setUserType(String userType) {
this.userType = userType;
}
} }
@@ -133,8 +133,9 @@ public class SysAuthHandler {
.generateToken( .generateToken(
user.getUsername(), user.getUsername(),
user.getId(), user.getId(),
roleKeys); roleKeys,
logger.info("用户登录成功: username={}, userId={}, roles={}", "ADMIN");
logger.info("用户登录成功: username={}, userId={}, roles={}, userType=ADMIN",
user.getUsername(), user.getUsername(),
user.getId(), user.getId(),
roleKeys); roleKeys);
@@ -146,7 +147,8 @@ public class SysAuthHandler {
AuthResponse response = new AuthResponse( AuthResponse response = new AuthResponse(
token, token,
user.getId(), user.getId(),
user.getUsername()); user.getUsername(),
"ADMIN");
return ServerResponse.ok() return ServerResponse.ok()
.bodyValue(response); .bodyValue(response);
}); });
@@ -37,6 +37,7 @@ public class JwtAuthenticationFilter implements WebFilter {
String username = jwtTokenProvider.getUsernameFromToken(token); String username = jwtTokenProvider.getUsernameFromToken(token);
jwtTokenProvider.getUserIdFromToken(token); jwtTokenProvider.getUserIdFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token); List<String> roles = jwtTokenProvider.getRolesFromToken(token);
String userType = jwtTokenProvider.getUserTypeFromToken(token);
List<SimpleGrantedAuthority> authorities = roles.stream() List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
@@ -53,6 +54,9 @@ public class JwtAuthenticationFilter implements WebFilter {
authorities authorities
); );
// 将 userType 存入 authentication details,供后续 AuthUtil 使用
authentication.setDetails(userType);
return chain.filter(exchange) return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
} }
@@ -32,24 +32,19 @@ public class JwtTokenProvider {
} }
public String generateToken(String username, Long userId) { public String generateToken(String username, Long userId) {
Map<String, Object> claims = new HashMap<>(); return generateToken(username, userId, java.util.Collections.emptyList(), "ADMIN");
claims.put("userId", userId);
claims.put("username", username);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(getSigningKey())
.compact();
} }
public String generateToken(String username, Long userId, java.util.List<String> roles) { public String generateToken(String username, Long userId, java.util.List<String> roles) {
return generateToken(username, userId, roles, "ADMIN");
}
public String generateToken(String username, Long userId, java.util.List<String> roles, String userType) {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId); claims.put("userId", userId);
claims.put("username", username); claims.put("username", username);
claims.put("roles", roles); claims.put("roles", roles);
claims.put("userType", userType);
return Jwts.builder() return Jwts.builder()
.setClaims(claims) .setClaims(claims)
@@ -85,6 +80,10 @@ public class JwtTokenProvider {
return java.util.Collections.emptyList(); return java.util.Collections.emptyList();
} }
public String getUserTypeFromToken(String token) {
return getClaimsFromToken(token).get("userType", String.class);
}
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
getClaimsFromToken(token); getClaimsFromToken(token);
@@ -29,4 +29,38 @@ public class AuthUtil {
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效"); if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
return jwtTokenProvider.getUserIdFromToken(token); return jwtTokenProvider.getUserIdFromToken(token);
} }
}
/**
* 获取当前 ADMIN 用户 ID,校验 userType 必须为 ADMIN
*/
public Long getAdminUserIdOrThrow(ServerRequest request) {
String token = extractToken(request);
if (token == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "缺少 Token");
if (!jwtTokenProvider.validateToken(token)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token 无效或已过期");
String userType = jwtTokenProvider.getUserTypeFromToken(token);
if (!"ADMIN".equals(userType)) {
log.warn("非管理员用户尝试访问管理端接口, userType={}", userType);
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问管理端接口");
}
Long userId = jwtTokenProvider.getUserIdFromToken(token);
if (userId <= 0L) throw new IllegalArgumentException("ID无效");
return userId;
}
/**
* 获取当前 MEMBER 用户 ID,校验 userType 必须为 MEMBER
*/
public Long getMemberUserIdOrThrow(ServerRequest request) {
String token = extractToken(request);
if (token == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "缺少 Token");
if (!jwtTokenProvider.validateToken(token)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token 无效或已过期");
String userType = jwtTokenProvider.getUserTypeFromToken(token);
if (!"MEMBER".equals(userType)) {
log.warn("非会员用户尝试访问会员接口, userType={}", userType);
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问会员接口");
}
Long userId = jwtTokenProvider.getUserIdFromToken(token);
if (userId <= 0L) throw new IllegalArgumentException("ID无效");
return userId;
}
}
@@ -80,7 +80,7 @@ class SysAuthHandlerTest {
// 配置密码编码器Mock来验证密码 // 配置密码编码器Mock来验证密码
when(passwordEncoder.matches(rawPassword, realEncodedPassword)).thenReturn(true); when(passwordEncoder.matches(rawPassword, realEncodedPassword)).thenReturn(true);
when(jwtTokenProvider.generateToken(eq("testuser"), eq(1L), anyList())).thenReturn("test_token"); when(jwtTokenProvider.generateToken(eq("testuser"), eq(1L), anyList(), eq("ADMIN"))).thenReturn("test_token");
// 使用测试数据工厂创建角色 // 使用测试数据工厂创建角色
SysRole mockRole = TestDataFactory.createUserRole(); SysRole mockRole = TestDataFactory.createUserRole();
@@ -103,7 +103,7 @@ class SysAuthHandlerTest {
.verifyComplete(); .verifyComplete();
verify(userService).findByUsername("testuser"); verify(userService).findByUsername("testuser");
verify(jwtTokenProvider).generateToken(eq("testuser"), eq(1L), anyList()); verify(jwtTokenProvider).generateToken(eq("testuser"), eq(1L), anyList(), eq("ADMIN"));
} }
@Test @Test
@@ -108,4 +108,52 @@ class JwtTokenProviderTest {
assertThat(isValid).isFalse(); assertThat(isValid).isFalse();
} }
@Test
void testGenerateTokenWithUserType() {
when(jwtProperties.getSecret()).thenReturn("test-secret-key-for-testing-purposes-only-1234567890");
when(jwtProperties.getExpiration()).thenReturn(3600000L);
String token = jwtTokenProvider.generateToken("testuser", 1L, java.util.List.of("admin"), "ADMIN");
assertThat(token).isNotNull();
assertThat(token).isNotEmpty();
}
@Test
void testGetUserTypeFromToken() {
when(jwtProperties.getSecret()).thenReturn("test-secret-key-for-testing-purposes-only-1234567890");
when(jwtProperties.getExpiration()).thenReturn(3600000L);
String token = jwtTokenProvider.generateToken("testuser", 1L, java.util.List.of("admin"), "ADMIN");
String userType = jwtTokenProvider.getUserTypeFromToken(token);
assertThat(userType).isEqualTo("ADMIN");
}
@Test
void testGetUserTypeFromToken_Member() {
when(jwtProperties.getSecret()).thenReturn("test-secret-key-for-testing-purposes-only-1234567890");
when(jwtProperties.getExpiration()).thenReturn(3600000L);
String token = jwtTokenProvider.generateToken("123", 123L, java.util.List.of(), "MEMBER");
String userType = jwtTokenProvider.getUserTypeFromToken(token);
assertThat(userType).isEqualTo("MEMBER");
}
@Test
void testGetUserTypeFromToken_DefaultIsAdmin() {
when(jwtProperties.getSecret()).thenReturn("test-secret-key-for-testing-purposes-only-1234567890");
when(jwtProperties.getExpiration()).thenReturn(3600000L);
// 使用旧的两参数方法生成的 token 默认 userType 为 ADMIN
String token = jwtTokenProvider.generateToken("testuser", 1L);
String userType = jwtTokenProvider.getUserTypeFromToken(token);
assertThat(userType).isEqualTo("ADMIN");
}
} }
+1
View File
@@ -43,6 +43,7 @@
<module>manage-notify</module> <module>manage-notify</module>
<module>manage-file</module> <module>manage-file</module>
<module>gym-member</module> <module>gym-member</module>
<module>gym-checkIn</module>
</modules> </modules>
<dependencyManagement> <dependencyManagement>