feat: 校验 SDK 初始化时 packageName 与平台配置的 appKey 是否匹配

- im/push/update 三个服务登录/检查更新接口新增必填参数 packageName
- 调用对应服务的 tenant-service 内部接口获取 platformInfo,与传入包名比对,不匹配返回 403
- update 服务按 platform 字段精确匹配(ANDROID/IOS/HARMONY 各用对应字段)
- im/push 服务对三端包名任一匹配即通过
- ImAppSecretClient / PushAppSecretClient 新增 getPlatformInfo 缓存方法
- 新增 UpdateTenantClient 用于 update-service 调用 tenant-service platformInfo 接口

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 16:41:17 +08:00
父节点 4c0db6e9b7
当前提交 0a267c5f70
共有 6 个文件被更改,包括 182 次插入13 次删除

查看文件

@ -1,7 +1,9 @@
package com.xuqm.im.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.ImAppSecretClient;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@ -16,20 +18,38 @@ import java.util.Map;
public class AuthController {
private final ImAccountService accountService;
private final ImAppSecretClient appSecretClient;
public AuthController(ImAccountService accountService) {
public AuthController(ImAccountService accountService, ImAppSecretClient appSecretClient) {
this.accountService = accountService;
this.appSecretClient = appSecretClient;
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam @NotBlank String appKey,
@RequestParam @NotBlank String userId,
@RequestParam @NotBlank String userSig) {
@RequestParam @NotBlank String userSig,
@RequestParam @NotBlank String packageName) {
if (userSig.isBlank()) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig"));
}
validatePackageName(appKey, packageName);
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));
}
private void validatePackageName(String appKey, String packageName) {
ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
String android = info.androidPackageName();
String ios = info.iosBundleId();
String harmony = info.harmonyBundleName();
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
if (anyConfigured) {
boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony);
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
}
}
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
}

查看文件

@ -18,8 +18,11 @@ import java.util.concurrent.ConcurrentHashMap;
@Component
public class ImAppSecretClient {
public record PlatformInfo(String androidPackageName, String iosBundleId, String harmonyBundleName) {}
private final RestTemplate restTemplate = new RestTemplate();
private final Map<String, String> cache = new ConcurrentHashMap<>();
private final Map<String, String> secretCache = new ConcurrentHashMap<>();
private final Map<String, PlatformInfo> platformInfoCache = new ConcurrentHashMap<>();
@Value("${im.tenant-service-url:http://127.0.0.1:8081}")
private String tenantServiceUrl;
@ -28,7 +31,11 @@ public class ImAppSecretClient {
private String internalToken;
public String getAppSecret(String appKey) {
return cache.computeIfAbsent(appKey, this::fetchAppSecret);
return secretCache.computeIfAbsent(appKey, this::fetchAppSecret);
}
public PlatformInfo getPlatformInfo(String appKey) {
return platformInfoCache.computeIfAbsent(appKey, this::fetchPlatformInfo);
}
private String fetchAppSecret(String appKey) {
@ -56,4 +63,25 @@ public class ImAppSecretClient {
}
throw new BusinessException(502, "Failed to resolve app secret for appKey: " + appKey);
}
private PlatformInfo fetchPlatformInfo(String appKey) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appKey}/platform-info")
.buildAndExpand(appKey)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
JsonNode data = response.getBody() == null ? null : response.getBody().path("data");
if (response.getStatusCode().is2xxSuccessful() && data != null && !data.isMissingNode()) {
return new PlatformInfo(
data.path("androidPackageName").asText(null),
data.path("iosBundleId").asText(null),
data.path("harmonyBundleName").asText(null));
}
} catch (RestClientException ignored) {}
return new PlatformInfo(null, null, null);
}
}

查看文件

