feat(push): 测试推送支持指定设备;改进小米/VIVO 错误日志

- TestOfflineRequest 新增 deviceId 字段,可直接推送到指定设备
- PushDispatcher 新增 pushToSpecificDevice(绕过 receivePush 过滤,适合测试)
- sendTestOfflineMessage:指定设备时 targetCount=1(设备已找到),sent=false 表示厂商拒绝
- XiaomiPushProvider:21301 鉴权失败时打印 appKey + 前缀供排查
- VivoPushProvider:发送失败时打印 VIVO 返回的错误码和描述

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-26 13:21:10 +08:00
父节点 c1a96af765
当前提交 68f6145c71
共有 5 个文件被更改,包括 56 次插入7 次删除

查看文件

@ -156,11 +156,14 @@ public class PushManagementController {
PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage( PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage(
request.appKey(), request.appKey(),
request.userId(), request.userId(),
request.deviceId(),
request.title(), request.title(),
request.body(), request.body(),
request.payload()); request.payload());
opLogService.record(request.appKey(), "TEST_PUSH", "PUSH", request.userId(), String target = request.deviceId() != null && !request.deviceId().isBlank()
"" + request.userId() + " 发送测试推送", null); ? "" + request.userId() + " 的指定设备发送测试推送"
: "" + request.userId() + " 发送测试推送";
opLogService.record(request.appKey(), "TEST_PUSH", "PUSH", request.userId(), target, null);
return ResponseEntity.ok(ApiResponse.success(result)); return ResponseEntity.ok(ApiResponse.success(result));
} }
@ -179,5 +182,6 @@ public class PushManagementController {
public record ImportUserRequest(String appKey, String userId, String nickname, public record ImportUserRequest(String appKey, String userId, String nickname,
String avatar, String gender, String status) {} String avatar, String gender, String status) {}
public record TestOfflineRequest(String appKey, String userId, public record TestOfflineRequest(String appKey, String userId,
String deviceId,
String title, String body, String payload) {} String title, String body, String payload) {}
} }

查看文件

@ -112,7 +112,19 @@ public class PushDiagnosticsService {
devices.stream().map(DeviceInfo::from).toList()); devices.stream().map(DeviceInfo::from).toList());
} }
public TestPushResult sendTestOfflineMessage(String appKey, String userId, String title, String body, String payload) { public TestPushResult sendTestOfflineMessage(String appKey, String userId, String deviceId, String title, String body, String payload) {
if (deviceId != null && !deviceId.isBlank()) {
Optional<DeviceTokenEntity> device = tokenRepository.findById(deviceId)
.filter(e -> appKey.equals(e.getAppKey()) && userId.equals(e.getUserId()));
if (device.isEmpty()) {
return new TestPushResult(appKey, userId, false, 0, List.of());
}
// Device found attempt regardless of receivePush flag (this is a manual test)
DeviceInfo target = DeviceInfo.from(device.get());
boolean ok = pushDispatcher.pushToSpecificDevice(appKey, userId, deviceId, title, body, payload);
// Always include the target so frontend can distinguish "vendor rejected" from "device not found"
return new TestPushResult(appKey, userId, ok, 1, List.of(target));
}
List<DeviceInfo> targets = pushDispatcher.selectedPushTargets(appKey, userId) List<DeviceInfo> targets = pushDispatcher.selectedPushTargets(appKey, userId)
.stream() .stream()
.map(DeviceInfo::from) .map(DeviceInfo::from)

查看文件

@ -276,6 +276,24 @@ public class PushDispatcher {
recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER); recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER);
} }
public boolean pushToSpecificDevice(String appKey, String userId, String deviceEntityId, String title, String body, String payload) {
Optional<DeviceTokenEntity> device = tokenRepository.findById(deviceEntityId)
.filter(e -> appKey.equals(e.getAppKey()) && userId.equals(e.getUserId()));
if (device.isEmpty()) {
log.warn("pushToSpecificDevice: device not found id={} userId={} appKey={}", deviceEntityId, userId, appKey);
return false;
}
DeviceTokenEntity t = device.get();
PushProvider provider = providers.get(t.getVendor().name());
if (provider == null) {
log.warn("pushToSpecificDevice: no provider for vendor={}", t.getVendor());
return false;
}
boolean ok = provider.send(appKey, t.getToken(), title, body, payload, resolveOptions(appKey, payload, t.getVendor()));
log.info("Test push to {}@{} via {} deviceId={}: {}", userId, appKey, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
return ok;
}
public void setReceivePush(String appKey, String userId, String deviceId, boolean enabled) { public void setReceivePush(String appKey, String userId, String deviceId, boolean enabled) {
List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank() List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank()
? tokenRepository.findByAppKeyAndUserId(appKey, userId) ? tokenRepository.findByAppKeyAndUserId(appKey, userId)

查看文件

@ -83,8 +83,13 @@ public class VivoPushProvider implements PushProvider {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
JsonNode json = objectMapper.readTree(response.body()); JsonNode json = objectMapper.readTree(response.body());
return json.path("result").asInt(-1) == 0; int result = json.path("result").asInt(-1);
if (result != 0) {
log.warn("Vivo push rejected result={} desc={} body={}", result, json.path("desc").asText(""), response.body());
}
return result == 0;
} }
log.warn("Vivo push HTTP error status={} body={}", response.statusCode(), response.body());
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("Vivo push failed: {}", e.getMessage()); log.error("Vivo push failed: {}", e.getMessage());

查看文件

@ -48,9 +48,10 @@ public class XiaomiPushProvider implements PushProvider {
appSecret = resolveVendorConfig(appKey, "appKey", envAppSecret); appSecret = resolveVendorConfig(appKey, "appKey", envAppSecret);
} }
if (appSecret.isBlank()) { if (appSecret.isBlank()) {
log.warn("Xiaomi push not configured"); log.warn("Xiaomi push not configured: appSecret is blank for appKey={}", appKey);
return false; return false;
} }
log.debug("Xiaomi push using appSecret={}...", appSecret.length() > 8 ? appSecret.substring(0, 8) : "[short]");
Map<String, String> platformInfo = configClient.fetchPlatformInfo(appKey); Map<String, String> platformInfo = configClient.fetchPlatformInfo(appKey);
String packageName = platformInfo.getOrDefault("androidPackageName", ""); String packageName = platformInfo.getOrDefault("androidPackageName", "");
@ -95,8 +96,17 @@ public class XiaomiPushProvider implements PushProvider {
JsonNode json = mapper.readTree(response.body()); JsonNode json = mapper.readTree(response.body());
String result = json.path("result").asText(""); String result = json.path("result").asText("");
if (!"ok".equalsIgnoreCase(result)) { if (!"ok".equalsIgnoreCase(result)) {
log.warn("Xiaomi push rejected result={} description={} body={}", int code = json.path("code").asInt(0);
result, json.path("description").asText(""), response.body()); String reason = json.path("reason").asText(json.path("description").asText(""));
if (code == 21301) {
log.error("Xiaomi push auth failed (21301): AppSecret is invalid for appKey={}. " +
"Check vendor config → 小米 → AppSecret. " +
"Used appSecret prefix={}...",
appKey,
appSecret.length() > 8 ? appSecret.substring(0, 8) : "[short]");
} else {
log.warn("Xiaomi push rejected code={} reason={} body={}", code, reason, response.body());
}
return false; return false;
} }
} catch (Exception e) { } catch (Exception e) {