feat(logs): 添加操作日志功能支持推送和授权模块

- 在JwtAuthFilter中设置认证详情到claims
- 为license-service添加LicenseOperationLog相关实体、仓库和服务
- 为push-service添加PushOperationLog相关实体、仓库和服务
- 在LicenseAdminController中注入并使用操作日志记录授权变更
- 在PushManagementController中注入并使用操作日志记录推送操作
- 更新OperationLogService以支持从JWT claims获取用户信息
- 扩展OperationLogService支持推送和授权操作日志查询
- 在前端OperationLogView中添加推送和授权日志选项卡
- 添加LicenseOperationLog和PushOperationLog接口定义
- 实现推送和授权日志的数据加载和分页功能
- 添加操作类型和资源类型的标签映射支持
这个提交包含在:
XuqmGroup 2026-05-27 13:36:16 +08:00
父节点 f9ad40cb98
当前提交 73dd4814f2
共有 13 个文件被更改,包括 388 次插入3 次删除

查看文件

@ -37,6 +37,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
: List.of();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(subject, null, authorities);
auth.setDetails(claims);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}

查看文件

@ -5,6 +5,7 @@ import com.xuqm.license.entity.AppLicenseEntity;
import com.xuqm.license.entity.DeviceEntity;
import com.xuqm.license.service.AppLicenseService;
import com.xuqm.license.service.DeviceService;
import com.xuqm.license.service.LicenseOperationLogService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -17,10 +18,13 @@ public class LicenseAdminController {
private final AppLicenseService appLicenseService;
private final DeviceService deviceService;
private final LicenseOperationLogService opLogService;
public LicenseAdminController(AppLicenseService appLicenseService, DeviceService deviceService) {
public LicenseAdminController(AppLicenseService appLicenseService, DeviceService deviceService,
LicenseOperationLogService opLogService) {
this.appLicenseService = appLicenseService;
this.deviceService = deviceService;
this.opLogService = opLogService;
}
@GetMapping("/apps/{appKey}")
@ -52,18 +56,21 @@ public class LicenseAdminController {
}
AppLicenseEntity updated = appLicenseService.update(
appKey, null, req.maxDevices(), newExpiresAt, clearExpiresAt, req.isActive(), req.remark(), null, null, null);
opLogService.record(appKey, "UPDATE_LICENSE", "LICENSE", appKey, "更新 " + appKey + " 的授权配置");
return ResponseEntity.ok(ApiResponse.success(updated));
}
@DeleteMapping("/devices/{id}")
public ResponseEntity<ApiResponse<Void>> revokeDevice(@PathVariable String id) {
deviceService.revoke(id);
opLogService.record(null, "REVOKE_DEVICE", "DEVICE", id, "吊销设备 " + id + " 的授权");
return ResponseEntity.ok(ApiResponse.ok());
}
@PutMapping("/devices/{id}/reactivate")
public ResponseEntity<ApiResponse<Void>> reactivateDevice(@PathVariable String id) {
deviceService.reactivate(id);
opLogService.record(null, "REACTIVATE_DEVICE", "DEVICE", id, "重新激活设备 " + id);
return ResponseEntity.ok(ApiResponse.ok());
}

查看文件

@ -0,0 +1,37 @@
package com.xuqm.license.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.license.entity.LicenseOperationLogEntity;
import com.xuqm.license.service.LicenseOperationLogService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/license/admin/operation-logs")
public class LicenseOperationLogController {
private final LicenseOperationLogService logService;
public LicenseOperationLogController(LicenseOperationLogService logService) {
this.logService = logService;
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> list(
@RequestParam String appKey,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<LicenseOperationLogEntity> result = logService.list(appKey, page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
}

查看文件

@ -0,0 +1,69 @@
package com.xuqm.license.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "license_operation_log", indexes = {
@Index(name = "idx_license_op_log_app_time", columnList = "appKey,createdAt")
})
public class LicenseOperationLogEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 128)
private String operator;
@Column(nullable = false, length = 64)
private String action;
@Column(nullable = false, length = 64)
private String resourceType;
@Column(length = 128)
private String resourceId;
@Column(length = 255)
private String summary;
@Column(columnDefinition = "TEXT")
private String detailJson;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getOperator() { return operator; }
public void setOperator(String operator) { this.operator = operator; }
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public String getResourceId() { return resourceId; }
public void setResourceId(String resourceId) { this.resourceId = resourceId; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getDetailJson() { return detailJson; }
public void setDetailJson(String detailJson) { this.detailJson = detailJson; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.license.repository;
import com.xuqm.license.entity.LicenseOperationLogEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LicenseOperationLogRepository extends JpaRepository<LicenseOperationLogEntity, String> {
Page<LicenseOperationLogEntity> findByAppKeyOrderByCreatedAtDesc(String appKey, Pageable pageable);
}

查看文件

@ -0,0 +1,56 @@
package com.xuqm.license.service;
import com.xuqm.license.entity.LicenseOperationLogEntity;
import com.xuqm.license.repository.LicenseOperationLogRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
public class LicenseOperationLogService {
private final LicenseOperationLogRepository repository;
public LicenseOperationLogService(LicenseOperationLogRepository repository) {
this.repository = repository;
}
public void record(String appKey, String action, String resourceType,
String resourceId, String summary) {
LicenseOperationLogEntity entity = new LicenseOperationLogEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
entity.setOperator(currentOperator());
entity.setAction(action);
entity.setResourceType(resourceType);
entity.setResourceId(resourceId);
entity.setSummary(summary);
entity.setCreatedAt(LocalDateTime.now());
repository.save(entity);
}
public Page<LicenseOperationLogEntity> list(String appKey, int page, int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 200);
return repository.findByAppKeyOrderByCreatedAtDesc(appKey, PageRequest.of(safePage, safeSize));
}
private String currentOperator() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null || auth.getName().isBlank()) {
return "system";
}
if (auth.getDetails() instanceof io.jsonwebtoken.Claims claims) {
String nickname = claims.get("nickname", String.class);
if (nickname != null && !nickname.isBlank()) return nickname;
String username = claims.get("username", String.class);
if (username != null && !username.isBlank()) return username;
}
return auth.getName();
}
}

查看文件

@ -5,6 +5,8 @@ import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.entity.PushUserEntity;
import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushDiagnosticsService;
import com.xuqm.push.service.PushOperationLogService;
import java.util.LinkedHashMap;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@ -28,11 +30,14 @@ public class PushManagementController {
private final PushDiagnosticsService diagnosticsService;
private final PushAccountService accountService;
private final PushOperationLogService opLogService;
public PushManagementController(PushDiagnosticsService diagnosticsService,
PushAccountService accountService) {
PushAccountService accountService,
PushOperationLogService opLogService) {
this.diagnosticsService = diagnosticsService;
this.accountService = accountService;
this.opLogService = opLogService;
}
// ---- user account management ----
@ -68,6 +73,8 @@ public class PushManagementController {
}
PushUserEntity updated = accountService.updateAccount(request.appKey(), userId,
request.nickname(), request.avatar(), gender);
opLogService.record(request.appKey(), "UPDATE_USER", "ACCOUNT", userId,
"编辑用户 " + userId + " 的信息", null);
return ResponseEntity.ok(ApiResponse.success(updated));
}
@ -81,7 +88,11 @@ public class PushManagementController {
} catch (Exception e) {
return ResponseEntity.badRequest().body(ApiResponse.error(400, "无效的状态值,可选ACTIVE, BANNED"));
}
return ResponseEntity.ok(ApiResponse.success(accountService.setUserStatus(request.appKey(), userId, status)));
PushUserEntity result = accountService.setUserStatus(request.appKey(), userId, status);
String statusLabel = status == PushUserEntity.Status.BANNED ? "禁用" : "启用";
opLogService.record(request.appKey(), "UPDATE_USER_STATUS", "ACCOUNT", userId,
statusLabel + "用户 " + userId, null);
return ResponseEntity.ok(ApiResponse.success(result));
}
@DeleteMapping("/users/{userId}")
@ -89,6 +100,8 @@ public class PushManagementController {
@PathVariable String userId,
@RequestParam String appKey) {
accountService.deleteAccount(appKey, userId);
opLogService.record(appKey, "DELETE_USER", "ACCOUNT", userId,
"删除用户 " + userId, null);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -104,6 +117,8 @@ public class PushManagementController {
}
PushUserEntity user = accountService.importAccount(request.appKey(), request.userId(),
request.nickname(), request.avatar(), gender, status);
opLogService.record(request.appKey(), "IMPORT_USER", "ACCOUNT", request.userId(),
"导入用户 " + request.userId(), null);
return ResponseEntity.ok(ApiResponse.success(user));
}
@ -139,6 +154,8 @@ public class PushManagementController {
request.title(),
request.body(),
request.payload());
opLogService.record(request.appKey(), "TEST_PUSH", "PUSH", request.userId(),
"" + request.userId() + " 发送测试推送", null);
return ResponseEntity.ok(ApiResponse.success(result));
}

