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
39 changed files with 671 additions and 1753 deletions
+7 -7
View File
@@ -5,7 +5,7 @@ test.describe('认证和授权测试', () => {
let userId: number;
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: {
'Content-Type': 'application/json'
},
@@ -28,7 +28,7 @@ test.describe('认证和授权测试', () => {
});
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: {
'Content-Type': 'application/json'
},
@@ -78,7 +78,7 @@ test.describe('认证和授权测试', () => {
});
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: {
'Authorization': `Bearer ${authToken}`
}
@@ -98,10 +98,10 @@ test.describe('认证和授权测试', () => {
test('权限验证测试', async ({ page }) => {
await test.step('测试访问受保护的API', async () => {
const protectedEndpoints = [
'/api/users',
'/api/roles',
'/api/menus',
'/api/config'
'/api/admin/users',
'/api/admin/roles',
'/api/admin/menus',
'/api/admin/config'
];
for (const endpoint of protectedEndpoints) {
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('参数配置功能测试', () => {
let authToken: string;
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: {
'Content-Type': 'application/json'
},
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('字典管理功能测试', () => {
let authToken: string;
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: {
'Content-Type': 'application/json'
},
+6 -6
View File
@@ -269,7 +269,7 @@ async function verifyAllServices(): Promise<void> {
console.log(' 验证网关到后端的连通性...');
try {
const response = await fetch('http://localhost:8080/api/auth/login', {
const response = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -316,7 +316,7 @@ async function waitForBackendReady(): Promise<void> {
console.log(`✅ 后端服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try {
const loginTest = await fetch('http://localhost:8084/api/auth/login', {
const loginTest = await fetch('http://localhost:8084/api/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -364,7 +364,7 @@ async function waitForGatewayReady(): Promise<void> {
console.log(`✅ 网关服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try {
const loginTest = await fetch('http://localhost:8080/api/auth/login', {
const loginTest = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
@@ -425,7 +425,7 @@ async function waitForFrontendReady(): Promise<void> {
async function cleanupTestData(): Promise<void> {
try {
// 登录获取token(通过网关)
const loginResponse = await fetch('http://localhost:8080/api/auth/login', {
const loginResponse = await fetch('http://localhost:8080/api/admin/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -458,7 +458,7 @@ async function cleanupTestData(): Promise<void> {
for (const user of users) {
if (user.id > 10) {
try {
await fetch(`http://localhost:8080/api/users/${user.id}`, {
await fetch(`http://localhost:8080/api/admin/users/${user.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
@@ -486,7 +486,7 @@ async function cleanupTestData(): Promise<void> {
for (const role of roles) {
if (role.id > 4) {
try {
await fetch(`http://localhost:8080/api/roles/${role.id}`, {
await fetch(`http://localhost:8080/api/admin/roles/${role.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
@@ -50,7 +50,7 @@ test.describe('管理员完整工作流', () => {
await test.step('提交表单', async () => {
const [response] = await Promise.all([
page.waitForResponse(resp =>
resp.url().includes('/api/roles') && resp.request().method() === 'POST',
resp.url().includes('/api/admin/roles') && resp.request().method() === 'POST',
{ timeout: 10000 }
).catch(() => null),
page.locator('.el-dialog button:has-text("确定")').click()
@@ -112,7 +112,7 @@ test.describe('用户权限边界验证', () => {
});
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());
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ test.describe('菜单管理功能测试', () => {
let authToken: string;
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: {
'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 }> {
const response = await this.request.post(`${this.baseURL}/api/auth/login`, {
const response = await this.request.post(`${this.baseURL}/api/admin/auth/login`, {
data: {
username,
password,
@@ -29,7 +29,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -37,7 +37,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -51,7 +51,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -66,7 +66,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -81,7 +81,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -93,7 +93,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -107,7 +107,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -122,7 +122,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
@@ -134,7 +134,7 @@ export class ApiClient {
}
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: {
Authorization: `Bearer ${token}`,
},
+4 -4
View File
@@ -55,7 +55,7 @@ export class TestDataManager {
}
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,
});
@@ -75,7 +75,7 @@ export class TestDataManager {
}
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,
});
@@ -100,7 +100,7 @@ export class TestDataManager {
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()) {
console.warn(`Failed to delete test user ${username}: ${await response.text()}`);
}
@@ -114,7 +114,7 @@ export class TestDataManager {
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()) {
console.warn(`Failed to delete test role ${roleKey}: ${await response.text()}`);
}
-10
View File
@@ -27,16 +27,6 @@
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
@@ -6,18 +6,12 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 会员到店签到记录实体
*
* @author 付嘉
* @date 2026-06-08
*/
@Data
@Builder
@NoArgsConstructor
@@ -25,185 +19,23 @@ import java.time.LocalDateTime;
@Table("sign_in_record")
public class SignInRecord {
/**
* 自增主键
*/
@Id
private Long id;
/**
* 会员ID,关联member表
*/
// 会员ID
@Column("member_id")
private Long memberId;
/**
* 签到时使用的会员卡ID
*/
@Column("member_card_id")
private Long memberCardId;
// 签到日期
@Column("sign_in_date")
private LocalDate signInDate;
/**
* 签到入场时间
*/
// 签到时间
@Column("sign_in_time")
private LocalDateTime signInTime;
/**
* 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
*/
@Column("sign_in_type")
private String signInType;
/**
* 签到状态:SUCCESS-成功,FAILED-失败
*/
@Column("sign_in_status")
private String signInStatus;
/**
* JSONB格式,存储会员卡验证时的快照数据
*/
@Column("verification_details")
private String verificationDetails;
/**
* 失败时的具体原因文案
*/
@Column("fail_reason")
private String failReason;
/**
* 操作人ID(前台人员),自助签到时为NULL
*/
@Column("operator_id")
private Long operatorId;
/**
* 操作人姓名冗余
*/
@Column("operator_name")
private String operatorName;
/**
* 签到设备标识或型号
*/
@Column("device_info")
private String deviceInfo;
/**
* 客户端IP地址
*/
@Column("ip_address")
private String ipAddress;
/**
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
*/
@Column("source")
private String source;
/**
* 软删除标识:false-未删除,true-已删除
*/
@Column("is_delete")
private Boolean isDelete;
/**
* 记录创建时间
*/
// 创建时间
@CreatedDate
@Column("created_at")
private LocalDateTime createdAt;
/**
* 记录更新时间
*/
@LastModifiedDate
@Column("updated_at")
private LocalDateTime updatedAt;
// ========== 常量定义 ==========
/**
* 签到类型常量
*/
public static final class SignInType {
/** 扫码签到 */
public static final String QR_CODE = "QR_CODE";
/** 手动签到 */
public static final String MANUAL = "MANUAL";
/** 人脸识别 */
public static final String FACE = "FACE";
private SignInType() {}
}
/**
* 签到状态常量
*/
public static final class SignInStatus {
/** 成功 */
public static final String SUCCESS = "SUCCESS";
/** 失败 */
public static final String FAILED = "FAILED";
private SignInStatus() {}
}
/**
* 签到来源常量
*/
public static final class Source {
/** 小程序扫码 */
public static final String MINI_PROGRAM = "MINI_PROGRAM";
/** 后台管理端手动签到 */
public static final String PC_BACKEND = "PC_BACKEND";
private Source() {}
}
// ========== 辅助方法 ==========
/**
* 判断签到是否成功
*/
public boolean isSuccess() {
return SignInStatus.SUCCESS.equals(this.signInStatus);
}
/**
* 判断签到是否失败
*/
public boolean isFailed() {
return SignInStatus.FAILED.equals(this.signInStatus);
}
/**
* 判断是否为扫码签到
*/
public boolean isQrCodeSign() {
return SignInType.QR_CODE.equals(this.signInType);
}
/**
* 判断是否已删除
*/
public boolean isDeleted() {
return Boolean.TRUE.equals(this.isDelete);
}
/**
* 软删除
*/
public void softDelete() {
this.isDelete = true;
}
/**
* 恢复删除
*/
public void restore() {
this.isDelete = false;
}
}
}
@@ -1,11 +1,9 @@
package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
@@ -13,8 +11,6 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@Slf4j
@@ -25,8 +21,6 @@ public class CheckInHandler {
private final AuthUtil authUtil;
private final CheckServiceImpl checkService;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 签到
*
@@ -35,17 +29,17 @@ public class CheckInHandler {
*/
public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(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);
boolean messageToClient = MyWebSocketHandler.sendMessageToClient(qrContent, "正在进行签到");
log.info("WebSocket 推送结果: {}", messageToClient);
return checkService.checkIn(memberId, qrContent)
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
.bodyValue(Map.of("code", 200, "message", "签到成功")));
})
.onErrorResume(e -> {
log.error("签到失败", e);
@@ -62,7 +56,8 @@ public class CheckInHandler {
*/
public Mono<ServerResponse> getQRCode(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
log.info("收到用户{}获取二维码请求", memberId);
@@ -71,123 +66,4 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(qrCodeVo));
}
/**
* 查询签到记录列表
*
* GET /api/checkIn/records
*
* @param request
* @return
*/
public Mono<ServerResponse> getSignInRecords(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("查询签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
return checkService.getSignInRecords(memberId, startDate, endDate)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 查询单条签到记录
*
* GET /api/checkIn/records/{id}
*
* @param request
* @return
*/
public Mono<ServerResponse> getSignInRecordById(ServerRequest request) {
Long id = Long.parseLong(request.pathVariable("id"));
log.info("查询签到记录详情, id: {}", id);
return checkService.getSignInRecordById(id)
.flatMap(record -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", record)))
.switchIfEmpty(ServerResponse.notFound().build());
}
/**
* 获取签到统计
*
* GET /api/checkIn/statistics
*
* @param request
* @return
*/
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("查询签到统计, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
return checkService.getSignInStats(memberId, startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
/**
* 导出签到记录
*
* GET /api/checkIn/records/export
*
* @param request
* @return
*/
public Mono<ServerResponse> exportSignInRecords(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("导出签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
String filename = "签到记录_" + startDateStr + "_" + endDateStr + ".csv";
return checkService.exportSignInRecords(memberId, startDate, endDate)
.flatMap(bytes -> ServerResponse.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.bodyValue(bytes));
}
/**
* 获取每日签到统计
*
* GET /api/checkIn/daily-stats
*
* @param request
* @return
*/
public Mono<ServerResponse> getDailySignInStats(ServerRequest request) {
String dateStr = request.queryParam("date").orElse(null);
LocalDate date = dateStr != null ? LocalDate.parse(dateStr, DATE_FORMATTER) : LocalDate.now();
log.info("查询每日签到统计, date: {}", date);
return checkService.getDailySignInStats(date)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
}
}
@@ -1,101 +0,0 @@
package cn.novalon.gym.manage.checkIn.repository;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 签到记录 Repository
*
* @author 付嘉
* @date 2026-06-08
*/
@Repository
public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Long> {
/**
* 查询会员某天的签到记录
*/
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
Mono<SignInRecord> findByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 查询会员的签到记录列表
*/
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByMemberId(Long memberId);
/**
* 统计会员某天的签到次数
*/
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
Mono<Long> countByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 插入签到记录
*/
@Query("INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, verification_details, fail_reason, source, created_at, updated_at, is_delete) " +
"VALUES (:memberId, :memberCardId, :signInTime, :signInType, :signInStatus, :verificationDetails, :failReason, :source, NOW(), NOW(), false)")
Mono<Void> insertRecord(Long memberId, Long memberCardId, LocalDateTime signInTime,
String signInType, String signInStatus, String verificationDetails,
String failReason, String source);
/**
* 根据会员ID和时间范围查询签到记录
*/
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计会员在时间范围内的签到次数
*/
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
Mono<Long> countByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计时间范围内的签到次数
*/
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
Mono<Long> countByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计会员在时间范围内的成功签到次数
*/
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
Mono<Long> countSuccessByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计时间范围内的成功签到次数
*/
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
Mono<Long> countSuccessByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 统计时间范围内签到的独立会员数
*/
@Query("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
Mono<Long> countDistinctMembersByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 获取会员在时间范围内的首次签到时间
*/
@Query("SELECT MIN(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
Mono<LocalDateTime> getFirstSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
/**
* 获取会员在时间范围内的最后签到时间
*/
@Query("SELECT MAX(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
Mono<LocalDateTime> getLastSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
}
@@ -1,81 +1,11 @@
package cn.novalon.gym.manage.checkIn.service;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
/**
* 签到服务接口
*
* @author 付嘉
* @date 2026-06-08
*/
public interface ICheckInService {
/**
* 获取签到二维码
*
* @param memberId 会员ID
* @return 二维码VO
*/
Mono<QRCodeVo> getQRCode(Long memberId);
/**
* 扫码签到
*
* @param memberId 会员ID
* @param qrContent 二维码内容
* @return 签到结果JSON字符串
*/
Mono<String> checkIn(Long memberId, String qrContent);
/**
* 查询会员签到记录列表
*
* @param memberId 会员ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 签到记录列表
*/
Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
/**
* 根据ID查询签到记录
*
* @param id 签到记录ID
* @return 签到记录VO
*/
Mono<SignInRecordVO> getSignInRecordById(Long id);
/**
* 获取会员签到统计
*
* @param memberId 会员ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 签到统计VO
*/
Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime);
/**
* 导出会员签到记录
*
* @param memberId 会员ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return CSV格式的字节数组
*/
Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
/**
* 获取每日签到统计
*
* @param date 日期
* @return 签到统计VO
*/
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
}
}
@@ -1,39 +1,26 @@
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.entity.SignInRecord;
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
import cn.novalon.gym.manage.checkIn.service.ICheckInService;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@@ -41,399 +28,70 @@ import java.util.Map;
@RequiredArgsConstructor
public class CheckServiceImpl implements ICheckInService {
@Autowired
private final QRCodeConfig qrCodeConfig;
private final RedisUtil redisUtil;
private final MemberCardRecordRepository memberCardRecordRepository;
private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final RedisUtil redisUtil;
@Override
public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始查询会员信息, memberId: {}", memberId);
return findValidMemberCard(memberId)
.flatMap(cardRecord -> {
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
log.info("开始生成二维码");
String qrContent = QRRedisKey.generateQrcodeContent();
Map<String, Object> redisMap = new HashMap<>();
redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false);
redisMap.put("memberId", memberId);
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
log.info("开始查询会员信息");
// TODO: 获取会员信息 - 查会员卡有效期/剩余次数,过期返回,先查缓存,缓存不存在则查数据库
// if (member有效期过了) throw new RuntimeException("会员有效期已过,拒绝生成二维码");
log.info("会员信息查询完成");
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, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
}));
})
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
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();
String key = RedisKeyConstants.QRCODE_USER_DAILY+memberId+LocalDate.now();
// 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障)
return checkTodayAlreadySignedIn(memberId)
.flatMap(existingRecord -> {
String checkInTime = existingRecord.getSignInTime().format(DATE_FORMATTER);
log.error("重复签到, memberId: {}", memberId);
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
})
.then(Mono.defer(() -> redisUtil.get(key)))
.flatMap(cachedObj -> {
if (cachedObj != null) {
Map<String, Object> map;
if (cachedObj instanceof Map) {
map = (Map<String, Object>) cachedObj;
} else if (cachedObj instanceof String) {
map = JSONUtil.parseObj((String) cachedObj);
} else {
MyWebSocketHandler.sendFailure(qrContent, "二维码数据格式错误");
return Mono.error(new RuntimeException("二维码数据格式错误"));
}
if (map.get("qrContent").equals(qrContent)) {
if ((boolean) map.get("isUsed")) {
String checkInTime = String.valueOf(map.get("checkInTime"));
log.error("重复签到(缓存), memberId: {}", memberId);
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
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")));
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
return processCheckIn(memberId, memberCardRecordId, map, qrContent);
} else {
MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
return Mono.error(new RuntimeException("二维码无效"));
return redisUtil.set(key,map).
then(Mono.just("签到成功"));
}
}
MyWebSocketHandler.sendFailure(qrContent, "二维码已过期或不存在");
return Mono.error(new RuntimeException("二维码已过期或不存在"));
});
}
/**
* 检查会员当天是否已经签到
* @param memberId 会员ID
* @return 如果已签到返回签到记录,否则返回空
*/
private Mono<SignInRecord> checkTodayAlreadySignedIn(Long memberId) {
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay);
}
/**
* 处理签到逻辑
*/
private Mono<String> processCheckIn(Long memberId, Long memberCardRecordId, Map<String, Object> redisMap, String qrContent) {
LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡...");
return memberCardRecordRepository.findById(memberCardRecordId)
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在");
return Mono.error(new RuntimeException("会员卡记录不存在"));
}))
.flatMap(cardRecord -> {
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
return Mono.error(new RuntimeException("会员卡状态不正确"));
}
if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期");
return Mono.error(new RuntimeException("会员卡已过期"));
}
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
// 检查是否有需要签到的团课预约
return validateBooking(memberId, now)
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId())
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在");
return Mono.error(new RuntimeException("会员卡类型不存在"));
}))
.flatMap(card -> {
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
return deductCardUsage(cardRecord, card)
.flatMap(updatedRecord -> {
redisMap.put("isUsed", true);
redisMap.put("checkInTime", now.format(DATE_FORMATTER));
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId())
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
.then(Mono.defer(() -> {
String successMsg = buildSuccessResponse(now);
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId);
return Mono.just(successMsg);
}));
});
}));
});
}
/**
* 验证预约信息
*/
private Mono<Void> validateBooking(Long memberId, LocalDateTime now) {
return groupCourseBookingService.getBookingsByMemberId(memberId)
.filter(booking -> {
String status = booking.getStatus();
LocalDateTime startTime = booking.getCourseStartTime();
return "0".equals(status) &&
startTime != null &&
startTime.toLocalDate().equals(now.toLocalDate()) &&
!startTime.isBefore(now.minusMinutes(30));
throw new RuntimeException("二维码无效");
})
.collectList()
.flatMap(bookings -> {
if (bookings.isEmpty()) {
return Mono.empty();
}
boolean hasValidBooking = bookings.stream()
.anyMatch(b -> {
LocalDateTime startTime = b.getCourseStartTime();
return startTime != null &&
!startTime.isBefore(now.minusMinutes(30)) &&
!startTime.isAfter(now.plusMinutes(30));
});
if (hasValidBooking) {
log.info("会员{}有有效的团课预约", memberId);
} else {
log.warn("会员{}有预约但不在签到时间范围内", memberId);
}
return Mono.empty();
});
}
/**
* 扣减会员卡使用次数/金额
*/
private Mono<MemberCardRecord> deductCardUsage(MemberCardRecord record, MemberCard card) {
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
LocalDateTime now = LocalDateTime.now();
switch (cardType) {
case TIME_CARD:
if (record.getExpireTime() != null && record.getExpireTime().isBefore(now)) {
return Mono.error(new RuntimeException("时长卡已过期"));
}
return Mono.just(record);
case COUNT_CARD:
int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0;
if (currentTimes < 1) {
return Mono.error(new RuntimeException("次卡剩余次数不足"));
}
record.setRemainingTimes(currentTimes - 1);
if (record.getRemainingTimes() == 0) {
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
}
return memberCardRecordRepository.save(record);
case STORED_VALUE_CARD:
double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0;
if (currentAmount < 0.01) {
return Mono.error(new RuntimeException("储值卡余额不足"));
}
record.setRemainingAmount(Math.max(0, currentAmount - 1));
if (record.getRemainingAmount() <= 0) {
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
}
return memberCardRecordRepository.save(record);
default:
return Mono.error(new RuntimeException("不支持的会员卡类型"));
}
}
/**
* 保存签到记录
*/
private Mono<Void> saveSignInRecord(Long memberId, Long memberCardRecordId, Long memberCardId) {
SignInRecord record = SignInRecord.builder()
.memberId(memberId)
.memberCardId(memberCardId)
.signInTime(LocalDateTime.now())
.signInType(SignInRecord.SignInType.QR_CODE)
.signInStatus(SignInRecord.SignInStatus.SUCCESS)
.source(SignInRecord.Source.MINI_PROGRAM)
.isDelete(false)
.build();
return signInRecordRepository.save(record).then();
}
/**
* 构建成功响应
*/
private String buildSuccessResponse(LocalDateTime dateTime) {
Map<String, Object> res = new HashMap<>();
res.put("message", "签到成功");
res.put("dateTime", dateTime.format(DATE_FORMATTER));
return JSONUtil.toJsonStr(res);
}
/**
* 查找会员的有效会员卡(优先选择有效期最早到期的)
*/
private Mono<MemberCardRecord> findValidMemberCard(Long memberId) {
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
.filter(record -> {
LocalDateTime expireTime = record.getExpireTime();
return expireTime == null || expireTime.isAfter(LocalDateTime.now());
})
.sort((r1, r2) -> {
LocalDateTime e1 = r1.getExpireTime();
LocalDateTime e2 = r2.getExpireTime();
if (e1 == null && e2 == null) return 0;
if (e1 == null) return 1;
if (e2 == null) return -1;
return e1.compareTo(e2);
})
.next();
}
// ==================== 签到记录管理功能 ====================
@Override
public Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
.map(this::convertToVO);
}
@Override
public Mono<SignInRecordVO> getSignInRecordById(Long id) {
return signInRecordRepository.findById(id)
.map(this::convertToVO);
}
@Override
public Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
LocalDateTime first = (LocalDateTime) results[2];
LocalDateTime last = (LocalDateTime) results[3];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(startTime);
stats.setEndDate(endTime);
stats.setFirstSignInTime(first);
stats.setLastSignInTime(last);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByMemberIdAndTimeRange(memberId, start, end),
signInRecordRepository.countSuccessByMemberIdAndTimeRange(memberId, start, end),
signInRecordRepository.getFirstSignInTime(memberId, start, end),
signInRecordRepository.getLastSignInTime(memberId, start, end)
);
}
@Override
public Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
.map(record -> {
String status = "SUCCESS".equals(record.getSignInStatus()) ? "成功" : "失败";
String type = "QR_CODE".equals(record.getSignInType()) ? "扫码签到" :
"MANUAL".equals(record.getSignInType()) ? "手动签到" : "人脸识别";
return String.join(",",
record.getId().toString(),
record.getMemberId().toString(),
record.getMemberCardId() != null ? record.getMemberCardId().toString() : "",
record.getSignInTime() != null ? record.getSignInTime().format(DATE_FORMATTER) : "",
type,
status,
record.getFailReason() != null ? record.getFailReason() : ""
);
})
.collectList()
.map(rows -> {
List<String> csvLines = new java.util.ArrayList<>();
csvLines.add("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因");
csvLines.addAll(rows);
return String.join("\n", csvLines).getBytes(java.nio.charset.StandardCharsets.UTF_8);
});
}
@Override
public Mono<SignInStatsVO> getDailySignInStats(LocalDate date) {
LocalDateTime start = date.atStartOfDay();
LocalDateTime end = date.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
Long members = (Long) results[2];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(date);
stats.setEndDate(date);
stats.setUniqueMemberCount(members);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByTimeRange(start, end),
signInRecordRepository.countSuccessByTimeRange(start, end),
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
);
}
/**
* 转换实体到VO
*/
private SignInRecordVO convertToVO(SignInRecord record) {
SignInRecordVO vo = new SignInRecordVO();
vo.setId(record.getId());
vo.setMemberId(record.getMemberId());
vo.setMemberCardId(record.getMemberCardId());
vo.setSignInTime(record.getSignInTime());
vo.setSignInType(record.getSignInType());
vo.setSignInStatus(record.getSignInStatus());
vo.setFailReason(record.getFailReason());
vo.setSource(record.getSource());
vo.setCreatedAt(record.getCreatedAt());
return vo;
.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);
}
}
}
@@ -3,8 +3,6 @@ package cn.novalon.gym.manage.checkIn.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDate;
@Data
@AllArgsConstructor
public class QRCodeVo {
@@ -13,11 +11,7 @@ public class QRCodeVo {
private boolean isUsed;
private String qrContent;
private Integer width;
private Integer height;
private LocalDate createTime;
}
@@ -1,66 +0,0 @@
package cn.novalon.gym.manage.checkIn.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 签到记录VO
*
* @author 付嘉
* @date 2026-06-08
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignInRecordVO {
/**
* 签到记录ID
*/
private Long id;
/**
* 会员ID
*/
private Long memberId;
/**
* 会员卡ID
*/
private Long memberCardId;
/**
* 签到时间
*/
private LocalDateTime signInTime;
/**
* 签到类型:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
*/
private String signInType;
/**
* 签到状态:SUCCESS-成功,FAILED-失败
*/
private String signInStatus;
/**
* 失败原因
*/
private String failReason;
/**
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
*/
private String source;
/**
* 创建时间
*/
private LocalDateTime createdAt;
}
@@ -1,62 +0,0 @@
package cn.novalon.gym.manage.checkIn.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 签到统计VO
*
* @author 付嘉
* @date 2026-06-08
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignInStatsVO {
/**
* 统计开始日期
*/
private LocalDate startDate;
/**
* 统计结束日期
*/
private LocalDate endDate;
/**
* 总签到次数
*/
private Long totalCount;
/**
* 成功签到次数
*/
private Long successCount;
/**
* 成功率(百分比)
*/
private Double successRate;
/**
* 独立会员数
*/
private Long uniqueMemberCount;
/**
* 首次签到时间
*/
private LocalDateTime firstSignInTime;
/**
* 最后签到时间
*/
private LocalDateTime lastSignInTime;
}
@@ -5,198 +5,94 @@ 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 reactor.core.publisher.Sinks;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket 处理类,用于实时签到反馈
*
* 技术要点:
* - 使用 Sinks 实现响应式消息推送
* - 使用 ConcurrentHashMap 管理 qrContent 与 sink 的映射
* - 支持实时推送签到进度和结果
*/
@Slf4j
@Component
public class MyWebSocketHandler implements WebSocketHandler {
/**
* qrContent -> Sink 映射,用于根据二维码内容找到对应的客户端连接
*/
private static final Map<String, Sinks.Many<String>> qrContentToSink = new ConcurrentHashMap<>();
/**
* 连接创建时间映射,用于超时清理
*/
private static final Map<String, LocalDateTime> qrContentToCreateTime = new ConcurrentHashMap<>();
/**
* 超时时间(秒),超过此时间未使用的连接将被清理
*/
private static final long TIMEOUT_SECONDS = 300;
// 存储所有连接
private static final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@Override
public Mono<Void> handle(WebSocketSession session) {
String sessionId = session.getId();
log.info("WebSocket 连接建立,sessionId: {}", sessionId);
// 创建 sink,用于向客户端发送消息
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
// 连接建立
sessions.put(sessionId, session);
log.info("WebSocket 连接建立,sessionId{},当前连接数:{}", sessionId, sessions.size());
// 订阅接收客户端消息(异步处理)
session.receive()
// 处理接收到的消息
Flux<WebSocketMessage> output = session.receive()
.doOnNext(message -> {
String payload = message.getPayloadAsText();
log.debug("收到消息:sessionId={}, payload={}", sessionId, payload);
try {
QRCodeDto qrCodeDto = JSONUtil.toBean(payload, QRCodeDto.class);
String qrContent = qrCodeDto.getQrContent();
if (qrContent != null && !qrContent.isEmpty()) {
// 绑定 qrContent 和 sink
qrContentToSink.put(qrContent, sink);
qrContentToCreateTime.put(qrContent, LocalDateTime.now());
log.info("绑定成功: qrContent={}, sessionId={}", qrContent, sessionId);
// 发送连接成功消息
sink.tryEmitNext(buildMessage("CONNECTED", "签到监听已建立,请扫描二维码"));
} else {
sink.tryEmitNext(buildMessage("ERROR", "二维码内容为空"));
}
} catch (Exception e) {
log.error("解析消息失败,sessionId={}", sessionId, e);
sink.tryEmitNext(buildMessage("ERROR", "消息格式错误: " + e.getMessage()));
}
log.info("收到消息:{}", payload);
})
.doOnError(e -> {
log.error("接收消息出错,sessionId={}", sessionId, e);
})
.subscribe(); // 必须订阅,否则不会执行
.map(message -> {
String payload = message.getPayloadAsText();
String response = processMessage(payload, sessionId);
return session.textMessage(response);
});
// 发送流给客户端
return session.send(sink.asFlux().map(session::textMessage))
.doFinally(signal -> {
// 连接关闭时清理映射
qrContentToSink.entrySet().removeIf(entry -> entry.getValue() == sink);
qrContentToCreateTime.entrySet().removeIf(entry -> {
Sinks.Many<String> s = qrContentToSink.get(entry.getKey());
return s == null || s == sink;
});
log.info("WebSocket 连接关闭,sessionId={}", sessionId);
// 连接关闭时清理
return session.send(output)
.doFinally(signalType -> {
sessions.remove(sessionId);
log.info("WebSocket 连接关闭,sessionId{},剩余连接数:{}", sessionId, sessions.size());
});
}
/**
* 向客户端发送消息
*
* @param qrContent 二维码内容
* @param message 消息内容
* @return 是否发送成功
* 处理消息逻辑
*/
public static boolean sendMessageToClient(String qrContent, String message) {
// 先清理超时连接
cleanupTimeoutConnections();
private String processMessage(String message, String sessionId) {
try {
// 解析 QRCodeDto
QRCodeDto qrCodeDto = JSONUtil.toBean(message, QRCodeDto.class);
Sinks.Many<String> sink = qrContentToSink.get(qrContent);
if (sink == null) {
log.warn("未找到绑定的连接,qrContent: {}", qrContent);
return false;
}
String response;
Sinks.EmitResult result = sink.tryEmitNext(message);
if (result.isSuccess()) {
log.info("主动推送成功,qrContent: {}, message: {}", qrContent, message);
return true;
} else {
log.warn("推送失败,qrContent: {}, result: {}", qrContent, result);
return false;
}
}
// 判断二维码是否有效
if (qrCodeDto.getQrContent() != null
&& !qrCodeDto.getQrContent().isEmpty()
&& !qrCodeDto.isUsed()) {
// 有效:qrContent 有值且 isUsed 为 false
response = "正在进行签到";
/**
* 发送签到进度消息
*
* @param qrContent 二维码内容
* @param step 进度步骤
* @param message 进度消息
*/
public static void sendProgress(String qrContent, String step, String message) {
String progressMessage = buildMessage("PROGRESS", message);
sendMessageToClient(qrContent, progressMessage);
log.debug("发送进度消息: qrContent={}, step={}, message={}", qrContent, step, message);
}
// 可选:将二维码标记为已使用(需要调用后端服务)
// checkInService.handleCheckIn(qrCodeDto.getQrContent());
/**
* 发送签到成功消息
*
* @param qrContent 二维码内容
* @param memberId 会员ID
* @param signInTime 签到时间
*/
public static void sendSuccess(String qrContent, Long memberId, String signInTime) {
String successMessage = buildMessage("SUCCESS", "签到成功!欢迎光临\n会员ID: " + memberId + "\n签到时间: " + signInTime);
sendMessageToClient(qrContent, successMessage);
log.info("发送成功消息: qrContent={}, memberId={}", qrContent, memberId);
}
/**
* 发送签到失败消息
*
* @param qrContent 二维码内容
* @param reason 失败原因
*/
public static void sendFailure(String qrContent, String reason) {
String failureMessage = buildMessage("FAILURE", "签到失败:" + reason);
sendMessageToClient(qrContent, failureMessage);
log.warn("发送失败消息: qrContent={}, reason={}", qrContent, reason);
}
/**
* 构建标准消息格式
*
* @param type 消息类型
* @param content 消息内容
* @return 格式化后的消息字符串
*/
private static String buildMessage(String type, String content) {
return JSONUtil.toJsonStr(Map.of(
"type", type,
"content", content,
"timestamp", System.currentTimeMillis()
));
}
/**
* 清理超时连接
*/
private static void cleanupTimeoutConnections() {
LocalDateTime now = LocalDateTime.now();
qrContentToCreateTime.entrySet().removeIf(entry -> {
LocalDateTime createTime = entry.getValue();
long secondsDiff = java.time.Duration.between(createTime, now).getSeconds();
if (secondsDiff > TIMEOUT_SECONDS) {
String qrContent = entry.getKey();
qrContentToSink.remove(qrContent);
log.debug("清理超时连接: qrContent={}, 超时时间={}秒", qrContent, secondsDiff);
return true;
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 false;
});
return response;
} catch (Exception e) {
log.error("解析消息失败,sessionId{}", sessionId, e);
return "消息格式错误";
}
}
/**
* 获取当前连接数
*
* @return 连接数
* 获取当前在线连接数
*/
public static int getConnectionCount() {
cleanupTimeoutConnections();
return qrContentToSink.size();
public static int getOnlineCount() {
return sessions.size();
}
}
@@ -1,281 +1,12 @@
package cn.novalon.gym.manage.checkin;
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* 签到模块接口测试类
* 测试模块三(gym-checkIn)的所有接口
*/
class CheckInModuleTest {
@Mock
private QRCodeConfig qrCodeConfig;
@Mock
private RedisUtil redisUtil;
@Mock
private MemberCardRecordRepository memberCardRecordRepository;
@Mock
private MemberCardRepository memberCardRepository;
@Mock
private SignInRecordRepository signInRecordRepository;
@Mock
private IGroupCourseBookingService groupCourseBookingService;
@Mock
private MemberCard mockMemberCard;
@Mock
private SignInRecord mockSignInRecord;
@Mock
private MemberCardRecord mockMemberCardRecord;
private CheckServiceImpl checkService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
memberCardRepository, signInRecordRepository, groupCourseBookingService);
when(mockMemberCard.getId()).thenReturn(1L);
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
when(mockSignInRecord.getId()).thenReturn(1L);
when(mockSignInRecord.getMemberId()).thenReturn(1L);
when(mockSignInRecord.getMemberCardId()).thenReturn(1L);
when(mockSignInRecord.getSignInTime()).thenReturn(LocalDateTime.now());
when(mockSignInRecord.getSignInType()).thenReturn("QR_CODE");
when(mockSignInRecord.getSignInStatus()).thenReturn("SUCCESS");
when(mockSignInRecord.getSource()).thenReturn("MINI_PROGRAM");
when(mockMemberCardRecord.getMemberCardRecordId()).thenReturn(1L);
when(mockMemberCardRecord.getMemberCardId()).thenReturn(1L);
when(mockMemberCardRecord.getRemainingTimes()).thenReturn(10);
when(mockMemberCardRecord.getRemainingAmount()).thenReturn(100.0);
when(mockMemberCardRecord.getExpireTime()).thenReturn(LocalDateTime.now().plusDays(30));
when(mockMemberCardRecord.getStatus()).thenReturn(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE);
}
@SpringBootTest
public class CheckInModuleTest {
@Test
@DisplayName("测试1: 获取二维码 - getQRCode")
void testGetQRCode() {
when(memberCardRecordRepository.findActiveCardsByMemberId(1L))
.thenReturn(Flux.just(mockMemberCardRecord));
when(redisUtil.setWithExpire(any(String.class), any(Map.class), any(Long.class)))
.thenReturn(Mono.just(true));
Mono<QRCodeVo> result = checkService.getQRCode(1L);
StepVerifier.create(result)
.expectNextMatches(qrCodeVo -> {
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo);
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo.getQrContent());
return true;
})
.verifyComplete();
}
@Test
@DisplayName("测试2: 签到 - checkIn")
void testCheckIn() {
Long memberId = 1L;
Map<String, Object> qrData = new HashMap<>();
qrData.put("qrContent", "test-qr-content");
qrData.put("memberId", memberId);
qrData.put("memberCardRecordId", 1L);
qrData.put("isUsed", false);
qrData.put("expireTime", System.currentTimeMillis() + 3600000);
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard));
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.empty());
Mono<String> result = checkService.checkIn(memberId, "test-qr-content");
StepVerifier.create(result)
.expectNextMatches(response -> response.contains("签到成功"))
.verifyComplete();
}
@Test
@DisplayName("测试3: 查询签到记录列表 - getSignInRecords")
void testGetSignInRecords() {
when(signInRecordRepository.findByMemberIdAndTimeRange(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Flux.just(mockSignInRecord));
Flux<SignInRecordVO> result = checkService.getSignInRecords(1L,
LocalDate.now().minusDays(30), LocalDate.now());
StepVerifier.create(result)
.expectNextCount(1)
.verifyComplete();
}
@Test
@DisplayName("测试4: 查询单条签到记录 - getSignInRecordById")
void testGetSignInRecordById() {
when(signInRecordRepository.findById(1L))
.thenReturn(Mono.just(mockSignInRecord));
Mono<SignInRecordVO> result = checkService.getSignInRecordById(1L);
StepVerifier.create(result)
.expectNextMatches(vo -> vo.getId() == 1L)
.verifyComplete();
}
@Test
@DisplayName("测试5: 查询签到记录 - 记录不存在")
void testGetSignInRecordById_NotFound() {
when(signInRecordRepository.findById(999L))
.thenReturn(Mono.empty());
Mono<SignInRecordVO> result = checkService.getSignInRecordById(999L);
StepVerifier.create(result)
.verifyComplete();
}
@Test
@DisplayName("测试6: 获取签到统计 - getSignInStats")
void testGetSignInStats() {
when(signInRecordRepository.countByMemberIdAndTimeRange(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(10L));
when(signInRecordRepository.countSuccessByMemberIdAndTimeRange(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(8L));
when(signInRecordRepository.getFirstSignInTime(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(LocalDateTime.now().minusDays(29)));
when(signInRecordRepository.getLastSignInTime(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(LocalDateTime.now()));
Mono<SignInStatsVO> result = checkService.getSignInStats(1L,
LocalDate.now().minusDays(30), LocalDate.now());
StepVerifier.create(result)
.expectNextMatches(stats -> {
org.junit.jupiter.api.Assertions.assertEquals(10L, stats.getTotalCount());
org.junit.jupiter.api.Assertions.assertEquals(8L, stats.getSuccessCount());
return true;
})
.verifyComplete();
}
@Test
@DisplayName("测试7: 获取每日签到统计 - getDailySignInStats")
void testGetDailySignInStats() {
when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(50L));
when(signInRecordRepository.countSuccessByTimeRange(
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(45L));
when(signInRecordRepository.countDistinctMembersByTimeRange(
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(30L));
Mono<SignInStatsVO> result = checkService.getDailySignInStats(LocalDate.now());
StepVerifier.create(result)
.expectNextMatches(stats -> {
org.junit.jupiter.api.Assertions.assertEquals(50L, stats.getTotalCount());
org.junit.jupiter.api.Assertions.assertEquals(45L, stats.getSuccessCount());
org.junit.jupiter.api.Assertions.assertEquals(30L, stats.getUniqueMemberCount());
return true;
})
.verifyComplete();
}
@Test
@DisplayName("测试8: 导出签到记录 - exportSignInRecords")
void testExportSignInRecords() {
when(signInRecordRepository.findByMemberIdAndTimeRange(
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Flux.just(mockSignInRecord));
Mono<byte[]> result = checkService.exportSignInRecords(1L,
LocalDate.now().minusDays(7), LocalDate.now());
StepVerifier.create(result)
.expectNextMatches(bytes -> bytes.length > 0)
.verifyComplete();
}
@Test
@DisplayName("测试9: 签到失败 - 二维码无效")
void testCheckIn_QRCodeInvalid() {
Long memberId = 1L;
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
when(redisUtil.get(eq(key))).thenReturn(Mono.just(new HashMap<>()));
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.empty());
Mono<String> result = checkService.checkIn(memberId, "invalid-qr");
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
@Test
@DisplayName("测试10: 签到失败 - 二维码不存在")
void testCheckIn_QRCodeNotFound() {
Long memberId = 1L;
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
when(redisUtil.get(eq(key))).thenReturn(Mono.empty());
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.empty());
Mono<String> result = checkService.checkIn(memberId, "not-exist");
StepVerifier.create(result)
.verifyComplete();
public void contextLoads() {
}
}
@@ -43,7 +43,7 @@ public class MemberHandler {
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("获取会员信息, memberId: {}", memberId);
@@ -56,7 +56,7 @@ public class MemberHandler {
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
public Mono<ServerResponse> updateMemberInfo(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("更新会员信息, memberId: {}", memberId);
@@ -70,7 +70,7 @@ public class MemberHandler {
@Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号")
public Mono<ServerResponse> bindPhone(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
Long memberId = authUtil.getMemberUserIdOrThrow(request);
String phoneCode = request.queryParam("phoneCode").orElse("");
@@ -87,7 +87,7 @@ public class MemberHandler {
@Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号")
public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
Long memberId = authUtil.getMemberUserIdOrThrow(request);
log.info("查询服务号关注状态, memberId: {}", memberId);
@@ -102,7 +102,7 @@ public class MemberHandler {
@Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号")
public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -134,7 +134,7 @@ public class MemberHandler {
@Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息")
public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -162,7 +162,7 @@ public class MemberHandler {
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long adminId = authUtil.getAdminUserIdOrThrow(request);
String memberIdStr = request.pathVariable("id");
long memberId = NumberUtils.toLong(memberIdStr, 0L);
@@ -181,7 +181,7 @@ public class MemberHandler {
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
public Mono<ServerResponse> searchMembers(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long adminId = authUtil.getAdminUserIdOrThrow(request);
String keyword = request.queryParam("searchValue").orElse(null);
Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
@@ -212,7 +212,7 @@ public class MemberHandler {
@Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表")
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 pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
@@ -303,9 +303,9 @@ public class WechatAuthServiceImpl implements WechatAuthService {
}
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;
@@ -316,6 +316,7 @@ public class WechatAuthServiceImpl implements WechatAuthService {
.expiresIn(expiresIn)
.isNewUser(isNewUser)
.needCompleteInfo(needCompleteInfo)
.userType("MEMBER")
.build();
}
}
@@ -35,4 +35,7 @@ public class WechatLoginVO {
// 是否需要补全信息(昵称、手机号等)
private Boolean needCompleteInfo;
// 用户类型(MEMBER
private String userType;
}
@@ -7,7 +7,10 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.web.server.WebFilter;
@@ -20,8 +23,7 @@ import java.util.List;
"cn.novalon.gym.manage.db.dao",
"cn.novalon.gym.manage.sys.audit.repository" ,
"cn.novalon.gym.manage.gymmembercard.dao",
"cn.novalon.gym.manage.member.repository",
"cn.novalon.gym.manage.checkIn.repository"
"cn.novalon.gym.manage.member.repository"
})
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
public class ManageApplication {
@@ -34,7 +34,7 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r
*
* 文件定义:配置WebFlux函数式路由,将HTTP请求映射到对应的Handler方法
* 涉及业务:用户、角色、字典、菜单、公告、文件等所有RESTful API路由
* 算法:使用RouterFunctions.route()构建函数式路由规则
* 路由规范:后台管理 API 统一前缀 /api/admin/**,前台会员 API 统一前缀 /api/member/**
*
* @author 张翔
* @date 2026-03-13
@@ -67,151 +67,151 @@ public class SystemRouter {
CheckInHandler checkInHandler) {
return route()
// ========== 诊断路由 ==========
.GET("/api/diagnostic/password", passwordDiagnosticHandler::diagnose)
// ========== 诊断路由(管理端) ==========
.GET("/api/admin/diagnostic/password", passwordDiagnosticHandler::diagnose)
// ========== 字典路由 ==========
.GET("/api/dictionaries", dictionaryHandler::getAllDictionaries)
.GET("/api/dictionaries/{id}", dictionaryHandler::getDictionaryById)
.GET("/api/dictionaries/type/{type}", dictionaryHandler::getDictionariesByType)
.GET("/api/dictionaries/check/exists", dictionaryHandler::checkTypeAndCodeExists)
.POST("/api/dictionaries", dictionaryHandler::createDictionary)
.PUT("/api/dictionaries/{id}", dictionaryHandler::updateDictionary)
.DELETE("/api/dictionaries/{id}", dictionaryHandler::deleteDictionary)
// ========== 字典路由(管理端) ==========
.GET("/api/admin/dictionaries", dictionaryHandler::getAllDictionaries)
.GET("/api/admin/dictionaries/{id}", dictionaryHandler::getDictionaryById)
.GET("/api/admin/dictionaries/type/{type}", dictionaryHandler::getDictionariesByType)
.GET("/api/admin/dictionaries/check/exists", dictionaryHandler::checkTypeAndCodeExists)
.POST("/api/admin/dictionaries", dictionaryHandler::createDictionary)
.PUT("/api/admin/dictionaries/{id}", dictionaryHandler::updateDictionary)
.DELETE("/api/admin/dictionaries/{id}", dictionaryHandler::deleteDictionary)
// ========== 用户路由 ==========
.GET("/api/users", userHandler::getAllUsers)
.GET("/api/users/page", userHandler::getUsersByPage)
.GET("/api/users/count", userHandler::getUserCount)
.GET("/api/users/username/{username}", userHandler::getUserByUsername)
.GET("/api/users/check/username", userHandler::checkUsernameExists)
.GET("/api/users/check/email", userHandler::checkEmailExists)
.POST("/api/users", userHandler::createUser)
.GET("/api/users/{id}", userHandler::getUserById)
.PUT("/api/users/{id}", userHandler::updateUser)
.DELETE("/api/users/{id}", userHandler::deleteUser)
.POST("/api/users/{id}/action/change-password", userHandler::changePassword)
.POST("/api/users/{id}/action/logical-delete", userHandler::logicalDeleteUser)
.POST("/api/users/logical-delete", userHandler::logicalDeleteUsers)
.POST("/api/users/action/restore", userHandler::restoreUsers)
.POST("/api/users/{id}/action/restore", userHandler::restoreUser)
.GET("/api/users/{id}/roles", userHandler::getUserRoles)
.POST("/api/users/{id}/roles", userHandler::assignRoles)
// ========== 用户路由(管理端) ==========
.GET("/api/admin/users", userHandler::getAllUsers)
.GET("/api/admin/users/page", userHandler::getUsersByPage)
.GET("/api/admin/users/count", userHandler::getUserCount)
.GET("/api/admin/users/username/{username}", userHandler::getUserByUsername)
.GET("/api/admin/users/check/username", userHandler::checkUsernameExists)
.GET("/api/admin/users/check/email", userHandler::checkEmailExists)
.POST("/api/admin/users", userHandler::createUser)
.GET("/api/admin/users/{id}", userHandler::getUserById)
.PUT("/api/admin/users/{id}", userHandler::updateUser)
.DELETE("/api/admin/users/{id}", userHandler::deleteUser)
.POST("/api/admin/users/{id}/action/change-password", userHandler::changePassword)
.POST("/api/admin/users/{id}/action/logical-delete", userHandler::logicalDeleteUser)
.POST("/api/admin/users/logical-delete", userHandler::logicalDeleteUsers)
.POST("/api/admin/users/action/restore", userHandler::restoreUsers)
.POST("/api/admin/users/{id}/action/restore", userHandler::restoreUser)
.GET("/api/admin/users/{id}/roles", userHandler::getUserRoles)
.POST("/api/admin/users/{id}/roles", userHandler::assignRoles)
// ========== 菜单路由 ==========
.GET("/api/menus", menuHandler::getAllMenus)
.GET("/api/menus/tree", menuHandler::getMenuTree)
.GET("/api/menus/{id}", menuHandler::getMenuById)
.POST("/api/menus", menuHandler::createMenu)
.PUT("/api/menus/{id}", menuHandler::updateMenu)
.DELETE("/api/menus/{id}", menuHandler::deleteMenu)
// ========== 菜单路由(管理端) ==========
.GET("/api/admin/menus", menuHandler::getAllMenus)
.GET("/api/admin/menus/tree", menuHandler::getMenuTree)
.GET("/api/admin/menus/{id}", menuHandler::getMenuById)
.POST("/api/admin/menus", menuHandler::createMenu)
.PUT("/api/admin/menus/{id}", menuHandler::updateMenu)
.DELETE("/api/admin/menus/{id}", menuHandler::deleteMenu)
// ========== 角色路由 ==========
.GET("/api/roles", roleHandler::getAllRoles)
.GET("/api/roles/page", roleHandler::getRolesByPage)
.GET("/api/roles/count", roleHandler::getRoleCount)
.GET("/api/roles/name/{roleName}", roleHandler::getRoleByName)
.GET("/api/roles/check-name", roleHandler::checkNameExists)
.GET("/api/roles/{id}", roleHandler::getRoleById)
.POST("/api/roles", roleHandler::createRole)
.PUT("/api/roles/{id}", roleHandler::updateRole)
.DELETE("/api/roles/{id}", roleHandler::deleteRole)
.POST("/api/roles/{id}/restore", roleHandler::restoreRole)
.GET("/api/roles/{id}/permissions", permissionHandler::getPermissionsByRoleId)
.POST("/api/roles/{id}/permissions", permissionHandler::assignPermissionsToRole)
// ========== 角色路由(管理端) ==========
.GET("/api/admin/roles", roleHandler::getAllRoles)
.GET("/api/admin/roles/page", roleHandler::getRolesByPage)
.GET("/api/admin/roles/count", roleHandler::getRoleCount)
.GET("/api/admin/roles/name/{roleName}", roleHandler::getRoleByName)
.GET("/api/admin/roles/check-name", roleHandler::checkNameExists)
.GET("/api/admin/roles/{id}", roleHandler::getRoleById)
.POST("/api/admin/roles", roleHandler::createRole)
.PUT("/api/admin/roles/{id}", roleHandler::updateRole)
.DELETE("/api/admin/roles/{id}", roleHandler::deleteRole)
.POST("/api/admin/roles/{id}/restore", roleHandler::restoreRole)
.GET("/api/admin/roles/{id}/permissions", permissionHandler::getPermissionsByRoleId)
.POST("/api/admin/roles/{id}/permissions", permissionHandler::assignPermissionsToRole)
// ========== 配置路由 ==========
.GET("/api/config", configHandler::getAllConfigs)
.GET("/api/config/{id}", configHandler::getConfigById)
.GET("/api/config/key/{configKey}", configHandler::getConfigByKey)
.POST("/api/config", configHandler::createConfig)
.PUT("/api/config/{id}", configHandler::updateConfig)
.DELETE("/api/config/{id}", configHandler::deleteConfig)
// ========== 配置路由(管理端) ==========
.GET("/api/admin/config", configHandler::getAllConfigs)
.GET("/api/admin/config/{id}", configHandler::getConfigById)
.GET("/api/admin/config/key/{configKey}", configHandler::getConfigByKey)
.POST("/api/admin/config", configHandler::createConfig)
.PUT("/api/admin/config/{id}", configHandler::updateConfig)
.DELETE("/api/admin/config/{id}", configHandler::deleteConfig)
// ========== 日志路由 ==========
.GET("/api/logs/login", logHandler::getAllLoginLogs)
.GET("/api/logs/login/page", logHandler::getLoginLogsByPage)
.GET("/api/logs/login/count", logHandler::getLoginLogCount)
.GET("/api/logs/login/today/count", logHandler::getTodayLoginCount)
.GET("/api/logs/login/recent", logHandler::getRecentLoginLogs)
.GET("/api/logs/login/{id}", logHandler::getLoginLogById)
.POST("/api/logs/login", logHandler::createLoginLog)
.GET("/api/logs/exception", logHandler::getAllExceptionLogs)
.GET("/api/logs/exception/page", logHandler::getExceptionLogsByPage)
.GET("/api/logs/exception/count", logHandler::getExceptionLogCount)
.GET("/api/logs/exception/{id}", logHandler::getExceptionLogById)
.POST("/api/logs/exception", logHandler::createExceptionLog)
.GET("/api/logs/operation", operationLogHandler::getAllOperationLogs)
.GET("/api/logs/operation/export", operationLogHandler::exportOperationLogs)
.GET("/api/logs/operation/page", operationLogHandler::getOperationLogsByPage)
.GET("/api/logs/operation/count", operationLogHandler::getOperationLogCount)
.GET("/api/logs/operation/{id}", operationLogHandler::getOperationLogById)
.POST("/api/logs/operation", operationLogHandler::createOperationLog)
// ========== 日志路由(管理端) ==========
.GET("/api/admin/logs/login", logHandler::getAllLoginLogs)
.GET("/api/admin/logs/login/page", logHandler::getLoginLogsByPage)
.GET("/api/admin/logs/login/count", logHandler::getLoginLogCount)
.GET("/api/admin/logs/login/today/count", logHandler::getTodayLoginCount)
.GET("/api/admin/logs/login/recent", logHandler::getRecentLoginLogs)
.GET("/api/admin/logs/login/{id}", logHandler::getLoginLogById)
.POST("/api/admin/logs/login", logHandler::createLoginLog)
.GET("/api/admin/logs/exception", logHandler::getAllExceptionLogs)
.GET("/api/admin/logs/exception/page", logHandler::getExceptionLogsByPage)
.GET("/api/admin/logs/exception/count", logHandler::getExceptionLogCount)
.GET("/api/admin/logs/exception/{id}", logHandler::getExceptionLogById)
.POST("/api/admin/logs/exception", logHandler::createExceptionLog)
.GET("/api/admin/logs/operation", operationLogHandler::getAllOperationLogs)
.GET("/api/admin/logs/operation/export", operationLogHandler::exportOperationLogs)
.GET("/api/admin/logs/operation/page", operationLogHandler::getOperationLogsByPage)
.GET("/api/admin/logs/operation/count", operationLogHandler::getOperationLogCount)
.GET("/api/admin/logs/operation/{id}", operationLogHandler::getOperationLogById)
.POST("/api/admin/logs/operation", operationLogHandler::createOperationLog)
// ========== 认证路由 ==========
.POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout)
// ========== 认证路由(管理端) ==========
.POST("/api/admin/auth/login", authHandler::login)
.POST("/api/admin/auth/register", authHandler::register)
.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/dict/types/{id}", dictHandler::getDictTypeById)
.GET("/api/dict/types/type/{dictType}", dictHandler::getDictTypeByType)
.POST("/api/dict/types", dictHandler::createDictType)
.PUT("/api/dict/types/{id}", dictHandler::updateDictType)
.DELETE("/api/dict/types/{id}", dictHandler::deleteDictType)
.GET("/api/dict/data", dictHandler::getAllDictData)
.GET("/api/dict/data/type/{dictType}", dictHandler::getDictDataByType)
.GET("/api/dict/data/{id}", dictHandler::getDictDataById)
.POST("/api/dict/data", dictHandler::createDictData)
.PUT("/api/dict/data/{id}", dictHandler::updateDictData)
.DELETE("/api/dict/data/{id}", dictHandler::deleteDictData)
// ========== 数据字典路由(管理端) ==========
.GET("/api/admin/dict/types", dictHandler::getAllDictTypes)
.GET("/api/admin/dict/types/{id}", dictHandler::getDictTypeById)
.GET("/api/admin/dict/types/type/{dictType}", dictHandler::getDictTypeByType)
.POST("/api/admin/dict/types", dictHandler::createDictType)
.PUT("/api/admin/dict/types/{id}", dictHandler::updateDictType)
.DELETE("/api/admin/dict/types/{id}", dictHandler::deleteDictType)
.GET("/api/admin/dict/data", dictHandler::getAllDictData)
.GET("/api/admin/dict/data/type/{dictType}", dictHandler::getDictDataByType)
.GET("/api/admin/dict/data/{id}", dictHandler::getDictDataById)
.POST("/api/admin/dict/data", dictHandler::createDictData)
.PUT("/api/admin/dict/data/{id}", dictHandler::updateDictData)
.DELETE("/api/admin/dict/data/{id}", dictHandler::deleteDictData)
// ========== 公告路由 ==========
.GET("/api/notices", noticeHandler::getAllNotices)
.GET("/api/notices/{id}", noticeHandler::getNoticeById)
.GET("/api/notices/status/{status}", noticeHandler::getNoticesByStatus)
.POST("/api/notices", noticeHandler::createNotice)
.PUT("/api/notices/{id}", noticeHandler::updateNotice)
.DELETE("/api/notices/{id}", noticeHandler::deleteNotice)
// ========== 公告路由(管理端) ==========
.GET("/api/admin/notices", noticeHandler::getAllNotices)
.GET("/api/admin/notices/{id}", noticeHandler::getNoticeById)
.GET("/api/admin/notices/status/{status}", noticeHandler::getNoticesByStatus)
.POST("/api/admin/notices", noticeHandler::createNotice)
.PUT("/api/admin/notices/{id}", noticeHandler::updateNotice)
.DELETE("/api/admin/notices/{id}", noticeHandler::deleteNotice)
// ========== 消息路由 ==========
.GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser)
.GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount)
.GET("/api/messages/user/{userId}/unread/list", messageHandler::getUnreadList)
.POST("/api/messages", messageHandler::createMessage)
.PUT("/api/messages/{id}/read", messageHandler::markAsRead)
.DELETE("/api/messages/{id}", messageHandler::deleteMessage)
// ========== 消息路由(管理端) ==========
.GET("/api/admin/messages/user/{userId}", messageHandler::getMessagesByUser)
.GET("/api/admin/messages/user/{userId}/unread", messageHandler::getUnreadCount)
.GET("/api/admin/messages/user/{userId}/unread/list", messageHandler::getUnreadList)
.POST("/api/admin/messages", messageHandler::createMessage)
.PUT("/api/admin/messages/{id}/read", messageHandler::markAsRead)
.DELETE("/api/admin/messages/{id}", messageHandler::deleteMessage)
// ========== 文件路由 ==========
.GET("/api/files", fileHandler::getAllFiles)
.GET("/api/files/{id}", fileHandler::getFileById)
.POST("/api/files/upload", fileHandler::uploadFile)
.GET("/api/files/{id}/download", fileHandler::downloadFile)
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName)
.GET("/api/files/{id}/preview", fileHandler::previewFile)
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile)
// ========== 文件路由(管理端) ==========
.GET("/api/admin/files", fileHandler::getAllFiles)
.GET("/api/admin/files/{id}", fileHandler::getFileById)
.POST("/api/admin/files/upload", fileHandler::uploadFile)
.GET("/api/admin/files/{id}/download", fileHandler::downloadFile)
.GET("/api/admin/files/download/{fileName}", fileHandler::downloadFileByName)
.GET("/api/admin/files/{id}/preview", fileHandler::previewFile)
.GET("/api/admin/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/admin/files/{id}", fileHandler::deleteFile)
// ========== 权限路由 ==========
.GET("/api/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById)
.GET("/api/permissions/code/{code}", permissionHandler::getPermissionByCode)
.GET("/api/permissions/check-code", permissionHandler::checkCodeExists)
.GET("/api/permissions/count", permissionHandler::getPermissionCount)
.POST("/api/permissions", permissionHandler::createPermission)
.PUT("/api/permissions/{id}", permissionHandler::updatePermission)
.DELETE("/api/permissions/{id}", permissionHandler::deletePermission)
// ========== 权限路由(管理端) ==========
.GET("/api/admin/permissions", permissionHandler::getAllPermissions)
.GET("/api/admin/permissions/{id}", permissionHandler::getPermissionById)
.GET("/api/admin/permissions/code/{code}", permissionHandler::getPermissionByCode)
.GET("/api/admin/permissions/check-code", permissionHandler::checkCodeExists)
.GET("/api/admin/permissions/count", permissionHandler::getPermissionCount)
.POST("/api/admin/permissions", permissionHandler::createPermission)
.PUT("/api/admin/permissions/{id}", permissionHandler::updatePermission)
.DELETE("/api/admin/permissions/{id}", permissionHandler::deletePermission)
// ========== 会员模块路由 - 微信认证 ==========
// ========== 会员模块路由 - 微信认证(前台公开) ==========
.POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin)
.GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature)
.POST("/api/member/auth/mp/callback", wechatAuthHandler::mpCallback)
// ========== 会员模块路由 - 会员信息 ==========
// ========== 会员模块路由 - 会员信息(前台) ==========
.GET("/api/member/info", memberHandler::getMemberInfo)
.PUT("/api/member/info", memberHandler::updateMemberInfo)
.POST("/api/member/phone/bind", memberHandler::bindPhone)
@@ -226,47 +226,35 @@ public class SystemRouter {
// ========================================
// ========== 会员卡管理路由 ==============
// ========== 会员卡管理路由(管理端) ==============
// ========================================
// ===== 会员卡类型管理 =====
.GET("/api/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/member-cards", memberCardHandler::createMemberCard)
.GET("/api/admin/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/admin/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/admin/member-cards", memberCardHandler::createMemberCard)
// ===== 会员卡记录管理(核心业务)=====
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
.POST("/api/member-card-records/{recordId}/renew", memberCardRecordHandler::renewCard)
.POST("/api/member-card-records/{recordId}/use", memberCardRecordHandler::useCard)
.POST("/api/member-card-records/{recordId}/refund", memberCardRecordHandler::refundCard)
.GET("/api/member-card-records/my-cards/{memberId}", memberCardRecordHandler::getMyCards)
.GET("/api/member-card-records/{recordId}", memberCardRecordHandler::getMemberCardRecordById)
.POST("/api/member-card-records/process-expired", memberCardRecordHandler::processExpiredCards)
.POST("/api/admin/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
.POST("/api/admin/member-card-records/{recordId}/renew", memberCardRecordHandler::renewCard)
.POST("/api/admin/member-card-records/{recordId}/use", memberCardRecordHandler::useCard)
.POST("/api/admin/member-card-records/{recordId}/refund", memberCardRecordHandler::refundCard)
.GET("/api/admin/member-card-records/my-cards/{memberId}", memberCardRecordHandler::getMyCards)
.GET("/api/admin/member-card-records/{recordId}", memberCardRecordHandler::getMemberCardRecordById)
.POST("/api/admin/member-card-records/process-expired", memberCardRecordHandler::processExpiredCards)
// ===== 会员卡交易流水管理 =====
.POST("/api/member-card-transactions", memberCardTransactionHandler::insertTransaction)
.GET("/api/member-card-transactions", memberCardTransactionHandler::getTransactionsWithConditions)
.GET("/api/member-card-transactions/member/{memberId}", memberCardTransactionHandler::getMemberTransactions)
.GET("/api/member-card-transactions/card/{cardId}", memberCardTransactionHandler::getTransactionsByCardId)
.GET("/api/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId)
.GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange)
.GET("/api/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember)
.POST("/api/admin/member-card-transactions", memberCardTransactionHandler::insertTransaction)
.GET("/api/admin/member-card-transactions", memberCardTransactionHandler::getTransactionsWithConditions)
.GET("/api/admin/member-card-transactions/member/{memberId}", memberCardTransactionHandler::getMemberTransactions)
.GET("/api/admin/member-card-transactions/card/{cardId}", memberCardTransactionHandler::getTransactionsByCardId)
.GET("/api/admin/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId)
.GET("/api/admin/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange)
.GET("/api/admin/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember)
// ========= 签到模块路由 ==========
// ===== 签到核心功能 =====
.POST("/api/checkIn", checkInHandler::checkIn)
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 =====
.GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
// ===== 签到统计 =====
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 签到数据导出 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
// ========= 签到路由(前台会员) ==========
.POST("/api/member/checkIn", checkInHandler::checkIn)
.GET("/api/member/checkIn/qrcode", checkInHandler::getQRCode)
.build();
}
}
@@ -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,74 +0,0 @@
-- ============================================
-- 会员到店签到记录表
-- 版本: V6
-- 描述: 创建sign_in_record表,用于记录会员签到信息
-- ============================================
-- 创建签到记录表
CREATE TABLE IF NOT EXISTS sign_in_record (
id BIGSERIAL PRIMARY KEY, -- 自增主键
member_id BIGINT NOT NULL, -- 会员ID,关联member表
member_card_id BIGINT, -- 签到时使用的会员卡ID
sign_in_time TIMESTAMP NOT NULL, -- 签到入场时间
sign_in_type VARCHAR(20) NOT NULL, -- 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
sign_in_status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', -- 签到状态:SUCCESS-成功,FAILED-失败
verification_details TEXT, -- JSON格式,存储会员卡验证时的快照数据
fail_reason VARCHAR(500), -- 失败时的具体原因文案
operator_id BIGINT, -- 操作人ID(前台人员),自助签到时为NULL
operator_name VARCHAR(100), -- 操作人姓名冗余
device_info VARCHAR(200), -- 签到设备标识或型号
ip_address VARCHAR(50), -- 客户端IP地址
source VARCHAR(20) NOT NULL, -- 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
is_delete BOOLEAN DEFAULT FALSE, -- 软删除标识:false-未删除,true-已删除
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- 记录更新时间
);
-- 创建索引
-- 会员ID索引(加速按会员查询签到记录)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_id ON sign_in_record(member_id);
-- 签到时间索引(加速按时间范围查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_time ON sign_in_record(sign_in_time);
-- 签到状态索引(加速按状态筛选)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_status ON sign_in_record(sign_in_status);
-- 会员卡ID索引(加速按会员卡查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_card_id ON sign_in_record(member_card_id);
-- 操作人ID索引(加速按操作人查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_operator_id ON sign_in_record(operator_id);
-- 签到来源索引(加速按来源统计)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_source ON sign_in_record(source);
-- 软删除索引(加速查询未删除的记录)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_is_delete ON sign_in_record(is_delete);
-- 复合索引:会员ID + 签到时间(加速会员签到历史查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_time ON sign_in_record(member_id, sign_in_time);
-- 复合索引:签到状态 + 签到时间(加速统计数据查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_status_time ON sign_in_record(sign_in_status, sign_in_time);
-- 添加表注释
COMMENT ON TABLE sign_in_record IS '会员到店签到记录表';
-- 添加字段注释
COMMENT ON COLUMN sign_in_record.id IS '自增主键';
COMMENT ON COLUMN sign_in_record.member_id IS '会员ID,关联member表';
COMMENT ON COLUMN sign_in_record.member_card_id IS '签到时使用的会员卡ID';
COMMENT ON COLUMN sign_in_record.sign_in_time IS '签到入场时间';
COMMENT ON COLUMN sign_in_record.sign_in_type IS '签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别';
COMMENT ON COLUMN sign_in_record.sign_in_status IS '签到状态:SUCCESS-成功,FAILED-失败';
COMMENT ON COLUMN sign_in_record.verification_details IS 'JSON格式,存储会员卡验证时的快照数据';
COMMENT ON COLUMN sign_in_record.fail_reason IS '失败时的具体原因文案';
COMMENT ON COLUMN sign_in_record.operator_id IS '操作人ID(前台人员),自助签到时为NULL';
COMMENT ON COLUMN sign_in_record.operator_name IS '操作人姓名冗余';
COMMENT ON COLUMN sign_in_record.device_info IS '签到设备标识或型号';
COMMENT ON COLUMN sign_in_record.ip_address IS '客户端IP地址';
COMMENT ON COLUMN sign_in_record.source IS '签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端';
COMMENT ON COLUMN sign_in_record.is_delete IS '软删除标识:false-未删除,true-已删除';
COMMENT ON COLUMN sign_in_record.created_at IS '记录创建时间';
COMMENT ON COLUMN sign_in_record.updated_at IS '记录更新时间';
@@ -42,6 +42,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
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);
Long userId = jwtUtil.getUserIdFromToken(token);
@@ -49,19 +56,34 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
.header("X-User-Id", String.valueOf(userId))
.header("X-Member-Id", String.valueOf(userId))
.header("X-Username", username)
.header("X-User-Type", userType != null ? userType : "UNKNOWN")
.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) {
return path.startsWith("/api/auth/") ||
return path.startsWith("/api/admin/auth/") ||
path.equals("/actuator/health") ||
path.equals("/api/member/auth/miniapp/login") ||
path.equals("/api/member/auth/mp/callback") ||
path.equals("/api/auth/login") ||
path.startsWith("/api/checkIn/") ||
path.startsWith("/api/member/auth/") ||
path.startsWith("/actuator/info");
}
@@ -28,6 +28,10 @@ public class JwtUtil {
}
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 expiryDate = new Date(now.getTime() + expiration);
@@ -35,13 +39,14 @@ public class JwtUtil {
String token = Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("userType", userType)
.claim("keyVersion", jwtKeyService.getCurrentKeyVersion())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.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;
} catch (Exception e) {
@@ -74,6 +79,11 @@ public class JwtUtil {
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) {
try {
parseToken(token);
@@ -64,7 +64,7 @@ signature:
max-age-minutes: ${SIGNATURE_MAX_AGE_MINUTES:5}
nonce-cache-size: ${SIGNATURE_NONCE_CACHE_SIZE:10000}
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:
enabled: ${RESILIENCE_ENABLED:true}
@@ -39,7 +39,7 @@ class GatewayJwtAuthenticationFilterTest {
@Test
void testPublicPath_AllowAccess() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/auth/login").build();
MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/auth/login").build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -56,7 +56,7 @@ class GatewayJwtAuthenticationFilterTest {
@Test
void testPublicPath_Register() {
MockServerHttpRequest request = MockServerHttpRequest.post("/api/auth/register").build();
MockServerHttpRequest request = MockServerHttpRequest.post("/api/admin/auth/register").build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -105,6 +105,40 @@ class GatewayJwtAuthenticationFilterTest {
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
void testProtectedPath_NoAuthHeader() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/users").build();
@@ -152,6 +186,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
@@ -163,6 +198,7 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).isTokenExpired(validToken);
verify(jwtUtil).getUsernameFromToken(validToken);
verify(jwtUtil).getUserIdFromToken(validToken);
verify(jwtUtil).getUserTypeFromToken(validToken);
verify(chain).filter(any(ServerWebExchange.class));
}
@@ -224,6 +260,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
@@ -235,6 +272,7 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).isTokenExpired(validToken);
verify(jwtUtil).getUsernameFromToken(validToken);
verify(jwtUtil).getUserIdFromToken(validToken);
verify(jwtUtil).getUserTypeFromToken(validToken);
verify(chain).filter(any(ServerWebExchange.class));
}
@@ -251,6 +289,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
@@ -263,11 +302,12 @@ class GatewayJwtAuthenticationFilterTest {
ServerHttpRequest modifiedRequest = exchangeCaptor.getValue().getRequest();
assert modifiedRequest.getHeaders().getFirst("X-User-Id").equals("1");
assert modifiedRequest.getHeaders().getFirst("X-Username").equals("testuser");
assert modifiedRequest.getHeaders().getFirst("X-User-Type").equals("ADMIN");
}
@Test
void testMixedPath_AuthPath() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/auth/logout").build();
void testMixedPath_AdminAuthPath() {
MockServerHttpRequest request = MockServerHttpRequest.get("/api/admin/auth/logout").build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
@@ -295,6 +335,7 @@ class GatewayJwtAuthenticationFilterTest {
when(jwtUtil.isTokenExpired(validToken)).thenReturn(false);
when(jwtUtil.getUsernameFromToken(validToken)).thenReturn("testuser");
when(jwtUtil.getUserIdFromToken(validToken)).thenReturn(1L);
when(jwtUtil.getUserTypeFromToken(validToken)).thenReturn("ADMIN");
Mono<Void> result = filter.apply(new JwtAuthenticationFilter.Config())
.filter(exchange, chain);
@@ -308,4 +349,123 @@ class GatewayJwtAuthenticationFilterTest {
verify(jwtUtil).getUserIdFromToken(validToken);
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,10 +47,10 @@ public class SecurityConfig {
.addFilterBefore(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterAfter(operationLogWebFilter, SecurityWebFiltersOrder.AUTHORIZATION)
.authorizeExchange(spec -> {
spec.pathMatchers("/api/auth/**").permitAll()
spec.pathMatchers("/api/admin/auth/**").permitAll()
.pathMatchers("/api/member/auth/**").permitAll()
.pathMatchers("/api/public/**").permitAll()
.pathMatchers("/ws/**").permitAll()
.pathMatchers("/**").permitAll()
.pathMatchers("/actuator/**").permitAll();
if (isDevOrTest) {
@@ -60,7 +60,7 @@ public class SecurityConfig {
.pathMatchers("/v3/api-docs/**").permitAll()
.pathMatchers("/swagger-resources/**").permitAll()
.pathMatchers("/webjars/**").permitAll()
.pathMatchers("/api/diagnostic/**").permitAll();
.pathMatchers("/api/admin/diagnostic/**").permitAll();
logger.info("SecurityConfig: Swagger路径和诊断端点已放行");
}
@@ -20,6 +20,9 @@ public class AuthResponse {
@Schema(description = "用户名", example = "admin")
private String username;
@Schema(description = "用户类型", example = "ADMIN")
private String userType;
public AuthResponse() {
}
@@ -27,6 +30,14 @@ public class AuthResponse {
this.token = token;
this.userId = userId;
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() {
@@ -52,4 +63,12 @@ public class AuthResponse {
public void setUsername(String 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(
user.getUsername(),
user.getId(),
roleKeys);
logger.info("用户登录成功: username={}, userId={}, roles={}",
roleKeys,
"ADMIN");
logger.info("用户登录成功: username={}, userId={}, roles={}, userType=ADMIN",
user.getUsername(),
user.getId(),
roleKeys);
@@ -146,7 +147,8 @@ public class SysAuthHandler {
AuthResponse response = new AuthResponse(
token,
user.getId(),
user.getUsername());
user.getUsername(),
"ADMIN");
return ServerResponse.ok()
.bodyValue(response);
});
@@ -37,6 +37,7 @@ public class JwtAuthenticationFilter implements WebFilter {
String username = jwtTokenProvider.getUsernameFromToken(token);
jwtTokenProvider.getUserIdFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
String userType = jwtTokenProvider.getUserTypeFromToken(token);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
@@ -53,6 +54,9 @@ public class JwtAuthenticationFilter implements WebFilter {
authorities
);
// 将 userType 存入 authentication details,供后续 AuthUtil 使用
authentication.setDetails(userType);
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
}
@@ -32,24 +32,19 @@ public class JwtTokenProvider {
}
public String generateToken(String username, Long userId) {
Map<String, Object> claims = new HashMap<>();
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();
return generateToken(username, userId, java.util.Collections.emptyList(), "ADMIN");
}
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<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("roles", roles);
claims.put("userType", userType);
return Jwts.builder()
.setClaims(claims)
@@ -85,6 +80,10 @@ public class JwtTokenProvider {
return java.util.Collections.emptyList();
}
public String getUserTypeFromToken(String token) {
return getClaimsFromToken(token).get("userType", String.class);
}
public boolean validateToken(String token) {
try {
getClaimsFromToken(token);
@@ -29,4 +29,38 @@ public class AuthUtil {
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
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来验证密码
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();
@@ -103,7 +103,7 @@ class SysAuthHandlerTest {
.verifyComplete();
verify(userService).findByUsername("testuser");
verify(jwtTokenProvider).generateToken(eq("testuser"), eq(1L), anyList());
verify(jwtTokenProvider).generateToken(eq("testuser"), eq(1L), anyList(), eq("ADMIN"));
}
@Test
@@ -108,4 +108,52 @@ class JwtTokenProviderTest {
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");
}
}