feat(log): 优化操作日志记录和展示功能

- 在OperationLogEntity实体中新增summary和ipAddress字段存储摘要和IP信息
- 修改operationLogService.record方法支持传入操作摘要信息
- 实现客户端IP地址解析功能,支持X-Forwarded-For和X-Real-IP头
- 更新系统更新服务中的数据库表结构迁移逻辑,增加NOT NULL列处理
- 优化前端操作日志页面展示,添加标签分类和详情弹窗功能
- 在系统更新流式响应中增加网络连接异常处理机制
- 添加Nginx代理配置中的缓冲区设置以支持实时日志流式传输
这个提交包含在:
XuqmGroup 2026-05-27 12:27:42 +08:00
父节点 50da70d580
当前提交 f9ad40cb98
共有 10 个文件被更改,包括 155 次插入58 次删除

查看文件

@ -106,9 +106,10 @@ public class AppController {
TenantEntity tenant = tenantRepository.findById(tenantId) TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found")); .orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.sendVerificationCode(tenant.getEmail(), purpose); emailService.sendVerificationCode(tenant.getEmail(), purpose);
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REQUEST_SECRET_VERIFY", Map.of( String purposeLabel = "REVEAL_SECRET".equals(purpose) ? "查看密钥" : "重置密钥";
"purpose", purpose operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REQUEST_SECRET_VERIFY",
)); "申请" + purposeLabel + "验证码,应用 " + appKey,
Map.of("purpose", purpose));
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@ -122,9 +123,9 @@ public class AppController {
TenantEntity tenant = tenantRepository.findById(tenantId) TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found")); .orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET"); emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET");
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REVEAL_APP_SECRET", Map.of( operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REVEAL_APP_SECRET",
"appKey", app.getAppKey() "查看应用「" + app.getName() + "」的 AppSecret",
)); Map.of("appKey", app.getAppKey()));
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret()))); return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret())));
} }

查看文件

@ -65,10 +65,9 @@ public class FeatureServiceController {
throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请"); throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请");
} }
FeatureServiceEntity saved = featureServiceManager.disable(appKey, platform, serviceType); FeatureServiceEntity saved = featureServiceManager.disable(appKey, platform, serviceType);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE", java.util.Map.of( operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE",
"platform", platform.name(), "停用 " + platform.name() + " 平台的 " + serviceType.name() + " 服务",
"serviceType", serviceType.name() java.util.Map.of("platform", platform.name(), "serviceType", serviceType.name()));
));
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -118,10 +117,9 @@ public class FeatureServiceController {
}; };
FeatureServiceEntity saved = featureServiceManager.updateConfig( FeatureServiceEntity saved = featureServiceManager.updateConfig(
appKey, platform, serviceType, config); appKey, platform, serviceType, config);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG", java.util.Map.of( operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG",
"platform", platform.name(), "更新 " + platform.name() + " 平台 " + serviceType.name() + " 服务配置",
"serviceType", serviceType.name() java.util.Map.of("platform", platform.name(), "serviceType", serviceType.name()));
));
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -141,7 +139,9 @@ public class FeatureServiceController {
if (applyReason != null && !applyReason.isBlank()) { if (applyReason != null && !applyReason.isBlank()) {
detail.put("applyReason", applyReason); detail.put("applyReason", applyReason);
} }
operationLogService.record(tenantId, "SERVICE", "SERVICE_ACTIVATION", saved.getId(), "REQUEST_SERVICE_ACTIVATION", detail); operationLogService.record(tenantId, "SERVICE", "SERVICE_ACTIVATION", saved.getId(), "REQUEST_SERVICE_ACTIVATION",
"申请开通 " + platform.name() + " 平台 " + serviceType.name() + " 服务",
detail);
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -159,10 +159,9 @@ public class FeatureServiceController {
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
appService.getByAppKey(appKey, tenantId); appService.getByAppKey(appKey, tenantId);
FeatureServiceEntity updated = featureServiceManager.regenerateSecretKey(id); FeatureServiceEntity updated = featureServiceManager.regenerateSecretKey(id);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY", java.util.Map.of( operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY",
"platform", updated.getPlatform().name(), "重新生成 " + updated.getPlatform().name() + " 平台 " + updated.getServiceType().name() + " 服务密钥",
"serviceType", updated.getServiceType().name() java.util.Map.of("platform", updated.getPlatform().name(), "serviceType", updated.getServiceType().name()));
));
return ResponseEntity.ok(ApiResponse.success(updated)); return ResponseEntity.ok(ApiResponse.success(updated));
} }

查看文件

