fix(hjc): 短信验证码发送成功却报「服务暂不可用」——data 不是对象时解析就炸

CoreResult.of 用 json.getJSONObject("data") 取 data。fastjson 见到 String 值会
当成 JSON 再解析一次,而核心实例 ApiResult.data 是 Object,/sendSmsCaptcha 成功
时给的就是阿里云返回的字符串 "OK"(core 的 MainController 是
success("发送成功", result.get("Message"))),解析 "OK" 抛 JSONException。

该异常发生在 of() 内部,不在那个只包住 parseObject(raw) 的 try 里,于是逃到
execute() 的兜底 catch,被改写成 code=-1「认证服务暂不可用,请稍后重试」。
实际后果是短信已经发出去了,用户和前端看到的却是发送失败:核心实例侧验证码已
写入 Redis、60 秒重发限制与当日 10 条计数均已消耗,用户一重试就撞上
「发送过于频繁」,看起来更像坏了。login/register/captcha 的 data 都是对象,
所以只有发送短信这一条路坏。

- data 改为只接受对象,取不到即 null,绝不抛异常(toObjectData 保留 Map 分支,
  兼容 fastjson 桥接层给出的普通 Map);调用方本就只按 code 判成败,明细读 raw
- 新增 HjcCoreAuthClientResultTest:用日志里的真实响应体走完整调用链
  (sendSmsCaptcha → post → execute → of,只打桩 doExecute 出口)。
  改前该用例复现出逐字相同的异常(栈同在 of(HjcCoreAuthClient.java:93)),改后绿
- 用例同时锁住:data 为对象(登录/验证码)、业务失败无 data 键、data 为数字/数组、
  响应根本不是 JSON(网关 HTML)四种情形
- application.yml:hjc.one-stop.base-url 由空串改为 http://180.141.88.21:8810
  (base-url 为空时一站式推送不会发起,此前上线一直缺这项配置)
