比较提交

...

2 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
4e54737e72 feat: webhook shows app name; auto-withdraw superseded approved stores
- Webhook notification body shows app display name (resolved from
  tenant-service via internal API with in-memory cache) instead of appKey
- When re-uploading a package with the same versionCode, automatically
  withdraw APPROVED store entries from the older entity before submitting
  the new entity, preventing duplicate active submissions
- tenant-service /internal/sdk/apps/{appKey}/platform-info now includes
  the app 'name' field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:31:31 +08:00
XuqmGroup
3bc8a39d0f fix: return 401 (not 403) for unauthenticated requests across all services
Spring Security's default Http403ForbiddenEntryPoint was returning 403
for all auth failures. Frontend clients treat 403 as a permission error
(not an auth error), so silent loops occurred instead of proper re-login.
Adding a custom AuthenticationEntryPoint that returns 401 makes clients
handle auth failures correctly (show login page on 401).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:31:24 +08:00
共有 10 个文件被更改,包括 102 次插入1 次删除

查看文件

@ -2,6 +2,7 @@ package com.xuqm.file.config;
import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@ -43,6 +44,9 @@ public class SecurityConfig {
// Upload requires authentication // Upload requires authentication
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

查看文件

@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
@ -42,6 +43,9 @@ public class SecurityConfig {
.requestMatchers("/api/im/auth/**", "/api/im/internal/**", "/ws/**", "/actuator/**").permitAll() .requestMatchers("/api/im/auth/**", "/api/im/internal/**", "/ws/**", "/actuator/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

查看文件

@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
@ -39,6 +40,9 @@ public class SecurityConfig {
.requestMatchers("/api/license/internal/**", "/actuator/health", "/actuator/info").permitAll() .requestMatchers("/api/license/internal/**", "/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

查看文件

@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@ -30,6 +31,9 @@ public class SecurityConfig {
.requestMatchers("/api/push/internal/**", "/api/push/auth/**", "/actuator/health", "/actuator/info").permitAll() .requestMatchers("/api/push/internal/**", "/api/push/auth/**", "/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

查看文件

@ -2,6 +2,7 @@ package com.xuqm.tenant.config;
import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@ -41,6 +42,9 @@ public class SecurityConfig {
).permitAll() ).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

查看文件

@ -57,6 +57,7 @@ public class InternalSdkController {
} }
AppEntity app = provisioningService.resolveApp(appKey); AppEntity app = provisioningService.resolveApp(appKey);
return ResponseEntity.ok(ApiResponse.success(Map.of( return ResponseEntity.ok(ApiResponse.success(Map.of(
"name", app.getName() == null ? "" : app.getName(),
"androidPackageName", app.getPackageName() == null ? "" : app.getPackageName(), "androidPackageName", app.getPackageName() == null ? "" : app.getPackageName(),
"iosBundleId", app.getIosBundleId() == null ? "" : app.getIosBundleId(), "iosBundleId", app.getIosBundleId() == null ? "" : app.getIosBundleId(),
"harmonyBundleName", app.getHarmonyBundleName() == null ? "" : app.getHarmonyBundleName() "harmonyBundleName", app.getHarmonyBundleName() == null ? "" : app.getHarmonyBundleName()

查看文件

@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
@ -46,6 +47,9 @@ public class SecurityConfig {
).permitAll() ).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable); .formLogin(AbstractHttpConfigurer::disable);

查看文件

@ -35,4 +35,7 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true) @Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true)
List<AppVersionEntity> findAllWithUnderReviewStores(); List<AppVersionEntity> findAllWithUnderReviewStores();
List<AppVersionEntity> findByAppKeyAndPlatformAndVersionCodeAndIdNot(
String appKey, AppVersionEntity.Platform platform, int versionCode, String id);
} }

查看文件

@ -1,6 +1,7 @@
package com.xuqm.update.service; package com.xuqm.update.service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.entity.AppVersionEntity;
@ -11,6 +12,7 @@ import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.UpdateOperationLogService; import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -30,6 +32,13 @@ public class AppStoreService {
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private static final Set<String> ACTIVE_REVIEW_STATES = Set.of("PENDING", "SUBMITTING", "UNDER_REVIEW"); private static final Set<String> ACTIVE_REVIEW_STATES = Set.of("PENDING", "SUBMITTING", "UNDER_REVIEW");
private final HttpClient http = HttpClient.newHttpClient(); private final HttpClient http = HttpClient.newHttpClient();
private final ConcurrentMap<String, String> appNameCache = 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;
private final AppStoreConfigRepository configRepo; private final AppStoreConfigRepository configRepo;
private final AppVersionRepository versionRepo; private final AppVersionRepository versionRepo;
@ -437,6 +446,28 @@ public class AppStoreService {
} }
} }
private String resolveAppName(String appKey) {
return appNameCache.computeIfAbsent(appKey, key -> {
try {
String url = tenantServiceUrl.replaceAll("/+$", "") + "/api/internal/sdk/apps/" + key + "/platform-info";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("X-Internal-Token", internalToken)
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
JsonNode body = mapper.readTree(resp.body());
String name = body.path("data").path("name").asText("");
if (!name.isBlank()) return name;
}
} catch (Exception e) {
log.warn("Failed to resolve app name for {}: {}", key, e.getMessage());
}
return key;
});
}
private String buildWebhookBody(String notifyType, AppVersionEntity v, private String buildWebhookBody(String notifyType, AppVersionEntity v,
String storeType, AppVersionEntity.StoreReviewState state, String reason) throws Exception { String storeType, AppVersionEntity.StoreReviewState state, String reason) throws Exception {
String stateLabel = switch (state) { String stateLabel = switch (state) {
@ -449,8 +480,9 @@ public class AppStoreService {
}; };
String storeLabel = storeDisplayName(storeType); String storeLabel = storeDisplayName(storeType);
String reasonSuffix = (reason != null && !reason.isBlank()) ? "\n原因" + reason : ""; String reasonSuffix = (reason != null && !reason.isBlank()) ? "\n原因" + reason : "";
String appName = resolveAppName(v.getAppKey());
String text = String.format("【应用审核通知】\n应用%s\n版本%s%d\n渠道%s\n状态%s%s", String text = String.format("【应用审核通知】\n应用%s\n版本%s%d\n渠道%s\n状态%s%s",
v.getAppKey(), v.getVersionName(), v.getVersionCode(), appName, v.getVersionName(), v.getVersionCode(),
storeLabel, stateLabel, reasonSuffix); storeLabel, stateLabel, reasonSuffix);
return switch (notifyType.toUpperCase()) { return switch (notifyType.toUpperCase()) {

查看文件

@ -134,6 +134,8 @@ public class StoreSubmissionService {
return; return;
} }
withdrawSupersededApprovedStores(v);
AtomicInteger successCount = new AtomicInteger(); AtomicInteger successCount = new AtomicInteger();
AtomicInteger rejectedCount = new AtomicInteger(); AtomicInteger rejectedCount = new AtomicInteger();
AtomicInteger skippedCount = new AtomicInteger(); AtomicInteger skippedCount = new AtomicInteger();
@ -1142,6 +1144,45 @@ public class StoreSubmissionService {
} }
} }
/**
* When a new version entity is submitted with the same versionCode as an existing one,
* mark APPROVED stores on the old entity as WITHDRAWN (DB-only, no store API call).
* This clears the superseded record without triggering notifications.
*/
private void withdrawSupersededApprovedStores(AppVersionEntity newVersion) {
List<AppVersionEntity> superseded = versionRepo.findByAppKeyAndPlatformAndVersionCodeAndIdNot(
newVersion.getAppKey(), newVersion.getPlatform(), newVersion.getVersionCode(), newVersion.getId());
for (AppVersionEntity old : superseded) {
if (old.getStoreReviewStatus() == null || !old.getStoreReviewStatus().contains("APPROVED")) continue;
try {
@SuppressWarnings("unchecked")
Map<String, Map<String, Object>> reviewMap =
mapper.readValue(old.getStoreReviewStatus(), new TypeReference<>() {});
boolean changed = false;
for (Map.Entry<String, Map<String, Object>> entry : reviewMap.entrySet()) {
Object state = entry.getValue().get("state");
if ("APPROVED".equals(state != null ? state.toString() : "")) {
Map<String, Object> updated = new LinkedHashMap<>(entry.getValue());
updated.put("state", "WITHDRAWN");
updated.put("stage", "WITHDRAWN");
updated.put("reason", "已被新版本包替代");
updated.put("updatedAt", LocalDateTime.now().toString());
entry.setValue(updated);
changed = true;
log.info("Superseding APPROVED store {} on old version {} (versionCode={})",
entry.getKey(), old.getId(), old.getVersionCode());
}
}
if (changed) {
old.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
versionRepo.save(old);
}
} catch (Exception e) {
log.warn("Failed to supersede approved stores for old version {}: {}", old.getId(), e.getMessage());
}
}
}
private List<String> extractActiveReviewTargets(AppVersionEntity v) { private List<String> extractActiveReviewTargets(AppVersionEntity v) {
if (v.getStoreReviewStatus() == null || v.getStoreReviewStatus().isBlank()) return List.of(); if (v.getStoreReviewStatus() == null || v.getStoreReviewStatus().isBlank()) return List.of();
try { try {