@ -1,8 +1,10 @@
package com.xuqm.push.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushAppSecretClient;
import com.xuqm.push.service.PushDispatcher;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@ -18,21 +20,21 @@ public class PushAuthController {
private final PushAccountService accountService;
private final PushDispatcher pushDispatcher;
private final PushAppSecretClient appSecretClient;
public PushAuthController(PushAccountService accountService, PushDispatcher pushDispatcher) {
public PushAuthController(PushAccountService accountService, PushDispatcher pushDispatcher,
PushAppSecretClient appSecretClient) {
this.accountService = accountService;
this.pushDispatcher = pushDispatcher;
this.appSecretClient = appSecretClient;
}
/**
* Login with userSig. On success, automatically registers the device push token.
* Returns a pushToken (JWT) used to authenticate subsequent push service requests.
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam String appKey,
@RequestParam String userId,
@RequestParam String userSig,
@RequestParam String packageName,
@RequestParam DeviceTokenEntity.Vendor vendor,
@RequestParam String token,
@RequestParam(required = false) String platform,
@ -42,6 +44,7 @@ public class PushAuthController {
@RequestParam(required = false) String osVersion,
@RequestParam(required = false) String appVersion) {
validatePackageName(appKey, packageName);
PushAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
@ -53,4 +56,21 @@ public class PushAuthController {
);
return ResponseEntity.ok(ApiResponse.success(response));
}
private void validatePackageName(String appKey, String packageName) {
if (packageName == null || packageName.isBlank()) {
throw new BusinessException(403, "packageName is required");
}
PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
String android = info.androidPackageName();
String ios = info.iosBundleId();
String harmony = info.harmonyBundleName();
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
if (anyConfigured) {
boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony);
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
}
}
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
}

查看文件

@ -18,8 +18,11 @@ import java.util.concurrent.ConcurrentHashMap;
@Component
public class PushAppSecretClient {
public record PlatformInfo(String androidPackageName, String iosBundleId, String harmonyBundleName) {}
private final RestTemplate restTemplate = new RestTemplate();
private final Map<String, String> cache = new ConcurrentHashMap<>();
private final Map<String, String> secretCache = new ConcurrentHashMap<>();
private final Map<String, PlatformInfo> platformInfoCache = new ConcurrentHashMap<>();
@Value("${push.tenant-service-url:http://127.0.0.1:8081}")
private String tenantServiceUrl;
@ -28,7 +31,11 @@ public class PushAppSecretClient {
private String internalToken;
public String getAppSecret(String appKey) {
return cache.computeIfAbsent(appKey, this::fetchAppSecret);
return secretCache.computeIfAbsent(appKey, this::fetchAppSecret);
}
public PlatformInfo getPlatformInfo(String appKey) {
return platformInfoCache.computeIfAbsent(appKey, this::fetchPlatformInfo);
}
private String fetchAppSecret(String appKey) {
@ -56,4 +63,25 @@ public class PushAppSecretClient {
}
throw new BusinessException(502, "Failed to resolve app secret for appKey: " + appKey);
}
private PlatformInfo fetchPlatformInfo(String appKey) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appKey}/platform-info")
.buildAndExpand(appKey)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
JsonNode data = response.getBody() == null ? null : response.getBody().path("data");
if (response.getStatusCode().is2xxSuccessful() && data != null && !data.isMissingNode()) {
return new PlatformInfo(
data.path("androidPackageName").asText(null),
data.path("iosBundleId").asText(null),
data.path("harmonyBundleName").asText(null));
}
} catch (RestClientException ignored) {}
return new PlatformInfo(null, null, null);
}
}

查看文件

@ -21,6 +21,8 @@ import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.UpdateTenantClient;
import com.xuqm.common.exception.BusinessException;
@RestController
@RequestMapping("/api/v1/updates")
@ -33,21 +35,23 @@ public class AppVersionController {
private final PublishConfigService publishConfigService;
private final AppStoreService appStoreService;
private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient;
private final UpdateTenantClient tenantClient;
public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
AppStoreService appStoreService,
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient) {
ImPushUserClient imPushUserClient,
UpdateTenantClient tenantClient) {
this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient;
this.tenantClient = tenantClient;
}
@GetMapping("/app/check")
@ -55,8 +59,10 @@ public class AppVersionController {
@RequestParam String appKey,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode,
@RequestParam @jakarta.validation.constraints.NotBlank String packageName,
@RequestParam(required = false) String userId) {
validatePackageName(appKey, platform, packageName);
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
Optional<AppVersionEntity> latest = versionRepository
@ -489,6 +495,18 @@ public class AppVersionController {
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
}
private void validatePackageName(String appKey, AppVersionEntity.Platform platform, String packageName) {
UpdateTenantClient.PlatformInfo info = tenantClient.getPlatformInfo(appKey);
String registered = switch (platform) {
case IOS -> info.iosBundleId();
case HARMONY -> info.harmonyBundleName();
default -> info.androidPackageName();
};
if (hasText(registered) && !registered.equals(packageName)) {
throw new BusinessException(403, "包名与应用配置不匹配");
}
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}

查看文件

@ -0,0 +1,55 @@
package com.xuqm.update.service;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class UpdateTenantClient {
public record PlatformInfo(String androidPackageName, String iosBundleId, String harmonyBundleName) {}
private final RestTemplate restTemplate = new RestTemplate();
private final Map<String, PlatformInfo> cache = new ConcurrentHashMap<>();
@Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}")
private String tenantServiceUrl;
@Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken;
public PlatformInfo getPlatformInfo(String appKey) {
return cache.computeIfAbsent(appKey, this::fetchPlatformInfo);
}
private PlatformInfo fetchPlatformInfo(String appKey) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appKey}/platform-info")
.buildAndExpand(appKey)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
JsonNode data = response.getBody() == null ? null : response.getBody().path("data");
if (response.getStatusCode().is2xxSuccessful() && data != null && !data.isMissingNode()) {
return new PlatformInfo(
data.path("androidPackageName").asText(null),
data.path("iosBundleId").asText(null),
data.path("harmonyBundleName").asText(null));
}
} catch (RestClientException ignored) {}
return new PlatformInfo(null, null, null);
}
}