@ -46,9 +46,9 @@ public class SubAccountController {
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(email, "email"); requireNonBlank(email, "email");
emailService.sendVerificationCode(email, "SUB_ACCOUNT"); emailService.sendVerificationCode(email, "SUB_ACCOUNT");
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE", Map.of( operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE",
"email", email "发送子账号邮箱验证码到 " + email,
)); Map.of("email", email));
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@ -59,9 +59,9 @@ public class SubAccountController {
requireNonBlank(email, "email"); requireNonBlank(email, "email");
requireNonBlank(code, "code"); requireNonBlank(code, "code");
subAccountService.verifyEmail(tenantId, email, code); subAccountService.verifyEmail(tenantId, email, code);
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of( operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL",
"email", email "验证子账号邮箱 " + email + " 成功",
)); Map.of("email", email));
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }

查看文件

@ -103,6 +103,8 @@ public class SystemUpdateController {
}); });
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN) .contentType(MediaType.TEXT_PLAIN)
.header("X-Accel-Buffering", "no")
.header("Cache-Control", "no-cache, no-store")
.body(body); .body(body);
} }
} }

查看文件

@ -39,6 +39,12 @@ public class OperationLogEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String detailJson; private String detailJson;
@Column(length = 255)
private String summary;
@Column(length = 64)
private String ipAddress;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -66,6 +72,12 @@ public class OperationLogEntity {
public String getDetailJson() { return detailJson; } public String getDetailJson() { return detailJson; }
public void setDetailJson(String detailJson) { this.detailJson = detailJson; } public void setDetailJson(String detailJson) { this.detailJson = detailJson; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getIpAddress() { return ipAddress; }
public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
} }

查看文件

@ -82,11 +82,9 @@ public class AppService {
app.setLicenseFileContent(generateLicenseFileContent(app)); app.setLicenseFileContent(generateLicenseFileContent(app));
AppEntity saved = appRepository.save(app); AppEntity saved = appRepository.save(app);
autoEnableFileService(saved.getAppKey()); autoEnableFileService(saved.getAppKey());
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of( operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP",
"name", saved.getName(), "创建应用「" + saved.getName() + "」(" + saved.getPackageName() + ")",
"packageName", saved.getPackageName(), Map.of("name", saved.getName(), "packageName", saved.getPackageName(), "appKey", saved.getAppKey()));
"appKey", saved.getAppKey()
));
return saved; return saved;
} }
@ -121,21 +119,18 @@ public class AppService {
after.put("packageName", saved.getPackageName()); after.put("packageName", saved.getPackageName());
after.put("description", saved.getDescription()); after.put("description", saved.getDescription());
after.put("iconUrl", saved.getIconUrl()); after.put("iconUrl", saved.getIconUrl());
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "UPDATE_APP", Map.of( operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "UPDATE_APP",
"before", before, "更新应用「" + saved.getName() + "」的信息",
"after", after Map.of("before", before, "after", after));
));
return saved; return saved;
} }
public void delete(String appKey, String tenantId) { public void delete(String appKey, String tenantId) {
AppEntity app = getByAppKey(appKey, tenantId); AppEntity app = getByAppKey(appKey, tenantId);
appRepository.delete(app); appRepository.delete(app);
operationLogService.record(tenantId, "APP", "APP", app.getAppKey(), "DELETE_APP", Map.of( operationLogService.record(tenantId, "APP", "APP", app.getAppKey(), "DELETE_APP",
"name", app.getName(), "删除应用「" + app.getName() + "」(" + app.getPackageName() + ")",
"packageName", app.getPackageName(), Map.of("name", app.getName(), "packageName", app.getPackageName(), "appKey", app.getAppKey()));
"appKey", app.getAppKey()
));
} }
public String resetSecret(String appKey, String tenantId) { public String resetSecret(String appKey, String tenantId) {
@ -143,11 +138,9 @@ public class AppService {
String newSecret = generateSecret(); String newSecret = generateSecret();
app.setAppSecret(newSecret); app.setAppSecret(newSecret);
appRepository.save(app); appRepository.save(app);
operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET", Map.of( operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET",
"name", app.getName(), "重置应用「" + app.getName() + "」的 AppSecret",
"packageName", app.getPackageName(), Map.of("name", app.getName(), "packageName", app.getPackageName(), "appKey", app.getAppKey()));
"appKey", app.getAppKey()
));
return newSecret; return newSecret;
} }

查看文件

