feat(system): 添加服务日志查看功能及版本化数据库迁移机制

- SystemUpdateService: 引入 _schema_migrations 迁移表,启动时自动执行,替换
  原 docker exec 方式;新增 getRunningServices / getServiceLogs 供日志查看使用
- SystemUpdateController: 新增 GET /api/system/services、/logs/{service}、/version
- OpsController: 新增 GET /api/ops/system/services、/logs/{service}(ROLE_OPS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 23:22:46 +08:00
父节点 0e5558116c
当前提交 5e788fe26b
共有 3 个文件被更改,包括 120 次插入1 次删除

查看文件

@ -14,9 +14,11 @@ import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.service.OpsService; import com.xuqm.tenant.service.OpsService;
import com.xuqm.tenant.service.OpsPushDiagnosticsClient; import com.xuqm.tenant.service.OpsPushDiagnosticsClient;
import com.xuqm.tenant.service.RiskControlService; import com.xuqm.tenant.service.RiskControlService;
import com.xuqm.tenant.service.SystemUpdateService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
@ -39,16 +41,19 @@ public class OpsController {
private final RiskControlService riskControlService; private final RiskControlService riskControlService;
private final OpsPushDiagnosticsClient pushDiagnosticsClient; private final OpsPushDiagnosticsClient pushDiagnosticsClient;
private final PrivateDeploymentProperties deploymentProperties; private final PrivateDeploymentProperties deploymentProperties;
private final SystemUpdateService systemUpdateService;
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager, public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
RiskControlService riskControlService, RiskControlService riskControlService,
OpsPushDiagnosticsClient pushDiagnosticsClient, OpsPushDiagnosticsClient pushDiagnosticsClient,
PrivateDeploymentProperties deploymentProperties) { PrivateDeploymentProperties deploymentProperties,
SystemUpdateService systemUpdateService) {
this.opsService = opsService; this.opsService = opsService;
this.featureServiceManager = featureServiceManager; this.featureServiceManager = featureServiceManager;
this.riskControlService = riskControlService; this.riskControlService = riskControlService;
this.pushDiagnosticsClient = pushDiagnosticsClient; this.pushDiagnosticsClient = pushDiagnosticsClient;
this.deploymentProperties = deploymentProperties; this.deploymentProperties = deploymentProperties;
this.systemUpdateService = systemUpdateService;
} }
@PostMapping("/api/auth/ops/login") @PostMapping("/api/auth/ops/login")
@ -271,6 +276,28 @@ public class OpsController {
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
/* ---------- 服务日志(公有化 ops 平台使用,需要 ROLE_OPS ---------- */
@GetMapping("/api/ops/system/services")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<java.util.List<String>>> opsSystemServices() {
return ResponseEntity.ok(ApiResponse.success(systemUpdateService.getRunningServices()));
}
@GetMapping(value = "/api/ops/system/logs/{service}", produces = MediaType.TEXT_PLAIN_VALUE)
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<String> opsSystemLogs(
@PathVariable String service,
@RequestParam(defaultValue = "200") int lines) {
try {
return ResponseEntity.ok(systemUpdateService.getServiceLogs(service, lines));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.status(500).body(e.getMessage());
}
}
/* ---------- 私有化部署维护接口(无需 JWT,仅私有化模式可用 ---------- */ /* ---------- 私有化部署维护接口(无需 JWT,仅私有化模式可用 ---------- */
@PostMapping("/api/private/admin/approve-pending-requests") @PostMapping("/api/private/admin/approve-pending-requests")

查看文件

@ -5,11 +5,14 @@ import com.xuqm.tenant.service.SystemUpdateService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@ -25,6 +28,33 @@ public class SystemUpdateController {
this.updateService = updateService; this.updateService = updateService;
} }
/** 返回当前正在运行的服务列表。仅 PRIVATE 模式可用。 */
@GetMapping("/services")
public ResponseEntity<?> services() {
if (!deployProps.isPrivate()) {
return ResponseEntity.status(403).body(Map.of("message", "此接口仅在私有化部署可用"));
}
List<String> svcList = updateService.getRunningServices();
return ResponseEntity.ok(Map.of("data", svcList));
}
/** 返回指定服务最近 N 行日志。仅 PRIVATE 模式可用。 */
@GetMapping(value = "/logs/{service}", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> logs(
@PathVariable String service,
@RequestParam(defaultValue = "200") int lines) {
if (!deployProps.isPrivate()) {
return ResponseEntity.status(403).body("此接口仅在私有化部署可用");
}
try {
return ResponseEntity.ok(updateService.getServiceLogs(service, lines));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.status(500).body(e.getMessage());
}
}
/** 返回当前部署版本号。仅 PRIVATE 模式可用。 */ /** 返回当前部署版本号。仅 PRIVATE 模式可用。 */
@GetMapping("/version") @GetMapping("/version")
public ResponseEntity<?> version() { public ResponseEntity<?> version() {

查看文件

@ -11,6 +11,7 @@ import javax.sql.DataSource;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -19,8 +20,11 @@ import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.Statement; import java.sql.Statement;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service @Service
public class SystemUpdateService { public class SystemUpdateService {
@ -32,6 +36,11 @@ public class SystemUpdateService {
"file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx" "file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx"
); );
private static final Set<String> ALLOWED_LOG_SERVICES = Set.of(
"tenant-service", "file-service", "im-service", "push-service",
"update-service", "license-service", "nginx", "tenant-web"
);
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
private String deployRoot; private String deployRoot;
@ -50,6 +59,59 @@ public class SystemUpdateService {
// 公开接口 // 公开接口
/**
* 返回当前正在运行的服务名列表docker compose ps --filter status=running
* 结果已过滤为 ALLOWED_LOG_SERVICES 白名单内的服务防止枚举非预期服务
*/
public List<String> getRunningServices() {
String composeFile = deployRoot + "/docker-compose.yml";
try {
Process p = new ProcessBuilder(
"docker", "compose", "-f", composeFile,
"ps", "--services", "--filter", "status=running"
).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
p.waitFor();
if (out.isEmpty()) return List.of();
return Arrays.stream(out.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty() && ALLOWED_LOG_SERVICES.contains(s))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("failed to list running services", e);
return List.of();
}
}
/**
* 获取指定服务最近 N 行日志上限 1000
* service 名称校验白名单防止注入
*/
public String getServiceLogs(String service, int lines) {
if (!ALLOWED_LOG_SERVICES.contains(service)) {
throw new IllegalArgumentException("不允许查看此服务的日志: " + service);
}
int safeLines = Math.min(Math.max(lines, 10), 1000);
String composeFile = deployRoot + "/docker-compose.yml";
try {
Process p = new ProcessBuilder(
"docker", "compose", "-f", composeFile,
"logs", "--tail", String.valueOf(safeLines), "--no-color", service
).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
int exitCode = p.waitFor();
if (exitCode != 0 && out.isBlank()) {
throw new RuntimeException("docker compose logs 返回非零退出码: " + exitCode);
}
return out;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
log.error("failed to fetch logs for service {}", service, e);
throw new RuntimeException("获取日志失败: " + e.getMessage());
}
}
/** 读取部署目录的 VERSION 文件,返回当前版本号,文件不存在时返回 "unknown"。 */ /** 读取部署目录的 VERSION 文件,返回当前版本号,文件不存在时返回 "unknown"。 */
public String readCurrentVersion() { public String readCurrentVersion() {
Path versionFile = Paths.get(deployRoot, "VERSION"); Path versionFile = Paths.get(deployRoot, "VERSION");