This commit is contained in:
2026-09-20 14:22:06 +08:00
parent 493d62e04c
commit b9e0c8ea2f
29 changed files with 1078 additions and 17 deletions
@@ -0,0 +1,88 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.PdaReleaseService;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@RestController
public class PdaReleaseController {
private final PdaReleaseService releases;
public PdaReleaseController(PdaReleaseService releases) { this.releases = releases; }
@GetMapping("/api/platform/v1/files/pda-releases/{app}")
@PreAuthorize("hasAuthority('platform:pda-release:list')")
public ApiResponse<List<PdaReleaseService.Release>> list(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String app) {
requirePlatform(user);
return ApiResponse.success(releases.list(app));
}
@PostMapping(value = "/api/platform/v1/files/pda-releases/{app}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String app, @RequestParam String packageType, @RequestParam String versionName,
@RequestParam int versionCode, @RequestParam int minAppVersionCode,
@RequestParam(defaultValue = "false") boolean mandatory, @RequestParam(defaultValue = "") String releaseNotes,
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(releases.upload(app, packageType, versionName, versionCode,
minAppVersionCode, mandatory, releaseNotes, file, requirePlatform(user)));
}
@PostMapping("/api/platform/v1/files/pda-releases/{id}/publish")
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> publish(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String id) {
requirePlatform(user);
return ApiResponse.success(releases.publish(id));
}
@PostMapping("/api/platform/v1/files/pda-releases/{id}/withdraw")
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> withdraw(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String id) {
requirePlatform(user);
return ApiResponse.success(releases.withdraw(id));
}
@GetMapping("/api/pda-updates/{app}/latest")
public Map<String, Object> latest(@PathVariable String app, @RequestParam String appid,
@RequestParam String platform, @RequestParam int appVersionCode, @RequestParam String wgtVersion) {
return releases.latest(app, appid, platform, appVersionCode, wgtVersion);
}
@GetMapping("/api/pda-updates/{app}/{id}/package")
public ResponseEntity<Resource> download(@PathVariable String app, @PathVariable String id) {
PdaReleaseService.PackageFile file = releases.packageFile(app, id);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(file.sizeBytes())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.body(file.resource());
}
private static long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
}
@@ -0,0 +1,83 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.zip.ZipFile;
@Component
public class PdaPackageStorage {
private static final long MAX_BYTES = 200L * 1024 * 1024;
private final Path root;
public PdaPackageStorage(@Value("${baiyue.file.storage-root}") String storageRoot) {
root = Path.of(storageRoot).toAbsolutePath().normalize().resolve("pda-packages");
}
public record Stored(String key, long sizeBytes, String sha256) { }
public Stored store(String app, String id, String type, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
throw new BizException(ResultCode.BAD_REQUEST, "更新包为空或超过 200MB");
}
String name = file.getOriginalFilename();
if (name == null || !name.toLowerCase().endsWith("." + type)) {
throw new BizException(ResultCode.BAD_REQUEST, "文件扩展名与包类型不符");
}
String key = app + "/" + id + "." + type;
Path target = path(key);
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
try (ZipFile zip = new ZipFile(target.toFile())) {
if (zip.size() == 0) throw new IOException("empty archive");
}
return new Stored(key, Files.size(target), sha256(target));
} catch (IOException | IllegalStateException exception) {
delete(key);
throw new BizException(ResultCode.BAD_REQUEST, "更新包不是有效的 APK/WGT 文件");
}
}
public Resource load(String key) {
Path file = path(key);
if (!Files.isRegularFile(file)) throw new BizException(ResultCode.NOT_FOUND, "更新包文件不存在");
return new PathResource(file);
}
public void delete(String key) {
try { Files.deleteIfExists(path(key)); } catch (IOException ignored) { }
}
private Path path(String key) {
if (key == null || !key.matches("(domestic|overseas)/[a-f0-9-]{36}\\.(apk|wgt)")) {
throw new BizException(ResultCode.BAD_REQUEST, "更新包路径无效");
}
Path path = root.resolve(key).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "更新包路径越界");
return path;
}
private static String sha256(Path file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
}
@@ -0,0 +1,229 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@Service
public class PdaReleaseService {
private final JdbcTemplate jdbc;
private final PdaPackageStorage storage;
private final String publicBaseUrl;
private final String domesticAppId;
private final String overseasAppId;
public PdaReleaseService(JdbcTemplate jdbc, PdaPackageStorage storage,
@Value("${baiyue.file.pda.public-base-url:}") String publicBaseUrl,
@Value("${baiyue.file.pda.domestic-appid:__UNI__9BFFAF2}") String domesticAppId,
@Value("${baiyue.file.pda.overseas-appid:__UNI__936D079}") String overseasAppId) {
this.jdbc = jdbc;
this.storage = storage;
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
this.domesticAppId = domesticAppId;
this.overseasAppId = overseasAppId;
if (!this.publicBaseUrl.isEmpty() && !this.publicBaseUrl.startsWith("https://")) {
throw new IllegalArgumentException("PDA_UPDATE_PUBLIC_BASE_URL must use HTTPS");
}
}
public record Release(String id, String app, String appid, String packageType, String versionName,
int versionCode, int minAppVersionCode, boolean mandatory, String releaseNotes,
String status, boolean current, String sha256, long sizeBytes, String downloadUrl,
LocalDateTime createdAt, LocalDateTime publishedAt) { }
public record PackageFile(Resource resource, long sizeBytes, String fileName) { }
private record Row(String id, String app, String appid, String type, String name, int code,
int minCode, boolean mandatory, String notes, String status,
String key, String sha256, long bytes, LocalDateTime createdAt,
LocalDateTime publishedAt) { }
private record Channel(String appid, String apkId, String wgtId) { }
public Release upload(String app, String type, String versionName, int versionCode,
int minAppVersionCode, boolean mandatory, String notes,
MultipartFile file, long operatorId) {
String appid = appid(app);
type = type == null ? "" : type.toLowerCase();
if (!type.equals("apk") && !type.equals("wgt")) throw bad("只支持 APK 或 WGT");
if (versionName == null || versionName.length() > 32
|| !versionName.matches("\\d+(\\.\\d+)+")) throw bad("版本号格式无效");
if (versionCode < 1 || minAppVersionCode < 1
|| (type.equals("apk") && versionCode != minAppVersionCode)) {
throw bad("APK 版本号或 WGT 最低基座版本无效");
}
if (notes != null && notes.length() > 1000) throw bad("更新说明过长");
String id = UUID.randomUUID().toString();
PdaPackageStorage.Stored stored = storage.store(app, id, type, file);
try {
jdbc.update("""
INSERT INTO file_pda_release
(id,app_code,app_id,package_type,version_name,version_code,min_app_version_code,
mandatory,release_notes,status,object_key,sha256,size_bytes,created_by)
VALUES (?,?,?,?,?,?,?,?,?,'DRAFT',?,?,?,?)
""", id, app, appid, type, versionName, versionCode, minAppVersionCode,
mandatory, notes == null ? "" : notes, stored.key(), stored.sha256(),
stored.sizeBytes(), operatorId);
} catch (RuntimeException exception) {
storage.delete(stored.key());
throw exception;
}
return view(get(id));
}
@Transactional
public Release publish(String id) {
Row row = get(id);
if (!row.status().equals("DRAFT")) throw conflict("只有草稿可以发布");
if (publicBaseUrl.isEmpty()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置");
}
Channel channel = channel(row.app(), true);
if (!Objects.equals(channel.appid(), row.appid())) throw conflict("应用 AppID 已改变,请重新上传");
Row current = current(row.type().equals("apk") ? channel.apkId() : channel.wgtId());
if (current != null && (row.type().equals("apk")
? row.code() <= current.code()
: compareVersion(row.name(), current.name()) <= 0)) {
throw conflict("新版本必须高于当前发布版本");
}
if (row.type().equals("wgt")) {
Row apk = current(channel.apkId());
if (apk == null || row.minCode() > apk.code()) throw conflict("请先发布兼容的 APK 基座");
}
storage.load(row.key());
if (jdbc.update("UPDATE file_pda_release SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='DRAFT'", id) != 1) {
throw conflict("版本状态已改变");
}
String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id";
jdbc.update("UPDATE file_pda_release_channel SET " + column + "=? WHERE app_code=?", id, row.app());
return view(get(id));
}
@Transactional
public Release withdraw(String id) {
Row row = get(id);
Channel channel = channel(row.app(), true);
if (!row.status().equals("PUBLISHED")) throw conflict("只有已发布版本可以停发");
String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id";
String currentId = row.type().equals("apk") ? channel.apkId() : channel.wgtId();
if (id.equals(currentId)) {
jdbc.update("UPDATE file_pda_release_channel SET " + column + "=NULL WHERE app_code=?", row.app());
}
jdbc.update("UPDATE file_pda_release SET status='WITHDRAWN' WHERE id=?", id);
return view(get(id));
}
public List<Release> list(String app) {
appid(app);
Channel channel = channel(app, false);
List<Row> rows = jdbc.query("SELECT * FROM file_pda_release WHERE app_code=? ORDER BY created_at DESC LIMIT 100",
(rs, index) -> map(rs), app);
return rows.stream().map(row -> view(row, channel)).toList();
}
public Map<String, Object> latest(String app, String requestedAppid, String platform,
int appVersionCode, String wgtVersion) {
if (!appid(app).equals(requestedAppid) || !"android".equalsIgnoreCase(platform)
|| appVersionCode < 1 || wgtVersion == null || !wgtVersion.matches("\\d+(\\.\\d+)+")) {
throw bad("更新检查参数无效");
}
Channel channel = channel(app, false);
Row apk = current(channel.apkId());
if (apk != null && apk.code() > appVersionCode) return manifest(apk);
Row wgt = current(channel.wgtId());
if (wgt != null && wgt.minCode() <= appVersionCode
&& compareVersion(wgt.name(), wgtVersion) > 0) return manifest(wgt);
return Map.of("update", false);
}
public PackageFile packageFile(String app, String id) {
Row row = get(id);
if (!row.app().equals(app) || !row.status().equals("PUBLISHED")) {
throw new BizException(ResultCode.NOT_FOUND, "更新包不存在");
}
return new PackageFile(storage.load(row.key()), row.bytes(),
row.app() + "-pda-" + row.name() + "." + row.type());
}
private Map<String, Object> manifest(Row row) {
if (publicBaseUrl.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置");
return Map.of("update", true, "appid", row.appid(), "packageType", row.type(),
"versionName", row.name(), "versionCode", row.code(), "minAppVersionCode", row.minCode(),
"mandatory", row.mandatory(), "releaseNotes", row.notes(), "downloadUrl", url(row));
}
private Release view(Row row) {
return view(row, channel(row.app(), false));
}
private Release view(Row row, Channel channel) {
boolean current = row.id().equals(row.type().equals("apk") ? channel.apkId() : channel.wgtId());
return new Release(row.id(), row.app(), row.appid(), row.type(), row.name(), row.code(),
row.minCode(), row.mandatory(), row.notes(), row.status(), current, row.sha256(), row.bytes(),
row.status().equals("PUBLISHED") && !publicBaseUrl.isEmpty() ? url(row) : null,
row.createdAt(), row.publishedAt());
}
private String url(Row row) {
return publicBaseUrl + "/api/pda-updates/" + row.app() + "/" + row.id() + "/package";
}
private Channel channel(String app, boolean locked) {
String sql = "SELECT app_id,current_apk_id,current_wgt_id FROM file_pda_release_channel WHERE app_code=?"
+ (locked ? " FOR UPDATE" : "");
List<Channel> rows = jdbc.query(sql, (rs, index) ->
new Channel(rs.getString(1), rs.getString(2), rs.getString(3)), app);
if (rows.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA 发布通道未初始化");
return rows.get(0);
}
private Row current(String id) { return id == null ? null : get(id); }
private Row get(String id) {
List<Row> rows = jdbc.query("SELECT * FROM file_pda_release WHERE id=?", (rs, index) -> map(rs), id);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "PDA 版本不存在");
return rows.get(0);
}
private String appid(String app) {
return switch (app) {
case "domestic" -> domesticAppId;
case "overseas" -> overseasAppId;
default -> throw bad("PDA 应用类型无效");
};
}
private static Row map(ResultSet rs) throws SQLException {
return new Row(rs.getString("id"), rs.getString("app_code"), rs.getString("app_id"),
rs.getString("package_type"), rs.getString("version_name"), rs.getInt("version_code"),
rs.getInt("min_app_version_code"), rs.getBoolean("mandatory"), rs.getString("release_notes"),
rs.getString("status"), rs.getString("object_key"), rs.getString("sha256"), rs.getLong("size_bytes"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("published_at") == null ? null : rs.getTimestamp("published_at").toLocalDateTime());
}
private static int compareVersion(String left, String right) {
String[] a = left.split("\\.");
String[] b = right.split("\\.");
for (int i = 0; i < Math.max(a.length, b.length); i++) {
int x = i < a.length ? Integer.parseInt(a[i]) : 0;
int y = i < b.length ? Integer.parseInt(b[i]) : 0;
if (x != y) return Integer.compare(x, y);
}
return 0;
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
+6 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000
servlet:
multipart:
max-file-size: 50MB
max-request-size: 51MB
max-file-size: 200MB
max-request-size: 201MB
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer:
@@ -60,6 +60,10 @@ baiyue:
url: ${FINANCE_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
pda:
public-base-url: ${PDA_UPDATE_PUBLIC_BASE_URL:}
domestic-appid: ${PDA_DOMESTIC_APPID:__UNI__9BFFAF2}
overseas-appid: ${PDA_OVERSEAS_APPID:__UNI__936D079}
price-book:
max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760}
export:
+34
View File
@@ -28,6 +28,40 @@ CREATE TABLE IF NOT EXISTS file_object (
KEY idx_file_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB COMMENT='文件元数据表';
CREATE TABLE IF NOT EXISTS file_pda_release_channel (
app_code VARCHAR(16) NOT NULL,
app_id VARCHAR(64) NOT NULL,
current_apk_id CHAR(36) NULL,
current_wgt_id CHAR(36) NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB COMMENT='国内/海外PDA当前发布指针';
INSERT INTO file_pda_release_channel (app_code, app_id) VALUES
('domestic', '__UNI__9BFFAF2'), ('overseas', '__UNI__936D079')
ON DUPLICATE KEY UPDATE app_id=VALUES(app_id);
CREATE TABLE IF NOT EXISTS file_pda_release (
id CHAR(36) NOT NULL,
app_code VARCHAR(16) NOT NULL,
app_id VARCHAR(64) NOT NULL,
package_type VARCHAR(8) NOT NULL,
version_name VARCHAR(32) NOT NULL,
version_code INT UNSIGNED NOT NULL,
min_app_version_code INT UNSIGNED NOT NULL,
mandatory TINYINT(1) NOT NULL DEFAULT 0,
release_notes VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
object_key VARCHAR(128) NOT NULL,
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pda_release_version (app_code, package_type, version_name),
KEY idx_pda_release_created (app_code, created_at)
) ENGINE=InnoDB COMMENT='PDA安装包与热更新发布历史';
CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',