feat(log): 优化操作日志记录和展示功能
- 在OperationLogEntity实体中新增summary和ipAddress字段存储摘要和IP信息 - 修改operationLogService.record方法支持传入操作摘要信息 - 实现客户端IP地址解析功能,支持X-Forwarded-For和X-Real-IP头 - 更新系统更新服务中的数据库表结构迁移逻辑,增加NOT NULL列处理 - 优化前端操作日志页面展示,添加标签分类和详情弹窗功能 - 在系统更新流式响应中增加网络连接异常处理机制 - 添加Nginx代理配置中的缓冲区设置以支持实时日志流式传输
这个提交包含在:
父节点
50da70d580
当前提交
f9ad40cb98
@ -106,9 +106,10 @@ public class AppController {
|
||||
TenantEntity tenant = tenantRepository.findById(tenantId)
|
||||
.orElseThrow(() -> new RuntimeException("Tenant not found"));
|
||||
emailService.sendVerificationCode(tenant.getEmail(), purpose);
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REQUEST_SECRET_VERIFY", Map.of(
|
||||
"purpose", purpose
|
||||
));
|
||||
String purposeLabel = "REVEAL_SECRET".equals(purpose) ? "查看密钥" : "重置密钥";
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REQUEST_SECRET_VERIFY",
|
||||
"申请" + purposeLabel + "验证码,应用 " + appKey,
|
||||
Map.of("purpose", purpose));
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@ -122,9 +123,9 @@ public class AppController {
|
||||
TenantEntity tenant = tenantRepository.findById(tenantId)
|
||||
.orElseThrow(() -> new RuntimeException("Tenant not found"));
|
||||
emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET");
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REVEAL_APP_SECRET", Map.of(
|
||||
"appKey", app.getAppKey()
|
||||
));
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REVEAL_APP_SECRET",
|
||||
"查看应用「" + app.getName() + "」的 AppSecret",
|
||||
Map.of("appKey", app.getAppKey()));
|
||||
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 申请");
|
||||
}
|
||||
FeatureServiceEntity saved = featureServiceManager.disable(appKey, platform, serviceType);
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE", java.util.Map.of(
|
||||
"platform", platform.name(),
|
||||
"serviceType", serviceType.name()
|
||||
));
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE",
|
||||
"停用 " + platform.name() + " 平台的 " + serviceType.name() + " 服务",
|
||||
java.util.Map.of("platform", platform.name(), "serviceType", serviceType.name()));
|
||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||
}
|
||||
|
||||
@ -118,10 +117,9 @@ public class FeatureServiceController {
|
||||
};
|
||||
FeatureServiceEntity saved = featureServiceManager.updateConfig(
|
||||
appKey, platform, serviceType, config);
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG", java.util.Map.of(
|
||||
"platform", platform.name(),
|
||||
"serviceType", serviceType.name()
|
||||
));
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG",
|
||||
"更新 " + platform.name() + " 平台 " + serviceType.name() + " 服务配置",
|
||||
java.util.Map.of("platform", platform.name(), "serviceType", serviceType.name()));
|
||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||
}
|
||||
|
||||
@ -141,7 +139,9 @@ public class FeatureServiceController {
|
||||
if (applyReason != null && !applyReason.isBlank()) {
|
||||
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));
|
||||
}
|
||||
|
||||
@ -159,10 +159,9 @@ public class FeatureServiceController {
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
appService.getByAppKey(appKey, tenantId);
|
||||
FeatureServiceEntity updated = featureServiceManager.regenerateSecretKey(id);
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY", java.util.Map.of(
|
||||
"platform", updated.getPlatform().name(),
|
||||
"serviceType", updated.getServiceType().name()
|
||||
));
|
||||
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY",
|
||||
"重新生成 " + updated.getPlatform().name() + " 平台 " + updated.getServiceType().name() + " 服务密钥",
|
||||
java.util.Map.of("platform", updated.getPlatform().name(), "serviceType", updated.getServiceType().name()));
|
||||
return ResponseEntity.ok(ApiResponse.success(updated));
|
||||
}
|
||||
|
||||
|
||||
@ -46,9 +46,9 @@ public class SubAccountController {
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
requireNonBlank(email, "email");
|
||||
emailService.sendVerificationCode(email, "SUB_ACCOUNT");
|
||||
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE", Map.of(
|
||||
"email", email
|
||||
));
|
||||
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE",
|
||||
"发送子账号邮箱验证码到 " + email,
|
||||
Map.of("email", email));
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@ -59,9 +59,9 @@ public class SubAccountController {
|
||||
requireNonBlank(email, "email");
|
||||
requireNonBlank(code, "code");
|
||||
subAccountService.verifyEmail(tenantId, email, code);
|
||||
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of(
|
||||
"email", email
|
||||
));
|
||||
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL",
|
||||
"验证子账号邮箱 " + email + " 成功",
|
||||
Map.of("email", email));
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
|
||||
@ -103,6 +103,8 @@ public class SystemUpdateController {
|
||||
});
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.header("X-Accel-Buffering", "no")
|
||||
.header("Cache-Control", "no-cache, no-store")
|
||||
.body(body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,12 @@ public class OperationLogEntity {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String detailJson;
|
||||
|
||||
@Column(length = 255)
|
||||
private String summary;
|
||||
|
||||
@Column(length = 64)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ -66,6 +72,12 @@ public class OperationLogEntity {
|
||||
public String getDetailJson() { return 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 void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@ -82,11 +82,9 @@ public class AppService {
|
||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||
AppEntity saved = appRepository.save(app);
|
||||
autoEnableFileService(saved.getAppKey());
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
||||
"name", saved.getName(),
|
||||
"packageName", saved.getPackageName(),
|
||||
"appKey", saved.getAppKey()
|
||||
));
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP",
|
||||
"创建应用「" + saved.getName() + "」(" + saved.getPackageName() + ")",
|
||||
Map.of("name", saved.getName(), "packageName", saved.getPackageName(), "appKey", saved.getAppKey()));
|
||||
return saved;
|
||||
}
|
||||
|
||||
@ -121,21 +119,18 @@ public class AppService {
|
||||
after.put("packageName", saved.getPackageName());
|
||||
after.put("description", saved.getDescription());
|
||||
after.put("iconUrl", saved.getIconUrl());
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "UPDATE_APP", Map.of(
|
||||
"before", before,
|
||||
"after", after
|
||||
));
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "UPDATE_APP",
|
||||
"更新应用「" + saved.getName() + "」的信息",
|
||||
Map.of("before", before, "after", after));
|
||||
return saved;
|
||||
}
|
||||
|
||||
public void delete(String appKey, String tenantId) {
|
||||
AppEntity app = getByAppKey(appKey, tenantId);
|
||||
appRepository.delete(app);
|
||||
operationLogService.record(tenantId, "APP", "APP", app.getAppKey(), "DELETE_APP", Map.of(
|
||||
"name", app.getName(),
|
||||
"packageName", app.getPackageName(),
|
||||
"appKey", app.getAppKey()
|
||||
));
|
||||
operationLogService.record(tenantId, "APP", "APP", app.getAppKey(), "DELETE_APP",
|
||||
"删除应用「" + app.getName() + "」(" + app.getPackageName() + ")",
|
||||
Map.of("name", app.getName(), "packageName", app.getPackageName(), "appKey", app.getAppKey()));
|
||||
}
|
||||
|
||||
public String resetSecret(String appKey, String tenantId) {
|
||||
@ -143,11 +138,9 @@ public class AppService {
|
||||
String newSecret = generateSecret();
|
||||
app.setAppSecret(newSecret);
|
||||
appRepository.save(app);
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET", Map.of(
|
||||
"name", app.getName(),
|
||||
"packageName", app.getPackageName(),
|
||||
"appKey", app.getAppKey()
|
||||
));
|
||||
operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET",
|
||||
"重置应用「" + app.getName() + "」的 AppSecret",
|
||||
Map.of("name", app.getName(), "packageName", app.getPackageName(), "appKey", app.getAppKey()));
|
||||
return newSecret;
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,8 @@ public class DashboardService {
|
||||
result.put("serviceCount", serviceCount);
|
||||
result.put("subAccountCount", subAccountCount);
|
||||
|
||||
operationLogService.record(tenantId, "CONSOLE", "DASHBOARD", tenantId, "VIEW_DASHBOARD", result);
|
||||
operationLogService.record(tenantId, "CONSOLE", "DASHBOARD", tenantId, "VIEW_DASHBOARD",
|
||||
"查看控制台概览", result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,14 @@ package com.xuqm.tenant.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.xuqm.tenant.entity.OperationLogEntity;
|
||||
import com.xuqm.tenant.repository.OperationLogRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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 org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
@ -31,6 +34,16 @@ public class OperationLogService {
|
||||
String resourceId,
|
||||
String action,
|
||||
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();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setTenantId(tenantId);
|
||||
@ -39,6 +52,8 @@ public class OperationLogService {
|
||||
entity.setResourceId(resourceId);
|
||||
entity.setAction(action);
|
||||
entity.setOperator(currentOperator());
|
||||
entity.setSummary(summary);
|
||||
entity.setIpAddress(resolveClientIp());
|
||||
entity.setDetailJson(serialize(detail));
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
repository.save(entity);
|
||||
@ -65,6 +80,27 @@ public class OperationLogService {
|
||||
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) {
|
||||
try {
|
||||
Map<String, Object> payload = detail == null ? Map.of() : new LinkedHashMap<>(detail);
|
||||
|
||||
@ -64,11 +64,9 @@ public class SubAccountService {
|
||||
sub.setParentId(parentId);
|
||||
sub.setCreatedAt(LocalDateTime.now());
|
||||
TenantEntity saved = tenantRepository.save(sub);
|
||||
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", saved.getId(), "CREATE_SUB_ACCOUNT", Map.of(
|
||||
"username", saved.getUsername(),
|
||||
"nickname", saved.getNickname(),
|
||||
"email", saved.getEmail()
|
||||
));
|
||||
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", saved.getId(), "CREATE_SUB_ACCOUNT",
|
||||
"创建子账号「" + saved.getNickname() + "」(" + saved.getUsername() + ")",
|
||||
Map.of("username", saved.getUsername(), "nickname", saved.getNickname(), "email", saved.getEmail()));
|
||||
return saved;
|
||||
}
|
||||
|
||||
@ -84,11 +82,9 @@ public class SubAccountService {
|
||||
}
|
||||
sub.setStatus(TenantEntity.Status.DISABLED);
|
||||
tenantRepository.save(sub);
|
||||
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", sub.getId(), "DISABLE_SUB_ACCOUNT", Map.of(
|
||||
"username", sub.getUsername(),
|
||||
"nickname", sub.getNickname(),
|
||||
"email", sub.getEmail()
|
||||
));
|
||||
operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", sub.getId(), "DISABLE_SUB_ACCOUNT",
|
||||
"禁用子账号「" + sub.getNickname() + "」(" + sub.getUsername() + ")",
|
||||
Map.of("username", sub.getUsername(), "nickname", sub.getNickname(), "email", sub.getEmail()));
|
||||
}
|
||||
|
||||
public String generatePassword() {
|
||||
|
||||
@ -177,6 +177,10 @@ public class SystemUpdateService {
|
||||
PRESERVE_TABLES.put("t_risk_config", "id");
|
||||
PRESERVE_TABLES.put("app_licenses", "app_key");
|
||||
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) {
|
||||
@ -297,9 +301,23 @@ public class SystemUpdateService {
|
||||
log.warn("no common columns between {} and {}", table, tmpTable);
|
||||
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 sql = "INSERT IGNORE INTO `" + table + "` (" + colList + ") " +
|
||||
"SELECT " + colList + " FROM `" + tmpTable + "`";
|
||||
String selectList = String.join(", ", selectExprs);
|
||||
String sql = "INSERT INTO `" + table + "` (" + colList + ") " +
|
||||
"SELECT " + selectList + " FROM `" + tmpTable + "`";
|
||||
int rows = stmt.executeUpdate(sql);
|
||||
log.info("restored {} rows into {}", rows, table);
|
||||
|
||||
@ -331,6 +349,44 @@ public class SystemUpdateService {
|
||||
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 版本化迁移 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@ -496,6 +552,7 @@ public class SystemUpdateService {
|
||||
+ " proxy_set_header Host $host;\n"
|
||||
+ " proxy_set_header X-Real-IP $remote_addr;\n"
|
||||
+ " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
|
||||
+ " proxy_buffering off;\n"
|
||||
+ " proxy_read_timeout 600s;\n"
|
||||
+ " proxy_send_timeout 600s;\n"
|
||||
+ " }\n\n"
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户