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>
这个提交包含在:
父节点
0e5558116c
当前提交
5e788fe26b
@ -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");
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户