@ -44,7 +44,8 @@ public class DashboardService {
result.put("serviceCount", serviceCount); result.put("serviceCount", serviceCount);
result.put("subAccountCount", subAccountCount); result.put("subAccountCount", subAccountCount);
operationLogService.record(tenantId, "CONSOLE", "DASHBOARD", tenantId, "VIEW_DASHBOARD", result); operationLogService.record(tenantId, "CONSOLE", "DASHBOARD", tenantId, "VIEW_DASHBOARD",
"查看控制台概览", result);
return result; return result;
} }
} }

查看文件

@ -3,11 +3,14 @@ package com.xuqm.tenant.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.tenant.entity.OperationLogEntity; import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.repository.OperationLogRepository; import com.xuqm.tenant.repository.OperationLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -31,6 +34,16 @@ public class OperationLogService {
String resourceId, String resourceId,
String action, String action,
Map<String, Object> detail) { Map<String, Object> detail) {
record(tenantId, moduleType, resourceType, resourceId, action, null, detail);
}
public void record(String tenantId,
String moduleType,
String resourceType,
String resourceId,
String action,
String summary,
Map<String, Object> detail) {
OperationLogEntity entity = new OperationLogEntity(); OperationLogEntity entity = new OperationLogEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setTenantId(tenantId); entity.setTenantId(tenantId);
@ -39,6 +52,8 @@ public class OperationLogService {
entity.setResourceId(resourceId); entity.setResourceId(resourceId);
entity.setAction(action); entity.setAction(action);
entity.setOperator(currentOperator()); entity.setOperator(currentOperator());
entity.setSummary(summary);
entity.setIpAddress(resolveClientIp());
entity.setDetailJson(serialize(detail)); entity.setDetailJson(serialize(detail));
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
repository.save(entity); repository.save(entity);
@ -65,6 +80,27 @@ public class OperationLogService {
return auth.getName(); return auth.getName();
} }
private String resolveClientIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest request = attrs.getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && !ip.isBlank()) {
ip = ip.split(",")[0].trim();
}
if (ip == null || ip.isBlank()) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isBlank()) {
ip = request.getRemoteAddr();
}
return ip;
} catch (Exception e) {
return null;
}
}
private String serialize(Map<String, Object> detail) { private String serialize(Map<String, Object> detail) {
try { try {
Map<String, Object> payload = detail == null ? Map.of() : new LinkedHashMap<>(detail); Map<String, Object> payload = detail == null ? Map.of() : new LinkedHashMap<>(detail);

查看文件

@ -64,11 +64,9 @@ public class SubAccountService {
sub.setParentId(parentId); sub.setParentId(parentId);
sub.setCreatedAt(LocalDateTime.now()); sub.setCreatedAt(LocalDateTime.now());
TenantEntity saved = tenantRepository.save(sub); TenantEntity saved = tenantRepository.save(sub);
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", saved.getId(), "CREATE_SUB_ACCOUNT", Map.of( operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", saved.getId(), "CREATE_SUB_ACCOUNT",
"username", saved.getUsername(), "创建子账号「" + saved.getNickname() + "」(" + saved.getUsername() + ")",
"nickname", saved.getNickname(), Map.of("username", saved.getUsername(), "nickname", saved.getNickname(), "email", saved.getEmail()));
"email", saved.getEmail()
));
return saved; return saved;
} }
@ -84,11 +82,9 @@ public class SubAccountService {
} }
sub.setStatus(TenantEntity.Status.DISABLED); sub.setStatus(TenantEntity.Status.DISABLED);
tenantRepository.save(sub); tenantRepository.save(sub);
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", sub.getId(), "DISABLE_SUB_ACCOUNT", Map.of( operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", sub.getId(), "DISABLE_SUB_ACCOUNT",
"username", sub.getUsername(), "禁用子账号「" + sub.getNickname() + "」(" + sub.getUsername() + ")",
"nickname", sub.getNickname(), Map.of("username", sub.getUsername(), "nickname", sub.getNickname(), "email", sub.getEmail()));
"email", sub.getEmail()
));
} }
public String generatePassword() { public String generatePassword() {

查看文件

@ -177,6 +177,10 @@ public class SystemUpdateService {
PRESERVE_TABLES.put("t_risk_config", "id"); PRESERVE_TABLES.put("t_risk_config", "id");
PRESERVE_TABLES.put("app_licenses", "app_key"); PRESERVE_TABLES.put("app_licenses", "app_key");
PRESERVE_TABLES.put("t_sensitive_word", "id"); PRESERVE_TABLES.put("t_sensitive_word", "id");
PRESERVE_TABLES.put("t_operation_log", "id");
PRESERVE_TABLES.put("t_service_activation_request","id");
PRESERVE_TABLES.put("t_email_verification", "id");
PRESERVE_TABLES.put("t_migrate_key", "id");
} }
private void resetDatabaseSchema(Consumer<String> emit) { private void resetDatabaseSchema(Consumer<String> emit) {
@ -297,9 +301,23 @@ public class SystemUpdateService {
log.warn("no common columns between {} and {}", table, tmpTable); log.warn("no common columns between {} and {}", table, tmpTable);
continue; continue;
} }
// 获取目标表列的 NOT NULL DEFAULT 信息
Map<String, Map<String, String>> colMeta = getColumnMeta(stmt, table);
// NOT NULL 列用 COALESCE 兜底避免 INSERT IGNORE 静默丢行
List<String> selectExprs = new java.util.ArrayList<>();
for (String col : columns) {
Map<String, String> meta = colMeta.get(col);
if (meta != null && "NO".equals(meta.get("nullable"))) {
String fallback = guessNotNullDefault(meta);
selectExprs.add("COALESCE(`" + col + "`, " + fallback + ") AS `" + col + "`");
} else {
selectExprs.add("`" + col + "`");
}
}
String colList = columns.stream().map(c -> "`" + c + "`").collect(Collectors.joining(", ")); String colList = columns.stream().map(c -> "`" + c + "`").collect(Collectors.joining(", "));
String sql = "INSERT IGNORE INTO `" + table + "` (" + colList + ") " + String selectList = String.join(", ", selectExprs);
"SELECT " + colList + " FROM `" + tmpTable + "`"; String sql = "INSERT INTO `" + table + "` (" + colList + ") " +
"SELECT " + selectList + " FROM `" + tmpTable + "`";
int rows = stmt.executeUpdate(sql); int rows = stmt.executeUpdate(sql);
log.info("restored {} rows into {}", rows, table); log.info("restored {} rows into {}", rows, table);
@ -331,6 +349,44 @@ public class SystemUpdateService {
return common; return common;
} }
/** 获取目标表各列的 Null、Default 等元信息。 */
private Map<String, Map<String, String>> getColumnMeta(Statement stmt, String table) throws Exception {
Map<String, Map<String, String>> meta = new java.util.LinkedHashMap<>();
try (ResultSet rs = stmt.executeQuery("SHOW COLUMNS FROM `" + table + "`")) {
while (rs.next()) {
Map<String, String> info = new java.util.LinkedHashMap<>();
info.put("type", rs.getString("Type"));
info.put("nullable", rs.getString("Null"));
info.put("default", rs.getString("Default"));
meta.put(rs.getString("Field"), info);
}
}
return meta;
}
/** 为 NOT NULL 且无 DEFAULT 的列根据类型推断一个合理的兜底值。 */
private String guessNotNullDefault(Map<String, String> meta) {
String defaultVal = meta.get("default");
if (defaultVal != null && !"null".equalsIgnoreCase(defaultVal) && !defaultVal.isEmpty()) {
// MySQL SHOW COLUMNS Default 字段已经是不带引号的字面量
// 但字符串类型的值在 JDBC ResultSet 中返回时可能不带引号需要加上
String type = meta.getOrDefault("type", "").toLowerCase();
if (type.startsWith("varchar") || type.startsWith("char") || type.startsWith("text") || type.startsWith("enum")) {
return "'" + defaultVal.replace("'", "\\'") + "'";
}
return defaultVal;
}
String type = meta.getOrDefault("type", "").toLowerCase();
if (type.startsWith("varchar") || type.startsWith("char") || type.startsWith("text")) return "''";
if (type.startsWith("bigint") || type.startsWith("int") || type.startsWith("tinyint") || type.startsWith("smallint")) return "0";
if (type.startsWith("decimal") || type.startsWith("double") || type.startsWith("float")) return "0";
if (type.startsWith("datetime") || type.startsWith("timestamp")) return "CURRENT_TIMESTAMP";
if (type.startsWith("date")) return "CURRENT_DATE";
if (type.startsWith("json")) return "'{}'";
if (type.startsWith("enum")) return "''";
return "''";
}
// Schema 版本化迁移 // Schema 版本化迁移
/** /**
@ -496,6 +552,7 @@ public class SystemUpdateService {
+ " proxy_set_header Host $host;\n" + " proxy_set_header Host $host;\n"
+ " proxy_set_header X-Real-IP $remote_addr;\n" + " proxy_set_header X-Real-IP $remote_addr;\n"
+ " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
+ " proxy_buffering off;\n"
+ " proxy_read_timeout 600s;\n" + " proxy_read_timeout 600s;\n"
+ " proxy_send_timeout 600s;\n" + " proxy_send_timeout 600s;\n"
+ " }\n\n" + " }\n\n"