2026-04-21 22:07:29 +08:00
|
|
|
|
package com.xuqm.update.controller;
|
|
|
|
|
|
|
|
|
|
|
|
import com.xuqm.common.model.ApiResponse;
|
|
|
|
|
|
import com.xuqm.update.entity.AppVersionEntity;
|
|
|
|
|
|
import com.xuqm.update.repository.AppVersionRepository;
|
2026-04-29 12:33:25 +08:00
|
|
|
|
import com.xuqm.update.model.AppPackageInspectResult;
|
2026-04-30 09:49:05 +08:00
|
|
|
|
import com.xuqm.update.service.UpdateOperationLogService;
|
2026-04-29 17:35:52 +08:00
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
import org.springframework.http.ResponseEntity;
|
2026-04-24 16:16:33 +08:00
|
|
|
|
import org.springframework.web.bind.annotation.*;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
|
|
|
|
|
|
|
|
|
|
import java.time.LocalDateTime;
|
2026-04-30 11:47:01 +08:00
|
|
|
|
import java.time.format.DateTimeParseException;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
import java.util.List;
|
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
import java.util.Optional;
|
|
|
|
|
|
import java.util.UUID;
|
2026-04-28 21:05:06 +08:00
|
|
|
|
import com.xuqm.update.service.UpdateAssetService;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
import com.xuqm.update.service.PublishConfigService;
|
|
|
|
|
|
import com.xuqm.update.service.AppStoreService;
|
2026-05-14 23:40:35 +08:00
|
|
|
|
import com.xuqm.update.service.ImPushUserClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
@RestController
|
|
|
|
|
|
@RequestMapping("/api/v1/updates")
|
|
|
|
|
|
public class AppVersionController {
|
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(AppVersionController.class);
|
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
|
private final AppVersionRepository versionRepository;
|
2026-04-28 21:05:06 +08:00
|
|
|
|
private final UpdateAssetService updateAssetService;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
private final PublishConfigService publishConfigService;
|
|
|
|
|
|
private final AppStoreService appStoreService;
|
2026-04-30 09:49:05 +08:00
|
|
|
|
private final UpdateOperationLogService operationLogService;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
2026-05-14 23:40:35 +08:00
|
|
|
|
private final ImPushUserClient imPushUserClient;
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
public AppVersionController(AppVersionRepository versionRepository,
|
|
|
|
|
|
UpdateAssetService updateAssetService,
|
|
|
|
|
|
PublishConfigService publishConfigService,
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppStoreService appStoreService,
|
2026-05-14 23:40:35 +08:00
|
|
|
|
UpdateOperationLogService operationLogService,
|
|
|
|
|
|
ImPushUserClient imPushUserClient) {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
this.versionRepository = versionRepository;
|
2026-04-28 21:05:06 +08:00
|
|
|
|
this.updateAssetService = updateAssetService;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
this.publishConfigService = publishConfigService;
|
|
|
|
|
|
this.appStoreService = appStoreService;
|
2026-04-30 09:49:05 +08:00
|
|
|
|
this.operationLogService = operationLogService;
|
2026-05-14 23:40:35 +08:00
|
|
|
|
this.imPushUserClient = imPushUserClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@GetMapping("/app/check")
|
|
|
|
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
|
2026-05-07 19:39:42 +08:00
|
|
|
|
@RequestParam String appKey,
|
2026-04-21 22:07:29 +08:00
|
|
|
|
@RequestParam AppVersionEntity.Platform platform,
|
2026-05-02 22:57:55 +08:00
|
|
|
|
@RequestParam int currentVersionCode,
|
|
|
|
|
|
@RequestParam(required = false) String userId) {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
2026-05-08 12:00:33 +08:00
|
|
|
|
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
|
|
|
|
|
|
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
|
Optional<AppVersionEntity> latest = versionRepository
|
2026-05-08 18:32:00 +08:00
|
|
|
|
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
2026-05-07 19:39:42 +08:00
|
|
|
|
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
2026-04-30 09:49:05 +08:00
|
|
|
|
Optional<AppVersionEntity> forcedHigher = versionRepository
|
2026-05-08 18:32:00 +08:00
|
|
|
|
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
|
2026-05-07 19:39:42 +08:00
|
|
|
|
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
if (latest.isEmpty()) {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AppVersionEntity v = latest.get();
|
2026-05-02 22:57:55 +08:00
|
|
|
|
|
|
|
|
|
|
// Gray release filtering
|
2026-05-08 12:00:33 +08:00
|
|
|
|
if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
2026-05-14 23:40:35 +08:00
|
|
|
|
boolean inGray = isInGrayRelease(v, userId);
|
2026-05-02 22:57:55 +08:00
|
|
|
|
if (!inGray) {
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
|
|
|
|
|
|
? v.getAppStoreUrl()
|
2026-05-07 19:39:42 +08:00
|
|
|
|
: appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
String harmonyJumpUrl = hasText(v.getMarketUrl())
|
|
|
|
|
|
? v.getMarketUrl()
|
2026-05-07 19:39:42 +08:00
|
|
|
|
: appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
|
2026-04-21 22:07:29 +08:00
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
|
|
|
|
|
"needsUpdate", true,
|
|
|
|
|
|
"versionName", v.getVersionName(),
|
|
|
|
|
|
"versionCode", v.getVersionCode(),
|
|
|
|
|
|
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
|
|
|
|
|
|
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"forceUpdate", forcedHigher.isPresent(),
|
2026-04-29 19:08:13 +08:00
|
|
|
|
"appStoreUrl", appStoreJumpUrl,
|
|
|
|
|
|
"marketUrl", harmonyJumpUrl
|
2026-04-21 22:07:29 +08:00
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@PostMapping("/app/upload")
|
|
|
|
|
|
public ResponseEntity<ApiResponse<AppVersionEntity>> upload(
|
2026-05-07 19:39:42 +08:00
|
|
|
|
@RequestParam String appKey,
|
2026-04-21 22:07:29 +08:00
|
|
|
|
@RequestParam AppVersionEntity.Platform platform,
|
2026-04-29 12:33:25 +08:00
|
|
|
|
@RequestParam(required = false) String versionName,
|
|
|
|
|
|
@RequestParam(required = false) Integer versionCode,
|
2026-04-21 22:07:29 +08:00
|
|
|
|
@RequestParam(required = false) String changeLog,
|
|
|
|
|
|
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
2026-04-29 17:35:52 +08:00
|
|
|
|
@RequestParam(required = false) String apkUrl,
|
2026-04-29 00:34:17 +08:00
|
|
|
|
@RequestParam(required = false) MultipartFile apkFile,
|
|
|
|
|
|
@RequestParam(required = false) String scheduledPublishAt,
|
|
|
|
|
|
@RequestParam(required = false) String webhookUrl,
|
|
|
|
|
|
@RequestParam(required = false) String storeSubmitTargets,
|
|
|
|
|
|
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
2026-04-29 15:46:40 +08:00
|
|
|
|
@RequestParam(defaultValue = "false") boolean publishImmediately,
|
|
|
|
|
|
@RequestParam(required = false) String packageName,
|
2026-04-29 19:08:13 +08:00
|
|
|
|
@RequestParam(required = false) String expectedPackageName,
|
2026-04-29 15:46:40 +08:00
|
|
|
|
@RequestParam(required = false) String appStoreUrl,
|
|
|
|
|
|
@RequestParam(required = false) String marketUrl) throws Exception {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
|
AppPackageInspectResult inspected = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
inspected = hasText(apkUrl)
|
|
|
|
|
|
? updateAssetService.inspectAppPackage(apkUrl)
|
|
|
|
|
|
: (apkFile != null && !apkFile.isEmpty() ? updateAssetService.inspectAppPackage(apkFile) : null);
|
|
|
|
|
|
} catch (Exception ex) {
|
2026-05-07 19:39:42 +08:00
|
|
|
|
log.warn("Unable to inspect upload package for appKey={}, platform={}, source={}, fallback to manual version fields: {}",
|
|
|
|
|
|
appKey, platform, hasText(apkUrl) ? apkUrl : (apkFile != null ? apkFile.getOriginalFilename() : null), ex.getMessage());
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
2026-04-29 15:46:40 +08:00
|
|
|
|
String resolvedVersionName = hasText(versionName) ? versionName : (inspected != null ? inspected.versionName() : null);
|
|
|
|
|
|
Integer resolvedVersionCode = versionCode != null ? versionCode : (inspected != null ? inspected.versionCode() : null);
|
|
|
|
|
|
String resolvedPackageName = hasText(packageName) ? packageName : (inspected != null ? inspected.packageName() : null);
|
2026-04-29 12:33:25 +08:00
|
|
|
|
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
|
|
|
|
|
|
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) {
|
|
|
|
|
|
throw new IllegalArgumentException("packageName does not match current app packageName");
|
2026-04-29 15:46:40 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
|
|
|
|
|
|
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases");
|
2026-04-29 15:46:40 +08:00
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
|
AppVersionEntity entity = new AppVersionEntity();
|
|
|
|
|
|
entity.setId(UUID.randomUUID().toString());
|
2026-05-08 18:32:00 +08:00
|
|
|
|
entity.setAppKey(appKey);
|
2026-04-21 22:07:29 +08:00
|
|
|
|
entity.setPlatform(platform);
|
2026-04-29 12:33:25 +08:00
|
|
|
|
entity.setVersionName(resolvedVersionName);
|
|
|
|
|
|
entity.setVersionCode(resolvedVersionCode);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID
|
|
|
|
|
|
? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))
|
|
|
|
|
|
: null);
|
2026-04-21 22:07:29 +08:00
|
|
|
|
entity.setChangeLog(changeLog);
|
|
|
|
|
|
entity.setForceUpdate(forceUpdate);
|
|
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
|
|
|
|
|
entity.setCreatedAt(LocalDateTime.now());
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setStoreSubmitMode("MANUAL");
|
|
|
|
|
|
entity.setStoreSubmitScheduledAt(null);
|
2026-05-14 23:40:35 +08:00
|
|
|
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setGrayMemberIds(null);
|
2026-04-29 00:34:17 +08:00
|
|
|
|
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
try {
|
|
|
|
|
|
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
|
|
|
|
|
} catch (DateTimeParseException e) {
|
|
|
|
|
|
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)");
|
|
|
|
|
|
}
|
2026-04-29 00:34:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
entity.setWebhookUrl(webhookUrl);
|
|
|
|
|
|
entity.setStoreSubmitTargets(storeSubmitTargets);
|
|
|
|
|
|
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
2026-04-29 12:33:25 +08:00
|
|
|
|
entity.setPackageName(resolvedPackageName);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setAppStoreUrl(hasText(appStoreUrl)
|
|
|
|
|
|
? appStoreUrl
|
|
|
|
|
|
: (platform == AppVersionEntity.Platform.IOS
|
2026-05-07 19:39:42 +08:00
|
|
|
|
? appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
: null));
|
|
|
|
|
|
entity.setMarketUrl(hasText(marketUrl)
|
|
|
|
|
|
? marketUrl
|
|
|
|
|
|
: (platform == AppVersionEntity.Platform.HARMONY
|
2026-05-07 19:39:42 +08:00
|
|
|
|
? appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
: null));
|
2026-04-29 15:46:40 +08:00
|
|
|
|
if (publishImmediately) {
|
|
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
|
|
|
|
|
entity.setGrayEnabled(false);
|
|
|
|
|
|
entity.setGrayPercent(0);
|
|
|
|
|
|
}
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppVersionEntity saved = versionRepository.save(entity);
|
|
|
|
|
|
operationLogService.record(
|
2026-05-08 18:32:00 +08:00
|
|
|
|
saved.getAppKey(),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"APP_VERSION",
|
|
|
|
|
|
saved.getId(),
|
|
|
|
|
|
"UPLOAD",
|
|
|
|
|
|
null,
|
|
|
|
|
|
Map.of(
|
|
|
|
|
|
"platform", saved.getPlatform().name(),
|
|
|
|
|
|
"versionName", saved.getVersionName(),
|
|
|
|
|
|
"versionCode", saved.getVersionCode(),
|
|
|
|
|
|
"publishImmediately", publishImmediately,
|
|
|
|
|
|
"forceUpdate", saved.isForceUpdate(),
|
|
|
|
|
|
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
|
|
|
|
|
|
));
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
|
@RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST})
|
|
|
|
|
|
public ResponseEntity<ApiResponse<AppPackageInspectResult>> inspect(
|
|
|
|
|
|
@RequestParam(required = false) String apkUrl,
|
|
|
|
|
|
@RequestParam(required = false) MultipartFile apkFile) throws Exception {
|
|
|
|
|
|
if (hasText(apkUrl)) {
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkUrl)));
|
|
|
|
|
|
}
|
2026-04-29 12:33:25 +08:00
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
|
@PostMapping("/app/{id}/publish")
|
2026-04-29 19:08:13 +08:00
|
|
|
|
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(
|
|
|
|
|
|
@PathVariable String id,
|
|
|
|
|
|
@RequestBody(required = false) Map<String, Object> body) {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
2026-04-29 19:08:13 +08:00
|
|
|
|
boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately"));
|
|
|
|
|
|
String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null
|
|
|
|
|
|
? body.get("scheduledPublishAt").toString() : null;
|
|
|
|
|
|
boolean forceUpdate = body != null && body.get("forceUpdate") != null
|
|
|
|
|
|
? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate();
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus();
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setForceUpdate(forceUpdate);
|
|
|
|
|
|
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
|
|
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
|
|
|
|
|
entity.setScheduledPublishAt(null);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
|
|
|
|
|
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
try {
|
|
|
|
|
|
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
|
|
|
|
|
} catch (DateTimeParseException e) {
|
|
|
|
|
|
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)");
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-24 16:16:33 +08:00
|
|
|
|
entity.setGrayEnabled(false);
|
|
|
|
|
|
entity.setGrayPercent(0);
|
2026-05-14 23:40:35 +08:00
|
|
|
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setGrayMemberIds(null);
|
2026-05-14 23:40:35 +08:00
|
|
|
|
entity.setGrayCallbackUrl(null);
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppVersionEntity saved = versionRepository.save(entity);
|
|
|
|
|
|
operationLogService.record(
|
2026-05-08 18:32:00 +08:00
|
|
|
|
saved.getAppKey(),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"APP_VERSION",
|
|
|
|
|
|
saved.getId(),
|
|
|
|
|
|
publishAction(previousStatus, saved.getPublishStatus(), publishImmediately),
|
|
|
|
|
|
null,
|
|
|
|
|
|
Map.of(
|
|
|
|
|
|
"versionName", saved.getVersionName(),
|
|
|
|
|
|
"versionCode", saved.getVersionCode(),
|
|
|
|
|
|
"publishImmediately", publishImmediately,
|
|
|
|
|
|
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(),
|
|
|
|
|
|
"forceUpdate", saved.isForceUpdate()
|
|
|
|
|
|
));
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
2026-04-24 16:16:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@PostMapping("/app/{id}/unpublish")
|
2026-04-30 09:49:05 +08:00
|
|
|
|
public ResponseEntity<ApiResponse<AppVersionEntity>> unpublish(
|
|
|
|
|
|
@PathVariable String id,
|
|
|
|
|
|
@RequestBody(required = false) Map<String, Object> body) {
|
2026-04-24 16:16:33 +08:00
|
|
|
|
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
2026-04-30 09:49:05 +08:00
|
|
|
|
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
|
|
|
|
|
|
if (reason.isBlank()) {
|
|
|
|
|
|
throw new IllegalArgumentException("unpublish reason is required");
|
|
|
|
|
|
}
|
2026-04-24 16:16:33 +08:00
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DEPRECATED);
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppVersionEntity saved = versionRepository.save(entity);
|
|
|
|
|
|
operationLogService.record(
|
2026-05-08 18:32:00 +08:00
|
|
|
|
saved.getAppKey(),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"APP_VERSION",
|
|
|
|
|
|
saved.getId(),
|
|
|
|
|
|
"UNPUBLISH",
|
|
|
|
|
|
reason,
|
|
|
|
|
|
Map.of(
|
|
|
|
|
|
"versionName", saved.getVersionName(),
|
|
|
|
|
|
"versionCode", saved.getVersionCode()
|
|
|
|
|
|
));
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
2026-04-24 16:16:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@PostMapping("/app/{id}/gray")
|
|
|
|
|
|
public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
|
2026-05-08 12:00:33 +08:00
|
|
|
|
@PathVariable String id,
|
|
|
|
|
|
@RequestBody Map<String, Object> body) throws Exception {
|
2026-04-24 16:16:33 +08:00
|
|
|
|
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
2026-05-08 18:32:00 +08:00
|
|
|
|
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) {
|
2026-05-08 12:00:33 +08:00
|
|
|
|
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
|
|
|
|
|
entity.setGrayEnabled(enabled);
|
|
|
|
|
|
if (!enabled) {
|
2026-05-14 23:40:35 +08:00
|
|
|
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
entity.setGrayPercent(0);
|
|
|
|
|
|
entity.setGrayMemberIds(null);
|
2026-05-14 23:40:35 +08:00
|
|
|
|
entity.setGrayCallbackUrl(null);
|
2026-04-29 19:08:13 +08:00
|
|
|
|
} else {
|
2026-05-14 23:40:35 +08:00
|
|
|
|
AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
|
|
|
|
|
|
entity.setGrayMode(grayMode);
|
|
|
|
|
|
switch (grayMode) {
|
|
|
|
|
|
case PERCENT -> {
|
|
|
|
|
|
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
|
|
|
|
|
|
entity.setGrayMemberIds(null);
|
|
|
|
|
|
entity.setGrayCallbackUrl(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
case IM_PUSH_USERS -> {
|
|
|
|
|
|
entity.setGrayPercent(0);
|
|
|
|
|
|
entity.setGrayMemberIds(null);
|
|
|
|
|
|
entity.setGrayCallbackUrl(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
case CUSTOMER_SYNC -> {
|
|
|
|
|
|
List<String> memberIds = extractMemberIds(body.get("memberIds"));
|
|
|
|
|
|
if (memberIds.isEmpty()) {
|
|
|
|
|
|
memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey());
|
|
|
|
|
|
}
|
|
|
|
|
|
entity.setGrayMemberIds(toJson(memberIds));
|
|
|
|
|
|
entity.setGrayPercent(0);
|
|
|
|
|
|
entity.setGrayCallbackUrl(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
case CUSTOMER_CALLBACK -> {
|
|
|
|
|
|
String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null;
|
|
|
|
|
|
if (callbackUrl == null || callbackUrl.isBlank()) {
|
|
|
|
|
|
throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode");
|
|
|
|
|
|
}
|
|
|
|
|
|
entity.setGrayCallbackUrl(callbackUrl);
|
|
|
|
|
|
entity.setGrayMemberIds(null);
|
|
|
|
|
|
entity.setGrayPercent(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
}
|
2026-04-24 16:16:33 +08:00
|
|
|
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
2026-04-30 09:49:05 +08:00
|
|
|
|
AppVersionEntity saved = versionRepository.save(entity);
|
|
|
|
|
|
operationLogService.record(
|
2026-05-08 18:32:00 +08:00
|
|
|
|
saved.getAppKey(),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"APP_VERSION",
|
|
|
|
|
|
saved.getId(),
|
|
|
|
|
|
"GRAY_UPDATE",
|
|
|
|
|
|
null,
|
|
|
|
|
|
Map.of(
|
|
|
|
|
|
"enabled", enabled,
|
2026-05-14 23:40:35 +08:00
|
|
|
|
"grayMode", saved.getGrayMode().name(),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
"grayPercent", saved.getGrayPercent(),
|
|
|
|
|
|
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
|
|
|
|
|
|
));
|
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 23:40:35 +08:00
|
|
|
|
private AppVersionEntity.GrayMode parseGrayMode(Object raw) {
|
|
|
|
|
|
if (raw == null) {
|
|
|
|
|
|
return AppVersionEntity.GrayMode.PERCENT;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return AppVersionEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase());
|
|
|
|
|
|
} catch (IllegalArgumentException e) {
|
|
|
|
|
|
return AppVersionEntity.GrayMode.PERCENT;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private boolean isInGrayRelease(AppVersionEntity v, String userId) {
|
|
|
|
|
|
return switch (v.getGrayMode()) {
|
|
|
|
|
|
case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent();
|
|
|
|
|
|
case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId);
|
|
|
|
|
|
case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId);
|
|
|
|
|
|
case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private boolean resolveCallbackGray(AppVersionEntity v, String userId) {
|
|
|
|
|
|
if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
List<String> memberIds = publishConfigService.resolveGrayMembersFromUrl(
|
|
|
|
|
|
v.getGrayCallbackUrl(), v.getAppKey(), userId);
|
|
|
|
|
|
return memberIds.contains(userId);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage());
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 22:07:29 +08:00
|
|
|
|
@GetMapping("/app/list")
|
|
|
|
|
|
public ResponseEntity<ApiResponse<List<AppVersionEntity>>> list(
|
2026-05-07 19:39:42 +08:00
|
|
|
|
@RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) {
|
2026-04-21 22:07:29 +08:00
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(
|
2026-05-08 18:32:00 +08:00
|
|
|
|
versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform)));
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
2026-04-29 12:33:25 +08:00
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
private String publishAction(AppVersionEntity.PublishStatus previousStatus,
|
|
|
|
|
|
AppVersionEntity.PublishStatus currentStatus,
|
|
|
|
|
|
boolean publishImmediately) {
|
|
|
|
|
|
if (!publishImmediately) {
|
|
|
|
|
|
return "SCHEDULE_PUBLISH";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (previousStatus == AppVersionEntity.PublishStatus.PUBLISHED) {
|
|
|
|
|
|
return "UPDATE_FORCE";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (previousStatus == AppVersionEntity.PublishStatus.DEPRECATED) {
|
|
|
|
|
|
return "REPUBLISH";
|
|
|
|
|
|
}
|
|
|
|
|
|
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:33:25 +08:00
|
|
|
|
private boolean hasText(String value) {
|
|
|
|
|
|
return value != null && !value.isBlank();
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
private List<String> extractMemberIds(Object raw) {
|
|
|
|
|
|
if (raw == null) {
|
|
|
|
|
|
return List.of();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw instanceof List<?> list) {
|
|
|
|
|
|
return list.stream()
|
|
|
|
|
|
.map(String::valueOf)
|
|
|
|
|
|
.filter(this::hasText)
|
|
|
|
|
|
.map(String::trim)
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw instanceof String s) {
|
|
|
|
|
|
if (!hasText(s)) {
|
|
|
|
|
|
return List.of();
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return java.util.Arrays.stream(s.split(","))
|
|
|
|
|
|
.map(String::trim)
|
|
|
|
|
|
.filter(this::hasText)
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
|
return List.of();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return List.of();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private String toJson(List<String> values) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
return "[]";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|