feat(license): 支持修改 License 过期时间 + 修复一键更新三个问题
License 过期时间: - LicenseAdminController PATCH 接口增加 expiresAt 字段 - AppLicenseService.update() 移除"一旦设置不可修改"限制,支持清空(永久)或更新日期 一键更新 (SystemUpdateService) 修复: 1. 改用 docker compose (v2) 替换 docker-compose (v1) 2. isRunning/getCurrentImage 去掉 project=xuqm 标签过滤 (deploy.sh 不传 -p 参数,实际 project 标签为目录名) 3. 拉取前读取 deployRoot/.env 中的 REGISTRY 凭据并执行 docker login Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
aece1fd08d
当前提交
b0e7f198db
@ -40,8 +40,18 @@ public class LicenseAdminController {
|
||||
if (req.maxDevices() != null && req.maxDevices() < 1) {
|
||||
throw new com.xuqm.common.exception.BusinessException(400, "最大设备数必须大于0");
|
||||
}
|
||||
java.time.LocalDateTime newExpiresAt = null;
|
||||
boolean clearExpiresAt = false;
|
||||
if (req.expiresAt() != null) {
|
||||
if (req.expiresAt().isEmpty()) {
|
||||
clearExpiresAt = true;
|
||||
} else {
|
||||
newExpiresAt = java.time.LocalDateTime.parse(req.expiresAt(),
|
||||
java.time.format.DateTimeFormatter.ISO_DATE_TIME);
|
||||
}
|
||||
}
|
||||
AppLicenseEntity updated = appLicenseService.update(
|
||||
appKey, null, req.maxDevices(), null, req.isActive(), req.remark());
|
||||
appKey, null, req.maxDevices(), newExpiresAt, clearExpiresAt, req.isActive(), req.remark());
|
||||
return ResponseEntity.ok(ApiResponse.success(updated));
|
||||
}
|
||||
|
||||
@ -60,7 +70,8 @@ public class LicenseAdminController {
|
||||
public record UpdateAppLicenseRequest(
|
||||
Integer maxDevices,
|
||||
Boolean isActive,
|
||||
String remark
|
||||
String remark,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
||||
}
|
||||
|
||||
@ -26,18 +26,21 @@ public class AppLicenseService {
|
||||
public AppLicenseEntity upsert(String appKey, String name, Integer maxDevices,
|
||||
LocalDateTime expiresAt, Boolean isActive, String remark) {
|
||||
return repository.findById(appKey)
|
||||
.map(license -> update(appKey, name, maxDevices, expiresAt, isActive, remark))
|
||||
.map(license -> update(appKey, name, maxDevices, expiresAt, false, isActive, remark))
|
||||
.orElseGet(() -> create(appKey, name, maxDevices, expiresAt, isActive, remark));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppLicenseEntity update(String appKey, String name, Integer maxDevices,
|
||||
LocalDateTime expiresAt, Boolean isActive, String remark) {
|
||||
LocalDateTime expiresAt, boolean clearExpiresAt, Boolean isActive, String remark) {
|
||||
AppLicenseEntity license = getByAppKey(appKey);
|
||||
if (name != null) license.setName(name);
|
||||
if (maxDevices != null) license.setMaxDevices(maxDevices);
|
||||
// expiresAt 一旦设置不可修改
|
||||
if (expiresAt != null && license.getExpiresAt() == null) license.setExpiresAt(expiresAt);
|
||||
if (clearExpiresAt) {
|
||||
license.setExpiresAt(null);
|
||||
} else if (expiresAt != null) {
|
||||
license.setExpiresAt(expiresAt);
|
||||
}
|
||||
if (isActive != null) license.setIsActive(isActive);
|
||||
if (remark != null) license.setRemark(remark);
|
||||
license.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
@ -7,6 +7,8 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@ -25,34 +27,35 @@ public class SystemUpdateService {
|
||||
public void runUpdate(Consumer<String> emit) {
|
||||
String composeFile = deployRoot + "/docker-compose.yml";
|
||||
|
||||
// Authenticate to the registry before pulling (credentials stored in .env by deploy.sh)
|
||||
dockerLogin(emit);
|
||||
|
||||
emit.accept(">>> 拉取最新镜像...");
|
||||
for (String svc : OTHER_SERVICES) {
|
||||
if (isRunning(svc)) {
|
||||
emit.accept(" pulling " + svc + " ...");
|
||||
exec(emit, "docker-compose", "-f", composeFile, "-p", "xuqm", "pull", "--quiet", svc);
|
||||
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc);
|
||||
}
|
||||
}
|
||||
emit.accept(" pulling tenant-service ...");
|
||||
exec(emit, "docker-compose", "-f", composeFile, "-p", "xuqm", "pull", "--quiet", "tenant-service");
|
||||
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service");
|
||||
emit.accept(">>> 镜像拉取完成");
|
||||
|
||||
emit.accept(">>> 重启各服务...");
|
||||
for (String svc : OTHER_SERVICES) {
|
||||
if (isRunning(svc)) {
|
||||
emit.accept(" restarting " + svc + " ...");
|
||||
exec(emit, "docker-compose", "-f", composeFile, "-p", "xuqm",
|
||||
exec(emit, "docker", "compose", "-f", composeFile,
|
||||
"up", "-d", "--no-deps", "--force-recreate", svc);
|
||||
emit.accept(" " + svc + " ✓");
|
||||
}
|
||||
}
|
||||
|
||||
// tenant-service 自身的重建:不能直接用 docker-compose,因为一旦发出 stop 指令,
|
||||
// 当前容器(含 docker-compose 进程)会立即被杀死,后续的 rm/create/start 步骤不会执行。
|
||||
// tenant-service 自身的重建:不能直接用 docker compose,因为一旦发出 stop 指令,
|
||||
// 当前容器(含 docker compose 进程)会立即被杀死,后续的 rm/create/start 步骤不会执行。
|
||||
// 解决方案:先用 docker run -d 启动一个独立助手容器,它不依附于 tenant-service,
|
||||
// 能在 tenant-service 停止后继续完成重建。
|
||||
emit.accept(">>> 启动自更新助手容器...");
|
||||
// Resolve the image by inspecting the running container via compose labels,
|
||||
// so we don't depend on REGISTRY/IMAGE_TAG being present in the container env.
|
||||
String selfImage = getCurrentImage();
|
||||
if (selfImage == null) {
|
||||
emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请检查容器标签或手动执行更新。");
|
||||
@ -66,11 +69,44 @@ public class SystemUpdateService {
|
||||
emit.accept("RESTART_SELF");
|
||||
} else {
|
||||
emit.accept(">>> [警告] 助手容器启动失败,请手动执行:");
|
||||
emit.accept(">>> docker-compose -f " + composeFile + " -p xuqm up -d --no-deps --force-recreate tenant-service");
|
||||
emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service");
|
||||
emit.accept("DONE");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read REGISTRY / REGISTRY_USER / REGISTRY_PASSWORD from deployRoot/.env and
|
||||
* run "docker login" so that subsequent pulls succeed on private registries.
|
||||
*/
|
||||
private void dockerLogin(Consumer<String> emit) {
|
||||
try {
|
||||
String registry = null, user = null, password = null;
|
||||
for (String line : Files.readAllLines(Paths.get(deployRoot + "/.env"))) {
|
||||
if (line.startsWith("REGISTRY=")) registry = line.substring("REGISTRY=".length()).trim();
|
||||
else if (line.startsWith("REGISTRY_USER=")) user = line.substring("REGISTRY_USER=".length()).trim();
|
||||
else if (line.startsWith("REGISTRY_PASSWORD=")) password = line.substring("REGISTRY_PASSWORD=".length()).trim();
|
||||
}
|
||||
if (registry == null || user == null || password == null || password.isEmpty()) return;
|
||||
// REGISTRY format: host/namespace → extract host
|
||||
String host = registry.contains("/") ? registry.substring(0, registry.indexOf('/')) : registry;
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin")
|
||||
.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
p.getOutputStream().write((password + "\n").getBytes());
|
||||
p.getOutputStream().flush();
|
||||
p.getOutputStream().close();
|
||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
||||
int code = p.waitFor();
|
||||
if (code == 0) {
|
||||
emit.accept(" 已完成镜像仓库登录");
|
||||
} else {
|
||||
emit.accept(" [警告] 镜像仓库登录失败,将使用本地缓存(" + out + ")");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
emit.accept(" [警告] 读取仓库凭据失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个独立的 detached 容器,在 tenant-service 被停止后重建它。
|
||||
* 助手容器与 tenant-service 无父子关系,不会随 tenant-service 终止。
|
||||
@ -82,8 +118,8 @@ public class SystemUpdateService {
|
||||
.redirectErrorStream(true).start().waitFor();
|
||||
|
||||
// 等待 8 秒确保 tenant-service 已完全停止,然后执行 force-recreate
|
||||
String shellCmd = "sleep 8 && docker-compose -f " + composeFile
|
||||
+ " -p xuqm up -d --no-deps --force-recreate tenant-service";
|
||||
String shellCmd = "sleep 8 && docker compose -f " + composeFile
|
||||
+ " up -d --no-deps --force-recreate tenant-service";
|
||||
|
||||
Process p = new ProcessBuilder(
|
||||
"docker", "run", "-d", "--rm",
|
||||
@ -105,13 +141,11 @@ public class SystemUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
// Use compose labels instead of container name — works with both Compose v1 (xuqm_svc_1)
|
||||
// and Compose v2 (xuqm-svc-1) naming conventions.
|
||||
// Filter only by service label — avoids dependency on project name which varies by deploy root dir.
|
||||
private boolean isRunning(String service) {
|
||||
try {
|
||||
Process p = new ProcessBuilder(
|
||||
"docker", "ps", "-q",
|
||||
"--filter", "label=com.docker.compose.project=xuqm",
|
||||
"--filter", "label=com.docker.compose.service=" + service
|
||||
).redirectErrorStream(true).start();
|
||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
||||
@ -126,7 +160,6 @@ public class SystemUpdateService {
|
||||
try {
|
||||
Process p = new ProcessBuilder(
|
||||
"docker", "ps",
|
||||
"--filter", "label=com.docker.compose.project=xuqm",
|
||||
"--filter", "label=com.docker.compose.service=tenant-service",
|
||||
"--format", "{{.Image}}"
|
||||
).redirectErrorStream(true).start();
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户