XuqmGroup-Server/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java

249 行
9.5 KiB
Java

package com.xuqm.update.service;
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.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
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 {
@Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir;
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl;
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(MultipartFile packageFile) throws Exception {
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
.orElse("");
String normalized = fileName.toLowerCase(Locale.ROOT);
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);
}
if (normalized.endsWith(".apk")) {
return inspectApk(temp, fileName);
}
if (normalized.endsWith(".ipa")) {
return inspectIpa(temp, fileName);
}
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
} finally {
Files.deleteIfExists(temp);
}
}
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, fileName, false);
}
return inspectRnBundleName(fileName);
}
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
if (bundle == null || bundle.isEmpty()) {
throw new IllegalArgumentException("bundle file is required");
}
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
Path dir = Paths.get(uploadDir, "rn", appId, 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 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]),
fileName,
true);
}
return new RnBundleInspectResult(
null,
platformFromFileName(fileName),
null,
null,
fileName,
false);
}
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 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();
}
public record StoredRnBundle(String bundlePath, String md5) {}
}