- 未改动核心实例代码,改动面限于 hjc 包与上述配置
This commit is contained in:
2026-09-11 01:07:34 +08:00
parent 27dbea9b6f
commit da1f9a64d4
3 changed files with 154 additions and 3 deletions
@@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Map;
/**
* 汇吉采登录认证对核心实例的调用出口。
@@ -64,7 +65,7 @@ public class HjcCoreAuthClient {
private int code;
private String message;
private String error;
/** 成功时的 data(核心实例的 LoginResult / CaptchaResult */
/** 成功时的 data(核心实例的 LoginResult / CaptchaResult;不是对象时留空,明细看 {@link #raw} */
private JSONObject data;
/** 原始响应体,仅用于日志与异常排查 */
private String raw;
@@ -90,10 +91,33 @@ public class HjcCoreAuthClient {
r.code = json.getIntValue("code");
r.message = json.getString("message");
r.error = json.getString("error");
r.data = json.getJSONObject("data");
r.data = toObjectData(json.get("data"));
r.ok = r.code == 0;
return r;
}
/**
* 把 {@code data} 取成 JSONObject<b>取不到就是 null,绝不抛异常</b>。
*
* <p>不能用 {@code json.getJSONObject("data")}fastjson 见到 String 会当成 JSON 再解析一次,
* 而核心实例的 {@code data} 是 {@code Object}{@code /sendSmsCaptcha} 成功时给的就是阿里云的
* 字符串 {@code "OK"}core 里是 {@code success("发送成功", result.get("Message"))})。
* 解析 {@code "OK"} 抛的 {@code JSONException} 会一路逃到 {@link #execute} 的兜底 catch
* 被改写成「认证服务暂不可用,请稍后重试」——<b>短信其实已经发出去了,用户却看到发送失败</b>,
* 核心实例那侧 60 秒重发限制和当日计数还已经消耗掉了。</p>
*
* <p>调用方一律按 {@code code} 判成败;{@code data} 不是对象时不需要它的内容,要明细读 {@code raw}。</p>
*/
private static JSONObject toObjectData(Object value) {
if (value instanceof JSONObject) {
return (JSONObject) value;
}
// fastjson 解析出来的嵌套对象可能只是普通 Map(不是 com.alibaba.fastjson.JSONObject 的实例)
if (value instanceof Map) {
return new JSONObject((Map<String, Object>) value);
}
return null;
}
}
/**
+1 -1
View File
@@ -200,5 +200,5 @@ hjc:
tenant-id: 10626
one-stop:
# 一站式平台地址(+ path = 最终推送地址)。base-url 为空时推送不会发起,部署时需配置。
base-url: ""
base-url: "http://180.141.88.21:8810"
create-purchase-details-path: /api/biz/createPurchaseDetails
@@ -0,0 +1,127 @@
package com.gxwebsoft.hjc.auth;
import cn.hutool.http.HttpRequest;
import com.gxwebsoft.common.core.config.ConfigProperties;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 核心实例响应解析:{@code data} <b>不一定是对象</b>。
*
* <p>核心实例的 {@code ApiResult.data} 是 {@code Object},各接口给什么类型都有可能。
* {@code /sendSmsCaptcha} 成功时 {@code data} 是阿里云返回的字符串 {@code "OK"}
* core {@code MainController.sendSmsCaptchaInternal} 里是
* {@code success("发送成功", result.get("Message"))})。</p>
*
* <p>此前 {@code CoreResult.of} 无条件调 {@code json.getJSONObject("data")}fastjson 见到
* 字符串会当成 JSON 去解析,解析 {@code "OK"} 直接抛 {@code JSONException}。异常逃出
* {@code of()} 后被 {@code execute()} 的兜底 catch 吞掉,对外变成
* 「认证服务暂不可用,请稍后重试」——<b>短信其实已经发出去了,用户却看到发送失败</b>,
* 而且核心实例那侧的重发限制(60 秒)和当日计数已经消耗掉。所以这里锁死:
* {@code data} 不是对象也必须按 {@code code} 判定成功。</p>
*/
class HjcCoreAuthClientResultTest {
/** 线上抓到的真实响应体(2026-09-11 00:29:23.708 日志原文) */
static final String SMS_OK = "{\"code\":0,\"message\":\"发送成功\",\"data\":\"OK\"}";
/** 走真实调用链(sendSmsCaptcha → post → execute → CoreResult.of),只打桩 HTTP 出口 */
@Test
void 短信发送成功时data是字符串OK也必须判成功() {
HjcCoreAuthClient.CoreResult r = clientReturning(SMS_OK).sendSmsCaptcha("13800000000");
assertTrue(r.isOk(), "核心实例已返回 code=0「发送成功」,不能因为 data 是字符串就判为失败;实际 code="
+ r.getCode() + " message=" + r.getMessage() + " error=" + r.getError());
assertEquals(0, r.getCode());
assertEquals("发送成功", r.getMessage());
assertEquals(SMS_OK, r.getRaw(), "原始响应体必须保留,否则日志里看不到真相");
}
/** data 是对象时照样要能取到(登录 / 图形验证码走的是这条路) */
@Test
void data是对象时正常解析() {
HjcCoreAuthClient.CoreResult r = clientReturning(
"{\"code\":0,\"message\":\"登录成功\",\"data\":{\"access_token\":\"t\",\"user\":{\"userId\":7}}}")
.login("企业A", "pwd", "1234");
assertTrue(r.isOk());
assertNotNull(r.getData(), "data 是对象时必须解析出来");
assertEquals(7, r.getData().getJSONObject("user").getIntValue("userId"));
}
/** 业务失败时核心实例不给 dataApiResult 标了 NON_NULLdata 键直接不出现) */
@Test
void 业务失败没有data字段时按message透传() {
HjcCoreAuthClient.CoreResult r = clientReturning(
"{\"code\":1,\"message\":\"发送过于频繁,请 60 秒后再试\"}").sendSmsCaptcha("13800000000");
assertFalse(r.isOk());
assertEquals(1, r.getCode());
assertEquals("发送过于频繁,请 60 秒后再试", r.getMessage());
assertNull(r.getData());
}
/** data 是数字 / 数组这类非对象 JSON 值时也不能炸 */
@Test
void data是其他非对象类型时不抛异常() {
HjcCoreAuthClient.CoreResult number = clientReturning("{\"code\":0,\"message\":\"ok\",\"data\":123}")
.captcha();
assertTrue(number.isOk(), "data=123 不该让整个调用失败:" + number.getError());
assertNull(number.getData(), "非对象 data 留空,明细看 raw");
HjcCoreAuthClient.CoreResult array = clientReturning("{\"code\":0,\"message\":\"ok\",\"data\":[1,2]}")
.captcha();
assertTrue(array.isOk(), "data=[1,2] 不该让整个调用失败:" + array.getError());
assertNull(array.getData());
}
/** 响应根本不是 JSON(网关 502 / HTML 错误页)时给既有文案,不把解析异常漏出去 */
@Test
void 响应不是JSON时给认证服务异常文案() {
HjcCoreAuthClient.CoreResult r = clientReturning("<html>502 Bad Gateway</html>").sendSmsCaptcha("13800000000");
assertFalse(r.isOk());
assertEquals(-1, r.getCode());
assertEquals("认证服务返回异常", r.getMessage());
}
private static HjcCoreAuthClient clientReturning(String raw) {
HjcCoreAuthClient client = new HjcCoreAuthClient() {
@Override
protected String doExecute(HttpRequest request, Integer tenantId, String path) {
return raw;
}
};
ConfigProperties configProperties = new ConfigProperties();
configProperties.setServerUrl("https://server.websoft.top/api");
set(client, "configProperties", configProperties);
HjcAuthProperties authProperties = new HjcAuthProperties();
set(authProperties, "tenantId", 10626);
set(client, "hjcAuthProperties", authProperties);
return client;
}
private static void set(Object target, String field, Object value) {
Class<?> type = target.getClass();
while (type != null) {
try {
Field f = type.getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
return;
} catch (NoSuchFieldException e) {
type = type.getSuperclass();
} catch (IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
throw new IllegalStateException("找不到字段 " + field);
}
}