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

546 行
27 KiB
Java

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;
import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
2026-04-21 22:07:29 +08:00
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
2026-04-21 22:07:29 +08:00
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
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;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient;
2026-04-21 22:07:29 +08:00
@RestController
@RequestMapping("/api/v1/updates")
public class AppVersionController {
private static final Logger log = LoggerFactory.getLogger(AppVersionController.class);
2026-04-21 22:07:29 +08:00
private final AppVersionRepository versionRepository;
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final AppStoreService appStoreService;
private final UpdateOperationLogService operationLogService;
2026-04-21 22:07:29 +08:00
private final ImPushUserClient imPushUserClient;
public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
AppStoreService appStoreService,
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient) {
2026-04-21 22:07:29 +08:00
this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
this.operationLogService = operationLogService;
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,
@RequestParam int currentVersionCode,
@RequestParam(required = false) String userId) {
2026-04-21 22:07:29 +08:00
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
2026-04-21 22:07:29 +08:00
Optional<AppVersionEntity> latest = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
2026-05-07 19:39:42 +08:00
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
Optional<AppVersionEntity> forcedHigher = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
2026-05-07 19:39:42 +08:00
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
2026-04-21 22:07:29 +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();
// Gray release: userId is required when anonymous checks are disabled and version is gray-targeted.
// Non-gray published versions are visible to all callers regardless of userId.
if (v.isGrayEnabled()) {
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
if (userId != null && !userId.isBlank()) {
boolean inGray = isInGrayRelease(v, userId);
if (!inGray) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
}
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
// App explicitly requires login to check for updates even without gray targeting.
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
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);
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() : "",
"forceUpdate", forcedHigher.isPresent(),
"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,
@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,
@RequestParam(required = false) String apkUrl,
@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,
@RequestParam(defaultValue = "false") boolean publishImmediately,
@RequestParam(required = false) String packageName,
@RequestParam(required = false) String expectedPackageName,
@RequestParam(required = false) String appStoreUrl,
@RequestParam(required = false) String marketUrl) throws Exception {
2026-04-21 22:07:29 +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());
}
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);
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
}
if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) {
throw new IllegalArgumentException("packageName does not match current app packageName");
}
if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases");
}
// Duplicate version check
if (hasText(resolvedPackageName)) {
java.util.Optional<AppVersionEntity> existing = versionRepository
.findByAppKeyAndPlatformAndPackageNameAndVersionCode(
appKey, platform, resolvedPackageName, resolvedVersionCode);
if (existing.isPresent()) {
throw new IllegalArgumentException(
"版本已存在,不能重复上传:" + resolvedVersionName + " · " + resolvedVersionCode);
}
// Check if same versionCode exists with any store already live
java.util.List<AppVersionEntity> sameCodeVersions = versionRepository
.findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus(
appKey, platform, resolvedPackageName, resolvedVersionCode,
AppVersionEntity.PublishStatus.PUBLISHED);
if (!sameCodeVersions.isEmpty() && inspected != null) {
// If a published version with same code exists, compare APK md5
// This is a conservative check; we can't easily compare md5 here without downloading,
// so we just block if same versionCode was ever published
throw new IllegalArgumentException(
"应用商店已上线相同版本号,不允许重复上传:" + resolvedVersionName + " · " + resolvedVersionCode);
}
}
2026-04-21 22:07:29 +08:00
AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
2026-04-21 22:07:29 +08:00
entity.setPlatform(platform);
entity.setVersionName(resolvedVersionName);
entity.setVersionCode(resolvedVersionCode);
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());
entity.setStoreSubmitMode("MANUAL");
entity.setStoreSubmitScheduledAt(null);
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
try {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601如 2026-05-01T10:00:00");
}
}
entity.setWebhookUrl(webhookUrl);
entity.setStoreSubmitTargets(storeSubmitTargets);
entity.setAutoPublishAfterReview(autoPublishAfterReview);
entity.setPackageName(resolvedPackageName);
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)
: 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)
: null));
if (publishImmediately) {
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
}
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"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
}
@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)));
}
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile)));
}
2026-04-21 22:07:29 +08:00
@PostMapping("/app/{id}/publish")
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();
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();
AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus();
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()) {
try {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601如 2026-05-01T10:00:00");
}
}
}
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"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));
}
@PostMapping("/app/{id}/unpublish")
public ResponseEntity<ApiResponse<AppVersionEntity>> unpublish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
if (reason.isBlank()) {
throw new IllegalArgumentException("unpublish reason is required");
}
entity.setPublishStatus(AppVersionEntity.PublishStatus.DEPRECATED);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"APP_VERSION",
saved.getId(),
"UNPUBLISH",
reason,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@DeleteMapping("/app/{id}")
public ResponseEntity<ApiResponse<Void>> deleteVersion(@PathVariable String id) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
if (entity.getPublishStatus() != AppVersionEntity.PublishStatus.DRAFT) {
throw new IllegalArgumentException("已发布或已下架的版本不允许删除,请先下架");
}
// Check if any store is in active review
if (hasActiveStoreReview(entity)) {
throw new IllegalArgumentException("当前版本有厂商正在审核中,不允许删除");
}
versionRepository.delete(entity);
operationLogService.record(
entity.getAppKey(),
"APP_VERSION",
id,
"DELETE",
null,
Map.of(
"versionName", entity.getVersionName(),
"versionCode", entity.getVersionCode()
));
return ResponseEntity.ok(ApiResponse.success(null));
}
private boolean hasActiveStoreReview(AppVersionEntity entity) {
if (entity.getStoreReviewStatus() == null || entity.getStoreReviewStatus().isBlank()) {
return false;
}
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
@SuppressWarnings("unchecked")
java.util.Map<String, java.util.Map<String, Object>> reviewMap =
mapper.readValue(entity.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {});
for (java.util.Map<String, Object> info : reviewMap.values()) {
String state = info.get("state") instanceof String s ? s : "";
if ("SUBMITTING".equals(state) || "UNDER_REVIEW".equals(state)) {
return true;
}
}
} catch (Exception e) {
return false;
}
return false;
}
@PostMapping("/app/{id}/gray")
public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
@PathVariable String id,
@RequestBody Map<String, Object> body) throws Exception {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
entity.setGrayEnabled(enabled);
if (!enabled) {
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayPercent(0);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
} else {
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);
}
}
}
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"APP_VERSION",
saved.getId(),
"GRAY_UPDATE",
null,
Map.of(
"enabled", enabled,
"grayMode", saved.getGrayMode().name(),
"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
}
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;
}
}
@PatchMapping("/app/{id}/changelog")
public ResponseEntity<ApiResponse<AppVersionEntity>> updateChangeLog(
@PathVariable String id,
@RequestBody Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
String oldChangeLog = entity.getChangeLog();
Object raw = body.get("changeLog");
String newChangeLog = raw != null && !raw.toString().isBlank() ? raw.toString().trim() : null;
entity.setChangeLog(newChangeLog);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"APP_VERSION",
saved.getId(),
"CHANGELOG_UPDATE",
null,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode(),
"before", oldChangeLog != null ? oldChangeLog : "",
"after", newChangeLog != null ? newChangeLog : ""
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
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(
versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform)));
2026-04-21 22:07:29 +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";
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
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
}