UpdateAssetService: add FILE_BASE_URL / FILE_SERVICE_INTERNAL_URL config; any URL starting with FILE_BASE_URL is rewritten to the internal file-service address instead of going through the external domain, fixing APK inspect timeout on private deployments. SystemUpdateService: add patchDockerComposeUpdateService() to inject FILE_BASE_URL and FILE_SERVICE_INTERNAL_URL into existing customers' docker-compose.yml on update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
520 行
21 KiB
Java
520 行
21 KiB
Java
package com.xuqm.update.service;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.xuqm.update.model.AppPackageInspectResult;
|
||
import com.xuqm.update.model.RnBundleInspectResult;
|
||
import net.dongliu.apk.parser.ApkFile;
|
||
import net.dongliu.apk.parser.bean.ApkMeta;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.ContentDisposition;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.web.multipart.MultipartFile;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
|
||
import java.io.IOException;
|
||
import java.io.InputStream;
|
||
import java.net.HttpURLConnection;
|
||
import java.net.URI;
|
||
import java.net.URL;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
import java.nio.file.Paths;
|
||
import java.nio.file.StandardCopyOption;
|
||
import java.security.DigestInputStream;
|
||
import java.security.MessageDigest;
|
||
import java.util.Map;
|
||
import java.util.Locale;
|
||
import java.util.HexFormat;
|
||
import java.util.Optional;
|
||
import java.util.UUID;
|
||
import java.util.zip.ZipEntry;
|
||
import java.util.zip.ZipFile;
|
||
import javax.xml.parsers.DocumentBuilderFactory;
|
||
import org.w3c.dom.Document;
|
||
import org.w3c.dom.Element;
|
||
import org.w3c.dom.Node;
|
||
import org.w3c.dom.NodeList;
|
||
|
||
@Service
|
||
public class UpdateAssetService {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class);
|
||
private final ObjectMapper objectMapper;
|
||
|
||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||
private String uploadDir;
|
||
|
||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||
private String baseUrl;
|
||
|
||
@Value("${FILE_SERVICE_INTERNAL_URL:${file.service.internal-url:http://file-service:8086}}")
|
||
private String fileServiceInternalUrl;
|
||
|
||
@Value("${FILE_BASE_URL:}")
|
||
private String fileBaseUrl;
|
||
|
||
public UpdateAssetService(ObjectMapper objectMapper) {
|
||
this.objectMapper = objectMapper;
|
||
}
|
||
|
||
public String storeAppPackage(MultipartFile apkFile) throws IOException {
|
||
if (apkFile == null || apkFile.isEmpty()) {
|
||
return null;
|
||
}
|
||
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
||
Path dir = Paths.get(uploadDir, "apk");
|
||
Files.createDirectories(dir);
|
||
Path dest = dir.resolve(filename);
|
||
apkFile.transferTo(dest.toFile());
|
||
return baseUrl + "/files/apk/" + filename;
|
||
}
|
||
|
||
public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception {
|
||
if (packageUrl == null || packageUrl.isBlank()) {
|
||
return new AppPackageInspectResult(null, null, null, null, null, false);
|
||
}
|
||
|
||
try {
|
||
RemotePackage remote = downloadRemotePackage(packageUrl, true);
|
||
try {
|
||
return inspectDownloadedPackage(remote.path(), remote.fileName());
|
||
} finally {
|
||
Files.deleteIfExists(remote.path());
|
||
}
|
||
} catch (Exception ex) {
|
||
log.warn("Failed to inspect remote package {}, fallback to undetected result: {}", packageUrl, ex.getMessage());
|
||
return fallbackInspectResult(packageUrl);
|
||
}
|
||
}
|
||
|
||
public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception {
|
||
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
|
||
.orElse("");
|
||
if (packageFile == null || packageFile.isEmpty()) {
|
||
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
||
}
|
||
|
||
Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName));
|
||
try {
|
||
try (InputStream in = packageFile.getInputStream()) {
|
||
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
||
}
|
||
|
||
return inspectDownloadedPackage(temp, fileName);
|
||
} catch (Exception ex) {
|
||
log.warn("Failed to inspect uploaded package {}, fallback to undetected result: {}", fileName, ex.getMessage());
|
||
return fallbackInspectResult(fileName);
|
||
} finally {
|
||
Files.deleteIfExists(temp);
|
||
}
|
||
}
|
||
|
||
public Path downloadRemotePackageToCache(String packageUrl) throws IOException {
|
||
return downloadRemotePackage(packageUrl, false).path();
|
||
}
|
||
|
||
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
|
||
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
|
||
.orElse("");
|
||
if (bundle == null || bundle.isEmpty()) {
|
||
return new RnBundleInspectResult(null, null, null, null, null, fileName, false);
|
||
}
|
||
if (isZipBundle(fileName)) {
|
||
Path temp = Files.createTempFile("xuqm-rn-bundle-inspect-", suffixFor(fileName));
|
||
try {
|
||
try (InputStream in = bundle.getInputStream()) {
|
||
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
||
}
|
||
RnBundleInspectResult zipped = inspectRnBundleZip(temp, fileName);
|
||
if (zipped.detected()) {
|
||
return zipped;
|
||
}
|
||
} finally {
|
||
Files.deleteIfExists(temp);
|
||
}
|
||
}
|
||
return inspectRnBundleName(fileName);
|
||
}
|
||
|
||
public StoredRnBundle storeRnBundle(String appKey, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
||
if (bundle == null || bundle.isEmpty()) {
|
||
throw new IllegalArgumentException("bundle file is required");
|
||
}
|
||
String filename = resolveBundleFilename(moduleId, platform, bundle.getOriginalFilename());
|
||
Path dir = Paths.get(uploadDir, "rn", appKey, platform.toLowerCase(), moduleId);
|
||
Files.createDirectories(dir);
|
||
Path dest = dir.resolve(filename);
|
||
|
||
String md5 = computeMd5(bundle);
|
||
bundle.transferTo(dest.toFile());
|
||
return new StoredRnBundle(dest.toAbsolutePath().toString(), md5);
|
||
}
|
||
|
||
private String computeMd5(MultipartFile file) throws Exception {
|
||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
||
byte[] buf = new byte[8192];
|
||
while (dis.read(buf) != -1) {
|
||
// read fully
|
||
}
|
||
}
|
||
return HexFormat.of().formatHex(digest.digest());
|
||
}
|
||
|
||
private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception {
|
||
try (ApkFile apk = new ApkFile(file.toFile())) {
|
||
ApkMeta meta = apk.getApkMeta();
|
||
Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue();
|
||
return new AppPackageInspectResult(
|
||
"ANDROID",
|
||
blankToNull(meta.getPackageName()),
|
||
blankToNull(meta.getVersionName()),
|
||
versionCode,
|
||
fileName,
|
||
true);
|
||
}
|
||
}
|
||
|
||
private AppPackageInspectResult inspectDownloadedPackage(Path file, String fileName) throws Exception {
|
||
String normalized = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
||
if (normalized.endsWith(".apk")) {
|
||
return inspectApk(file, fileName);
|
||
}
|
||
if (normalized.endsWith(".ipa")) {
|
||
return inspectIpa(file, fileName);
|
||
}
|
||
// 文件名无后缀时,按文件魔数尝试解析(APK/IPA 本质都是 ZIP)
|
||
if (isZipFile(file)) {
|
||
try {
|
||
return inspectApk(file, fileName);
|
||
} catch (Exception ignored) {
|
||
}
|
||
try {
|
||
return inspectIpa(file, fileName);
|
||
} catch (Exception ignored) {
|
||
}
|
||
}
|
||
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
|
||
}
|
||
|
||
private boolean isZipFile(Path file) throws IOException {
|
||
if (!Files.exists(file) || Files.size(file) < 4) {
|
||
return false;
|
||
}
|
||
try (InputStream in = Files.newInputStream(file)) {
|
||
byte[] magic = new byte[4];
|
||
int read = in.read(magic);
|
||
return read == 4 && magic[0] == 0x50 && magic[1] == 0x4B && magic[2] == 0x03 && magic[3] == 0x04;
|
||
}
|
||
}
|
||
|
||
private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception {
|
||
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
||
ZipEntry entry = zipFile.stream()
|
||
.filter(e -> e.getName().endsWith("Info.plist"))
|
||
.findFirst()
|
||
.orElse(null);
|
||
if (entry != null) {
|
||
byte[] data = zipFile.getInputStream(entry).readAllBytes();
|
||
String text = new String(data, StandardCharsets.UTF_8);
|
||
if (text.contains("<plist")) {
|
||
var plist = parsePlistXml(text);
|
||
return new AppPackageInspectResult(
|
||
"IOS",
|
||
blankToNull(plist.get("CFBundleIdentifier")),
|
||
blankToNull(plist.get("CFBundleShortVersionString")),
|
||
parseInteger(plist.get("CFBundleVersion")),
|
||
fileName,
|
||
true);
|
||
}
|
||
}
|
||
}
|
||
return new AppPackageInspectResult("IOS", null, null, null, fileName, false);
|
||
}
|
||
|
||
private RnBundleInspectResult inspectRnBundleName(String fileName) {
|
||
String baseName = stripExtension(fileName);
|
||
String[] parts = baseName.split("__");
|
||
if (parts.length >= 4) {
|
||
return new RnBundleInspectResult(
|
||
blankToNull(parts[0]),
|
||
platformFromToken(parts[1]),
|
||
blankToNull(parts[2]),
|
||
blankToNull(parts[3]),
|
||
parts.length >= 5 ? blankToNull(parts[4]) : null,
|
||
fileName,
|
||
true);
|
||
}
|
||
return new RnBundleInspectResult(
|
||
null,
|
||
platformFromFileName(fileName),
|
||
null,
|
||
null,
|
||
null,
|
||
fileName,
|
||
false);
|
||
}
|
||
|
||
private RnBundleInspectResult inspectRnBundleZip(Path file, String fileName) throws Exception {
|
||
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
||
ZipEntry entry = zipFile.getEntry("rn-manifest.json");
|
||
if (entry == null) {
|
||
entry = zipFile.getEntry("manifest.json");
|
||
}
|
||
if (entry != null) {
|
||
try (InputStream in = zipFile.getInputStream(entry)) {
|
||
JsonNode node = objectMapper.readTree(in);
|
||
String moduleId = text(node, "moduleId");
|
||
String platform = text(node, "platform");
|
||
String version = firstText(node, "bundleVersion", "version");
|
||
String minCommonVersion = text(node, "minCommonVersion");
|
||
String packageName = text(node, "packageName");
|
||
return new RnBundleInspectResult(
|
||
moduleId,
|
||
platformFromToken(platform),
|
||
version,
|
||
minCommonVersion,
|
||
packageName,
|
||
fileName,
|
||
hasText(moduleId) && hasText(version) && hasText(platform));
|
||
}
|
||
}
|
||
}
|
||
return inspectRnBundleName(fileName);
|
||
}
|
||
|
||
private Map<String, String> parsePlistXml(String xml) throws Exception {
|
||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||
factory.setNamespaceAware(false);
|
||
factory.setExpandEntityReferences(false);
|
||
Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||
NodeList dictNodes = document.getElementsByTagName("dict");
|
||
if (dictNodes.getLength() == 0) {
|
||
return Map.of();
|
||
}
|
||
Element dict = (Element) dictNodes.item(0);
|
||
Map<String, String> values = new java.util.LinkedHashMap<>();
|
||
Node child = dict.getFirstChild();
|
||
String currentKey = null;
|
||
while (child != null) {
|
||
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
||
String nodeName = child.getNodeName();
|
||
if ("key".equals(nodeName)) {
|
||
currentKey = child.getTextContent();
|
||
} else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) {
|
||
values.put(currentKey, child.getTextContent());
|
||
currentKey = null;
|
||
}
|
||
}
|
||
child = child.getNextSibling();
|
||
}
|
||
return values;
|
||
}
|
||
|
||
private String platformFromFileName(String fileName) {
|
||
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
||
if (lower.contains("ios") || lower.endsWith(".ipa")) {
|
||
return "IOS";
|
||
}
|
||
return "ANDROID";
|
||
}
|
||
|
||
private String platformFromToken(String token) {
|
||
if (token == null) {
|
||
return null;
|
||
}
|
||
String normalized = token.trim().toUpperCase(Locale.ROOT);
|
||
return switch (normalized) {
|
||
case "ANDROID", "IOS" -> normalized;
|
||
case "A", "ANDROIDSDK" -> "ANDROID";
|
||
case "I", "IOSSDK" -> "IOS";
|
||
default -> normalized.isBlank() ? null : normalized;
|
||
};
|
||
}
|
||
|
||
private String stripExtension(String fileName) {
|
||
if (fileName == null) return "";
|
||
int idx = fileName.lastIndexOf('.');
|
||
return idx > 0 ? fileName.substring(0, idx) : fileName;
|
||
}
|
||
|
||
private String suffixFor(String fileName) {
|
||
if (fileName == null) return ".tmp";
|
||
int idx = fileName.lastIndexOf('.');
|
||
return idx > 0 ? fileName.substring(idx) : ".tmp";
|
||
}
|
||
|
||
private boolean isZipBundle(String fileName) {
|
||
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
||
return lower.endsWith(".zip") || lower.endsWith(".bundle.zip") || lower.endsWith(".tar.gz");
|
||
}
|
||
|
||
private String resolveBundleFilename(String moduleId, String platform, String originalFilename) {
|
||
String safeOriginal = Optional.ofNullable(originalFilename).orElse("").trim();
|
||
String ext = "";
|
||
int idx = safeOriginal.lastIndexOf('.');
|
||
if (idx > 0 && idx < safeOriginal.length() - 1) {
|
||
ext = safeOriginal.substring(idx);
|
||
}
|
||
if (!hasText(ext)) {
|
||
ext = ".zip";
|
||
}
|
||
return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext;
|
||
}
|
||
|
||
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
|
||
// Rewrite external file-service URLs to the internal Docker network address to avoid
|
||
// hairpin NAT / HTTPS overhead when update-service and file-service are co-located.
|
||
String internalUrl = packageUrl;
|
||
if (packageUrl != null) {
|
||
if (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)) {
|
||
internalUrl = fileServiceInternalUrl + packageUrl.substring(fileBaseUrl.length());
|
||
} else if (packageUrl.contains("file.dev.xuqinmin.com")) {
|
||
internalUrl = packageUrl.replace("https://file.dev.xuqinmin.com", fileServiceInternalUrl);
|
||
}
|
||
}
|
||
HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();
|
||
connection.setConnectTimeout(15_000);
|
||
connection.setReadTimeout(300_000);
|
||
connection.setInstanceFollowRedirects(true);
|
||
|
||
int status = connection.getResponseCode();
|
||
if (status >= 400) {
|
||
throw new IOException("Failed to download package: HTTP " + status);
|
||
}
|
||
|
||
String contentType = Optional.ofNullable(connection.getContentType()).orElse("");
|
||
String fileName = resolveRemoteFileName(connection, packageUrl, contentType);
|
||
Path path = tempFile
|
||
? Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName))
|
||
: buildRemoteCachePath(packageUrl, fileName, contentType);
|
||
|
||
if (!tempFile && Files.exists(path)) {
|
||
return new RemotePackage(path, fileName, contentType);
|
||
}
|
||
|
||
try (InputStream in = connection.getInputStream()) {
|
||
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
|
||
}
|
||
return new RemotePackage(path, fileName, contentType);
|
||
}
|
||
|
||
private Path buildRemoteCachePath(String packageUrl, String fileName, String contentType) throws IOException {
|
||
Path dir = Paths.get(uploadDir, "apk", "remote");
|
||
Files.createDirectories(dir);
|
||
String safeName = sanitizeFileName(fileName);
|
||
String cacheName = sha256Hex(packageUrl) + "_" + safeName;
|
||
if (!cacheName.contains(".") && hasText(contentType)) {
|
||
cacheName = cacheName + suffixByContentType(contentType);
|
||
}
|
||
return dir.resolve(cacheName);
|
||
}
|
||
|
||
private String resolveRemoteFileName(HttpURLConnection connection, String packageUrl, String contentType) {
|
||
String fileName = null;
|
||
String disposition = connection.getHeaderField("Content-Disposition");
|
||
if (hasText(disposition)) {
|
||
try {
|
||
fileName = ContentDisposition.parse(disposition).getFilename();
|
||
} catch (Exception ignored) {
|
||
fileName = null;
|
||
}
|
||
if (!hasText(fileName)) {
|
||
int idx = disposition.toLowerCase(Locale.ROOT).indexOf("filename=");
|
||
if (idx >= 0) {
|
||
fileName = disposition.substring(idx + 9).replace("\"", "").trim();
|
||
}
|
||
}
|
||
}
|
||
if (!hasText(fileName)) {
|
||
String pathName = Paths.get(URI.create(packageUrl).getPath()).getFileName() != null
|
||
? Paths.get(URI.create(packageUrl).getPath()).getFileName().toString()
|
||
: null;
|
||
fileName = hasText(pathName) ? pathName : "downloaded";
|
||
}
|
||
if (!fileName.contains(".")) {
|
||
fileName = fileName + suffixByContentType(contentType);
|
||
}
|
||
return fileName;
|
||
}
|
||
|
||
private String suffixByContentType(String contentType) {
|
||
String lower = Optional.ofNullable(contentType).orElse("").toLowerCase(Locale.ROOT);
|
||
if (lower.contains("android.package-archive")) return ".apk";
|
||
if (lower.contains("ipa")) return ".ipa";
|
||
if (lower.contains("zip")) return ".zip";
|
||
return ".bin";
|
||
}
|
||
|
||
private String sanitizeFileName(String fileName) {
|
||
String safe = Optional.ofNullable(fileName).orElse("downloaded");
|
||
return safe.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||
}
|
||
|
||
private String sha256Hex(String value) {
|
||
try {
|
||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||
} catch (Exception e) {
|
||
throw new IllegalStateException("SHA-256 not available", e);
|
||
}
|
||
}
|
||
|
||
private Integer parseInteger(String value) {
|
||
if (value == null || value.isBlank()) return null;
|
||
try {
|
||
return Integer.valueOf(value.trim());
|
||
} catch (NumberFormatException e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private String blankToNull(String value) {
|
||
return value == null || value.isBlank() ? null : value.trim();
|
||
}
|
||
|
||
private String text(JsonNode node, String field) {
|
||
if (node == null || !node.hasNonNull(field)) {
|
||
return null;
|
||
}
|
||
String value = node.get(field).asText();
|
||
return blankToNull(value);
|
||
}
|
||
|
||
private String firstText(JsonNode node, String primaryField, String fallbackField) {
|
||
String primary = text(node, primaryField);
|
||
return hasText(primary) ? primary : text(node, fallbackField);
|
||
}
|
||
|
||
private AppPackageInspectResult fallbackInspectResult(String source) {
|
||
String fileName = source;
|
||
try {
|
||
if (source != null && (source.startsWith("http://") || source.startsWith("https://"))) {
|
||
fileName = Optional.ofNullable(Paths.get(URI.create(source).getPath()).getFileName())
|
||
.map(Path::toString)
|
||
.orElse(source);
|
||
}
|
||
} catch (Exception ignored) {
|
||
fileName = source;
|
||
}
|
||
return new AppPackageInspectResult(
|
||
platformFromFileName(fileName),
|
||
null,
|
||
null,
|
||
null,
|
||
fileName,
|
||
false);
|
||
}
|
||
|
||
private boolean hasText(String value) {
|
||
return value != null && !value.isBlank();
|
||
}
|
||
|
||
public record StoredRnBundle(String bundlePath, String md5) {}
|
||
|
||
private record RemotePackage(Path path, String fileName, String contentType) {}
|
||
}
|