查看文件

@ -0,0 +1,39 @@
package com.xuqm.push.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.PushOperationLogEntity;
import com.xuqm.push.service.PushOperationLogService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/push/admin/operation-logs")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public class PushOperationLogController {
private final PushOperationLogService logService;
public PushOperationLogController(PushOperationLogService logService) {
this.logService = logService;
}
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> list(
@RequestParam String appKey,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<PushOperationLogEntity> result = logService.list(appKey, page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
}

查看文件

@ -0,0 +1,69 @@
package com.xuqm.push.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "push_operation_log", indexes = {
@Index(name = "idx_push_op_log_app_time", columnList = "appKey,createdAt")
})
public class PushOperationLogEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 128)
private String operator;
@Column(nullable = false, length = 64)
private String action;
@Column(nullable = false, length = 64)
private String resourceType;
@Column(length = 128)
private String resourceId;
@Column(length = 255)
private String summary;
@Column(columnDefinition = "TEXT")
private String detail;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getOperator() { return operator; }
public void setOperator(String operator) { this.operator = operator; }
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public String getResourceId() { return resourceId; }
public void setResourceId(String resourceId) { this.resourceId = resourceId; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getDetail() { return detail; }
public void setDetail(String detail) { this.detail = detail; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.push.repository;
import com.xuqm.push.entity.PushOperationLogEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PushOperationLogRepository extends JpaRepository<PushOperationLogEntity, String> {
Page<PushOperationLogEntity> findByAppKeyOrderByCreatedAtDesc(String appKey, Pageable pageable);
}

查看文件

@ -0,0 +1,58 @@
package com.xuqm.push.service;
import com.xuqm.push.entity.PushOperationLogEntity;
import com.xuqm.push.repository.PushOperationLogRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Service
public class PushOperationLogService {
private final PushOperationLogRepository repository;
public PushOperationLogService(PushOperationLogRepository repository) {
this.repository = repository;
}
public void record(String appKey, String action, String resourceType,
String resourceId, String summary, Map<String, Object> detail) {
PushOperationLogEntity entity = new PushOperationLogEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
entity.setOperator(currentOperator());
entity.setAction(action);
entity.setResourceType(resourceType);
entity.setResourceId(resourceId);
entity.setSummary(summary);
entity.setDetail(detail == null ? null : detail.toString());
entity.setCreatedAt(LocalDateTime.now());
repository.save(entity);
}
public Page<PushOperationLogEntity> list(String appKey, int page, int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 200);
return repository.findByAppKeyOrderByCreatedAtDesc(appKey, PageRequest.of(safePage, safeSize));
}
private String currentOperator() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null || auth.getName().isBlank()) {
return "system";
}
if (auth.getDetails() instanceof io.jsonwebtoken.Claims claims) {
String nickname = claims.get("nickname", String.class);
if (nickname != null && !nickname.isBlank()) return nickname;
String username = claims.get("username", String.class);
if (username != null && !username.isBlank()) return username;
}
return auth.getName();
}
}

查看文件

@ -77,6 +77,12 @@ public class OperationLogService {
if (auth == null || auth.getName() == null || auth.getName().isBlank()) {
return "system";
}
if (auth.getDetails() instanceof io.jsonwebtoken.Claims claims) {
String nickname = claims.get("nickname", String.class);
if (nickname != null && !nickname.isBlank()) return nickname;
String username = claims.get("username", String.class);
if (username != null && !username.isBlank()) return username;
}
return auth.getName();
}

查看文件

@ -56,6 +56,12 @@ public class UpdateOperationLogService {
if (auth == null || auth.getName() == null || auth.getName().isBlank()) {
return "system";
}
if (auth.getDetails() instanceof io.jsonwebtoken.Claims claims) {
String nickname = claims.get("nickname", String.class);
if (nickname != null && !nickname.isBlank()) return nickname;
String username = claims.get("username", String.class);
if (username != null && !username.isBlank()) return username;
}
return auth.getName();
}