mirror of
https://github.com/Weasley-J/chinaport-data-signature.git
synced 2026-10-19 16:15:47 +00:00
Merge branch 'feature_x' into feature_download_x509_certificate
This commit is contained in:
@@ -241,6 +241,7 @@ _参见[start.sh](https://github.com/julxxy/chinaport-data-signature/blob/main/s
|
||||
- 小红书
|
||||
- 广东铭鸿数据有限公司
|
||||
- 海口中伟机免投资有限公司
|
||||
- 远方好物跨境供应链(深圳)有限公司 [官网](https://www.yuanfangkuajing.com/home)
|
||||
- ...
|
||||
|
||||
---
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
# 检查并安装 sptn 命令
|
||||
source ~/.zshrc
|
||||
if command -v sptn >/dev/null 2>&1; then
|
||||
echo "检测到 sptn 命令,正在执行..."
|
||||
sptn
|
||||
if command -v setproxy >/dev/null 2>&1; then
|
||||
echo "检测到 setproxy 命令,正在执行..."
|
||||
setproxy
|
||||
else
|
||||
echo "未检测到 sptn 命令,跳过执行"
|
||||
echo "未检测到 setproxy 命令,跳过执行"
|
||||
fi
|
||||
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
echo "当前 JDK 版本: $(java -version 2>&1 | head -1)"
|
||||
|
||||
# --- 进入项目目录 ---
|
||||
cd ..
|
||||
SETTINGS="/Users/weasley/Development/program/apache-maven/conf/settings-aliyun.xml"
|
||||
SETTINGS="/Users/${USER}/Development/program/apache-maven/conf/settings-aliyun.xml"
|
||||
mvn clean package --settings $SETTINGS
|
||||
cd chinaport-data-signature-webapp
|
||||
|
||||
# --- 配置信息 ---
|
||||
TAG="1.2.0.1"
|
||||
TAG="1.2.1"
|
||||
IMAGE="weasleyj/chinaport-data-signature"
|
||||
CONTAINER_NAME="chinaport-data-signature"
|
||||
BUILDER_NAME="multiarch-builder"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>cn.alphahub.eport.signature</groupId>
|
||||
<artifactId>chinaport-data-signature-webapp</artifactId>
|
||||
<version>1.2.0</version>
|
||||
<version>1.2.1</version>
|
||||
<name>chinaport-data-signature-webapp</name>
|
||||
<url>https://github.com/julxxy/chinaport-data-signature.git</url>
|
||||
|
||||
|
||||
+16
@@ -87,4 +87,20 @@ public class EportReportResultController {
|
||||
return Result.ok(msgResult.block());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询报错信息
|
||||
*
|
||||
* @param qryId 查询时间,日期时间格式:yyyyMMddHHmmss
|
||||
* @return 报错信息
|
||||
*/
|
||||
@GetMapping("/ceb900msg")
|
||||
public Result<String> getCeb900msgResult(@RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMddHHmmss") LocalDateTime qryId) {
|
||||
qryId = ObjectUtils.getIfNull(qryId, LocalDateTime.now().minusSeconds(15));
|
||||
log.info("开始查询报错信息, qryId {}", FORMATTER.format(qryId));
|
||||
String traceId = MDC.get(TRACE_ID);
|
||||
WebClientConfiguration.HTTP_HEADERS.set(h -> h.add(TRACE_ID, traceId));
|
||||
Mono<String> msgResult = eportReportResultHttpClient.getCeb900msgResult(chinaEportProperties.getDxpId(), FORMATTER.format(qryId))
|
||||
.contextWrite(Context.of(TRACE_ID, traceId));
|
||||
return Result.ok(msgResult.block());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -179,6 +179,8 @@ public class SignHandler {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("唤醒线程异常 {}", e.getLocalizedMessage(), e);
|
||||
} finally {
|
||||
LockSupport.unpark(ref.get().getThread());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -55,4 +55,15 @@ public interface EportReportResultHttpClient {
|
||||
*/
|
||||
@GetExchange(value = "/ceb622msg", accept = {MediaType.TEXT_PLAIN_VALUE})
|
||||
Mono<String> getCe622msgResult(@RequestParam String dxpid, @RequestParam String qryid);
|
||||
|
||||
/**
|
||||
* 报错信息
|
||||
*
|
||||
* @param dxpid 向中国电子口岸数据中心申请数据交换平台的用户编号
|
||||
* @param qryid 查询时间,日期时间格式:yyyyMMddHHmmss
|
||||
* @return 报错信息
|
||||
* @apiNote 查询下 311,621 等申报回执时,如果出现报错信息,可以通过此接口查询报错信息
|
||||
*/
|
||||
@GetExchange(value = "/ceb900msg", accept = {MediaType.TEXT_PLAIN_VALUE})
|
||||
Mono<String> getCeb900msgResult(@RequestParam String dxpid, @RequestParam String qryid);
|
||||
}
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ public final class CertificateParser {
|
||||
/**
|
||||
* 根据 'xxx.cer' 证书获取 X509 证书
|
||||
*
|
||||
* @param filePath 文件名, 如:/Users/weasley/Downloads/01691fe9.cer
|
||||
* @param filePath 文件名, 如:/Users/julian/Downloads/01691fe9.cer
|
||||
* @return X.509 certificate
|
||||
*/
|
||||
@SuppressWarnings("all")
|
||||
|
||||
+22
-6
@@ -4,6 +4,7 @@ import cn.alphahub.eport.signature.config.ChinaEportProperties;
|
||||
import cn.alphahub.eport.signature.core.ChinaEportReportClient;
|
||||
import cn.alphahub.eport.signature.core.SignHandler;
|
||||
import cn.alphahub.eport.signature.core.web.EportCebMessageHttpClient;
|
||||
import cn.alphahub.eport.signature.core.web.EportReportResultHttpClient;
|
||||
import cn.alphahub.eport.signature.entity.SignRequest;
|
||||
import cn.alphahub.eport.signature.entity.SignResult;
|
||||
import cn.alphahub.eport.signature.entity.UkeyRequest;
|
||||
@@ -34,6 +35,7 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
class EportSignControllerTest {
|
||||
|
||||
@Autowired
|
||||
SignHandler signHandler;
|
||||
@Autowired
|
||||
@@ -44,6 +46,8 @@ class EportSignControllerTest {
|
||||
ChinaEportReportClient chinaEportReportClient;
|
||||
@Autowired
|
||||
EportCebMessageHttpClient cebMessageHttpClient;
|
||||
@Autowired
|
||||
EportReportResultHttpClient eportReportResultHttpClient;
|
||||
|
||||
@Test
|
||||
@DisplayName("海关XML数据加签+验正签名结果")
|
||||
@@ -151,6 +155,7 @@ class EportSignControllerTest {
|
||||
ceb311Message.getOrder().getOrderHead().setGuid(guid);
|
||||
chinaEportReportClient.buildBaseTransfer(ceb311Message.getBaseTransfer());
|
||||
MessageRequest messageRequest = chinaEportReportClient.buildMessageRequest(ceb311Message, MessageType.CEB311Message);
|
||||
|
||||
/*
|
||||
String requestServer = StringUtils.defaultIfBlank(chinaEportProperties.getServer(), Base64.decodeStr(EPORT_CEBMESSAGE_SERVER_ENCODE));
|
||||
String requestBody = JSONUtil.toJsonStr(messageRequest);
|
||||
@@ -161,22 +166,33 @@ class EportSignControllerTest {
|
||||
.execute();
|
||||
log.info("开始上报,Http响应结果: {}", httpResponse.body());
|
||||
*/
|
||||
|
||||
cebMessageHttpClient.reportCebMessage(messageRequest).subscribe(data -> {
|
||||
log.info("开始申报数据,Http响应结果: {}", data);
|
||||
});
|
||||
|
||||
// 睡8秒,开始查询回执
|
||||
// 睡8秒,开始查询回执、报错信息
|
||||
LocalDateTime uploadTime = LocalDateTime.now();
|
||||
int sleepTime = 15;
|
||||
for (int i = sleepTime; i > 0; i--) {
|
||||
String currDate = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(uploadTime);
|
||||
|
||||
for (int i = 15; i > 0; i--) {
|
||||
log.warn("开始查询推送回执,海关后台正在处理中,线程挂起剩余 {} 秒。", i);
|
||||
TimeUnit.SECONDS.sleep(1);
|
||||
}
|
||||
String url = "http://" + Base64.decodeStr("MzYuMTAxLjIwOC4yMzA=") + ":8090/ceb312msg?" + HttpUtil.toParams(new LinkedHashMap<>() {{
|
||||
|
||||
String url = "http://" + Base64.decodeStr("MzYuMTAxLjIwOC4yMzA=") + ":8090/ceb900msg?" + HttpUtil.toParams(new LinkedHashMap<>() {{
|
||||
put("dxpid", chinaEportProperties.getDxpId());
|
||||
put("qryid", DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(uploadTime));
|
||||
put("qryid", currDate);
|
||||
}});
|
||||
HttpResponse receiptResponse = HttpUtil.createGet(url).execute();
|
||||
log.info("\n{}\n回执结果: {}", url, receiptResponse.body()); //回执结果: [{org_id=1, instm=1690556389000, re_order_guid=AOF0YM-WEASLEY-20230728225932-6QVICD, returnstatus=-301014, ebpcode=4601630004, returntime2=20230728225949, ebccode=4601630004, trans_dxpid=DXPENT0000530815, returninfo=订单报文处理失败,接收报文为旧报文数据,不做处理;, returntime=1690556377000, orderno=T_C5051511332138160010}]
|
||||
log.info("\n{}\n900回执结果: {}", url, receiptResponse.body()); //
|
||||
|
||||
url = "http://" + Base64.decodeStr("MzYuMTAxLjIwOC4yMzA=") + ":8090/ceb312msg?" + HttpUtil.toParams(new LinkedHashMap<>() {{
|
||||
put("dxpid", chinaEportProperties.getDxpId());
|
||||
put("qryid", currDate);
|
||||
}});
|
||||
receiptResponse = HttpUtil.createGet(url).execute();
|
||||
log.info("\n{}\n311回执结果: {}", url, receiptResponse.body()); //回执结果: [{org_id=1, instm=1690556389000, re_order_guid=AOF0YM-WEASLEY-20230728225932-6QVICD, returnstatus=-301014, ebpcode=4601630004, returntime2=20230728225949, ebccode=4601630004, trans_dxpid=DXPENT0000530815, returninfo=订单报文处理失败,接收报文为旧报文数据,不做处理;, returntime=1690556377000, orderno=T_C5051511332138160010}]
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ class CertificateHandlerParserTest {
|
||||
|
||||
@Test
|
||||
void parseCertificateByCertPath() {
|
||||
CertificateParser.parseCertificateByCertPath("/Users/weasley/Downloads/03000000000cde6f.cer");
|
||||
CertificateParser.parseCertificateByCertPath("/Users/weasley/Downloads/01691fe9.cer");
|
||||
CertificateParser.parseCertificateByCertPath("/Users/julian/Downloads/03000000000cde6f.cer");
|
||||
CertificateParser.parseCertificateByCertPath("/Users/julian/Downloads/01691fe9.cer");
|
||||
}
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package cn.alphahub.eport.signature.core;
|
||||
|
||||
import cn.alphahub.eport.signature.entity.UkeyRequest;
|
||||
import cn.alphahub.eport.signature.entity.UkeyResponse.Args;
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* PDF 签名.
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @date 8/15/26 16:20
|
||||
*/
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
class PdfFileSignatureTests {
|
||||
|
||||
/**
|
||||
* 实际下发的 ukey 方法
|
||||
*/
|
||||
static final String PDF_ADD_SIGN_METHOD = "pdf-sign_PdfAddSign";
|
||||
/**
|
||||
* 实际下发的 ukey 方法
|
||||
*/
|
||||
static final String PDF_CHECK_SIGN_METHOD = "pdf-sign_PdfCheckSign";
|
||||
/**
|
||||
* 随附单据类型编码,按实际业务调整
|
||||
*/
|
||||
static final String EDOC_CODE = "00000001";
|
||||
/**
|
||||
* u-key默认密码8个8
|
||||
*/
|
||||
private static final String DEFAULT_PASSWORD = "88888888";
|
||||
/**
|
||||
* 根据实际情况替换
|
||||
*/
|
||||
final String PDF_PATH = "doc/sign-pdf-test.pdf";
|
||||
|
||||
@Autowired
|
||||
CertificateHandler certHandler;
|
||||
|
||||
@Autowired
|
||||
SignHandler signHandler;
|
||||
|
||||
@Test
|
||||
@DisplayName("海关PDF文件加签+验证签名结果")
|
||||
void signPdfFile() {
|
||||
Path pdfFile = resolvePdfFile();
|
||||
String destDir = pdfFile.getParent().toString();
|
||||
String fileName = pdfFile.getFileName().toString();
|
||||
String datetiem = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
|
||||
Map<String, Object> signArgsMap = new LinkedHashMap<>();
|
||||
signArgsMap.put("passwd", DEFAULT_PASSWORD);
|
||||
signArgsMap.put("filePath", destDir);
|
||||
signArgsMap.put("fileName", fileName);
|
||||
signArgsMap.put("datetiem", datetiem);
|
||||
signArgsMap.put("Edoc_code", EDOC_CODE);
|
||||
|
||||
Args signArgs = signHandler.getUkeyResponseArgs(new UkeyRequest(PDF_ADD_SIGN_METHOD, signArgsMap));
|
||||
System.err.println("PDF加签结果: " + JSONUtil.toJsonStr(signArgs));
|
||||
System.err.println("X509Certificate: " + certHandler.getX509Certificate(CertificateHandler.SING_DATA_METHOD));
|
||||
|
||||
Map<String, Object> verifyArgsMap = new LinkedHashMap<>();
|
||||
verifyArgsMap.put("filePath", pdfFile.toString());
|
||||
Args verifyArgs = signHandler.getUkeyResponseArgs(new UkeyRequest(PDF_CHECK_SIGN_METHOD, verifyArgsMap));
|
||||
System.err.println("PDF验签结果: " + JSONUtil.toJsonStr(verifyArgs));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("海关PDF Base64内容加签+验证签名结果")
|
||||
void signPdfFileContent() throws Exception {
|
||||
Path pdfFile = resolvePdfFile();
|
||||
String fileContent = Base64.encode(Files.readAllBytes(pdfFile));
|
||||
String datetiem = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
|
||||
Map<String, Object> signArgsMap = new LinkedHashMap<>();
|
||||
signArgsMap.put("fileContent", fileContent);
|
||||
signArgsMap.put("datetiem", datetiem);
|
||||
signArgsMap.put("Edoc_code", EDOC_CODE);
|
||||
signArgsMap.put("passwd", DEFAULT_PASSWORD);
|
||||
|
||||
Args signArgs = signHandler.getUkeyResponseArgs(new UkeyRequest(PDF_ADD_SIGN_METHOD, signArgsMap));
|
||||
System.err.println("PDF加签结果: " + JSONUtil.toJsonStr(signArgs));
|
||||
System.err.println("X509Certificate: " + certHandler.getX509Certificate(CertificateHandler.SING_DATA_METHOD));
|
||||
|
||||
String signedPdfBase64 = signArgs.getData().isEmpty() ? fileContent : signArgs.getData().get(0);
|
||||
Map<String, Object> verifyArgsMap = new LinkedHashMap<>();
|
||||
verifyArgsMap.put("fileContent", signedPdfBase64);
|
||||
Args verifyArgs = signHandler.getUkeyResponseArgs(new UkeyRequest(PDF_CHECK_SIGN_METHOD, verifyArgsMap));
|
||||
System.err.println("PDF验签结果: " + JSONUtil.toJsonStr(verifyArgs));
|
||||
}
|
||||
|
||||
private Path resolvePdfFile() {
|
||||
Path fromCwd = Path.of(PDF_PATH);
|
||||
if (Files.isRegularFile(fromCwd)) {
|
||||
return fromCwd.toAbsolutePath().normalize();
|
||||
}
|
||||
Path fromModuleParent = Path.of("..").resolve(PDF_PATH);
|
||||
if (Files.isRegularFile(fromModuleParent)) {
|
||||
return fromModuleParent.toAbsolutePath().normalize();
|
||||
}
|
||||
throw new IllegalStateException("未找到测试 PDF: " + PDF_PATH);
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package cn.alphahub.eport.signature.core.v121;
|
||||
|
||||
import cn.alphahub.eport.signature.base.constant.FrameworkConstant;
|
||||
import cn.alphahub.eport.signature.config.UkeyInitialConfig;
|
||||
import cn.alphahub.eport.signature.config.UkeyProperties;
|
||||
import cn.alphahub.eport.signature.core.SignatureHandler;
|
||||
import cn.alphahub.eport.signature.core.WebSocketClientHandler;
|
||||
import cn.alphahub.eport.signature.core.notify.EmailNotifyStrategy;
|
||||
import cn.alphahub.eport.signature.entity.SignRequest;
|
||||
import cn.alphahub.eport.signature.entity.SignResult;
|
||||
import cn.alphahub.eport.signature.entity.WebSocketWrapper;
|
||||
import cn.alphahub.eport.signature.support.XMLValidator;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.socket.client.WebSocketConnectionManager;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
|
||||
/**
|
||||
* 电子口岸加签业务核心类(最终优化版)
|
||||
* 核心:仅解决大消息1009错误 + 连接复用,业务逻辑与原SignHandler完全一致
|
||||
*/
|
||||
@Data
|
||||
@Slf4j
|
||||
//@Service("mySignHandler") // 唯一Bean名,避免与原SignHandler冲突
|
||||
@Validated
|
||||
@Accessors(chain = true)
|
||||
public class MySignHandler {
|
||||
// 原逻辑常量(完全复制)
|
||||
private static final long LOCK_TIMEOUT_NANOS = 1000 * 1000 * 1000 * 5L;
|
||||
|
||||
// 大消息配置(解决1009错误)
|
||||
private static final int MAX_TEXT_MESSAGE_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
@Autowired
|
||||
private UkeyProperties ukeyProperties;
|
||||
@Autowired
|
||||
private WebSocketClientHandler webSocketClientHandler; // 复用原全局Handler
|
||||
@Autowired
|
||||
private StandardWebSocketClient standardWebSocketClient;
|
||||
@Autowired
|
||||
@Qualifier(EmailNotifyStrategy.NAME)
|
||||
private EmailNotifyStrategy emailNotifyStrategy;
|
||||
|
||||
// 连接复用(仅这一个优化点)
|
||||
private WebSocketConnectionManager reuseConnManager;
|
||||
|
||||
|
||||
// -------------------- 业务方法完全复制原SignHandler --------------------
|
||||
public static String getInitData(@Valid SignRequest request) {
|
||||
if (isSignXml(request)) {
|
||||
return SignatureHandler.getSignatureNodeBeforeSend(request);
|
||||
}
|
||||
return request.getData();
|
||||
}
|
||||
|
||||
public static boolean isSignXml(@Valid SignRequest request) {
|
||||
if (StringUtils.isBlank(request.getData())) {
|
||||
throw new IllegalArgumentException("加签数据请求入参能为空!");
|
||||
}
|
||||
boolean isSignXmlString = XMLValidator.isValidXML(request.getData()) || Strings.CS.startsWith(request.getData(), "<ceb:CEB");
|
||||
boolean isSign179String = Strings.CS.startsWith(request.getData(), "\"sessionID\"");
|
||||
if (isSignXmlString && !isSign179String) {
|
||||
return true;
|
||||
}
|
||||
if (isSign179String && !isSignXmlString) {
|
||||
return false;
|
||||
}
|
||||
throw new IllegalArgumentException("加签数据请求入参不合法,请检查参数:" + request.getData());
|
||||
}
|
||||
|
||||
public String getSignDataParameter(@Valid SignRequest request) {
|
||||
return UkeyInitialConfig.getSignDataAsPEM(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心sign方法:完全复制原逻辑,仅优化连接复用
|
||||
*/
|
||||
public SignResult sign(@Valid SignRequest request, @NotBlank(message = "websocket发送的数据载荷不能为空") String payload) {
|
||||
log.info("收到u-key加签数据: {}", payload);
|
||||
|
||||
// 完全复制原逻辑:创建WebSocketWrapper
|
||||
WebSocketWrapper wsWrapper = webSocketClientHandler.getWebSocketWrapper();
|
||||
wsWrapper.setPayload(payload);
|
||||
wsWrapper.setRequest(request);
|
||||
wsWrapper.setSignResult(new SignResult());
|
||||
wsWrapper.setThreadReference(new AtomicReference<>(Thread.currentThread()));
|
||||
wsWrapper.setSessionId(MDC.get(FrameworkConstant.TRACE_ID));
|
||||
|
||||
// 完全复制原逻辑:XML加签字段赋值
|
||||
if (MySignHandler.isSignXml(request)) {
|
||||
wsWrapper.getSignResult().setDigestValue(SignatureHandler.getDigestValueOfCEBXxxMessage(request.getData()));
|
||||
wsWrapper.getSignResult().setSignatureNode(SignatureHandler.getSignatureNodeBeforeSend(request));
|
||||
}
|
||||
|
||||
// 优化点:连接复用(替代原每次new)
|
||||
if (reuseConnManager == null) {
|
||||
reuseConnManager = new WebSocketConnectionManager(
|
||||
standardWebSocketClient, webSocketClientHandler, ukeyProperties.getWsUrl()
|
||||
);
|
||||
}
|
||||
WebSocketConnectionManager wsConnManager = reuseConnManager;
|
||||
|
||||
// 完全复制原逻辑:启动连接 + 阻塞等待
|
||||
wsConnManager.start();
|
||||
try {
|
||||
LockSupport.parkNanos(wsWrapper.getThreadReference().get(), LOCK_TIMEOUT_NANOS);
|
||||
} catch (Exception e) {
|
||||
log.error("线程自动unpark异常 {}", e.getLocalizedMessage(), e);
|
||||
wsWrapper.getSignResult().setSuccess(false);
|
||||
}
|
||||
|
||||
// 完全复制原逻辑:返回结果(signatureValue由原WebSocketClientHandler赋值)
|
||||
return wsWrapper.getSignResult();
|
||||
}
|
||||
|
||||
// 销毁资源:关闭复用的连接
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (reuseConnManager != null && reuseConnManager.isRunning()) {
|
||||
reuseConnManager.stop();
|
||||
}
|
||||
log.info("MySignHandler资源已释放");
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package cn.alphahub.eport.signature.core.v121;
|
||||
|
||||
import cn.alphahub.eport.signature.config.UkeyProperties;
|
||||
import cn.alphahub.eport.signature.core.notify.EmailNotifyStrategy;
|
||||
import cn.alphahub.eport.signature.core.notify.model.EmailNotifyRecord;
|
||||
import cn.alphahub.eport.signature.entity.SignRequest;
|
||||
import cn.alphahub.eport.signature.entity.SignResult;
|
||||
import cn.alphahub.eport.signature.entity.UkeyRequest;
|
||||
import cn.alphahub.eport.signature.entity.UkeyResponse;
|
||||
import cn.alphahub.eport.signature.entity.WebSocketWrapper;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.client.WebSocketConnectionManager;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
/**
|
||||
* U-Key加签服务最终兼容版(适配所有Spring版本,移除getSession()依赖)
|
||||
*/
|
||||
@Data
|
||||
@Slf4j
|
||||
//@Service
|
||||
@Validated
|
||||
@Accessors(chain = true)
|
||||
public class UkeySignService {
|
||||
|
||||
private static final String TRACE_ID = FrameworkConstant.TRACE_ID;
|
||||
private static final long CONNECT_TIMEOUT_MS = 5000; // 连接超时
|
||||
private static final long RESPONSE_TIMEOUT_MS = 3000; // 响应超时
|
||||
private static final long HEARTBEAT_INTERVAL_SEC = 30; // 心跳间隔
|
||||
private static final long RECONNECT_RETRY_DELAY_MS = 1000; // 重连重试间隔
|
||||
private static final int RECONNECT_MAX_RETRY = 5; // 重连最大重试次数
|
||||
// 核心组件
|
||||
private final WebSocketConnectionManager wsConnManager;
|
||||
private final AtomicReference<WebSocketSession> sessionRef = new AtomicReference<>(); // 缓存Session
|
||||
private final Map<String, CompletableFuture<UkeyResponse.Args>> requestFutureMap = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
private final AtomicBoolean isConnected = new AtomicBoolean(false);
|
||||
private final Object connectLock = new Object();
|
||||
// 配置常量(建议通过配置文件注入)
|
||||
@Autowired
|
||||
private UkeyProperties ukeyProperties;
|
||||
/**
|
||||
* 电子口岸u-key加签失败通知策略
|
||||
*/
|
||||
@Autowired
|
||||
@Qualifier(EmailNotifyStrategy.NAME)
|
||||
private EmailNotifyStrategy emailNotifyStrategy;
|
||||
|
||||
// 初始化
|
||||
public UkeySignService(UkeyProperties ukeyProperties, EmailNotifyStrategy emailNotifyStrategy) {
|
||||
this.ukeyProperties = ukeyProperties;
|
||||
this.emailNotifyStrategy = emailNotifyStrategy;
|
||||
this.wsConnManager = createWebSocketManager();
|
||||
startHeartbeat(); // 启动心跳
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建WebSocket管理器(完全兼容低版本Spring)
|
||||
* 核心:自定义Handler缓存Session,替代getSession()
|
||||
*/
|
||||
private WebSocketConnectionManager createWebSocketManager() {
|
||||
// 1. 配置WebSocket客户端(设置超时)
|
||||
StandardWebSocketClient webSocketClient = new StandardWebSocketClient();
|
||||
webSocketClient.setUserProperties(Map.of(
|
||||
"javax.websocket.client.connect.timeout", CONNECT_TIMEOUT_MS,
|
||||
"org.apache.tomcat.websocket.connect.timeout", CONNECT_TIMEOUT_MS
|
||||
));
|
||||
|
||||
// 2. 自定义Handler(缓存Session + 处理消息)
|
||||
TextWebSocketHandler customHandler = new TextWebSocketHandler() {
|
||||
// 连接建立时缓存Session
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
sessionRef.set(session); // 缓存Session实例
|
||||
isConnected.set(true);
|
||||
log.info("WebSocket连接成功,sessionId:{}", session.getId());
|
||||
super.afterConnectionEstablished(session);
|
||||
}
|
||||
|
||||
// 处理响应消息
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
try {
|
||||
MDC.put(TRACE_ID, UUID.randomUUID().toString()); // 补全TRACE_ID
|
||||
String payload = message.getPayload();
|
||||
// 严格非空校验
|
||||
UkeyResponse response = JSONUtil.toBean(payload, UkeyResponse.class, true);
|
||||
if (response == null || response.get_id() == null || response.get_args() == null) {
|
||||
log.error("U-Key响应格式异常,payload:{}", payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 匹配请求并完成Future
|
||||
String requestId = response.get_id().toString();
|
||||
CompletableFuture<UkeyResponse.Args> future = requestFutureMap.remove(requestId);
|
||||
if (future == null) {
|
||||
log.warn("未找到匹配的U-Key请求,id:{}", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理结果
|
||||
UkeyResponse.Args args = response.get_args();
|
||||
if (Boolean.FALSE.equals(args.getResult())) {
|
||||
String errorMsg = Objects.requireNonNullElse(args.getError().toString(), "未知错误");
|
||||
log.error("U-Key加签失败 | id={}, error={}", requestId, errorMsg);
|
||||
sendErrorNotify(response, errorMsg);
|
||||
future.completeExceptionally(new UkeySignException(errorMsg));
|
||||
} else {
|
||||
log.info("U-Key加签成功 | id={}, data={}", requestId, args.getData());
|
||||
future.complete(args);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("处理U-Key响应异常", e);
|
||||
failAllPendingFuture(new UkeySignException("响应处理异常", e));
|
||||
} finally {
|
||||
MDC.remove(TRACE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// 传输异常处理
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
log.error("WebSocket传输异常 | sessionId={}", session.getId(), exception);
|
||||
sessionRef.set(null); // 清空失效Session
|
||||
isConnected.set(false);
|
||||
failAllPendingFuture(new UkeyConnectionException("传输异常", exception));
|
||||
super.handleTransportError(session, exception);
|
||||
}
|
||||
|
||||
// 连接关闭处理
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) throws Exception {
|
||||
log.warn("WebSocket连接关闭 | sessionId={}, status={}", session.getId(), status);
|
||||
sessionRef.set(null); // 清空失效Session
|
||||
isConnected.set(false);
|
||||
failAllPendingFuture(new UkeyConnectionException("连接关闭,status:" + status));
|
||||
super.afterConnectionClosed(session, status);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 创建连接管理器(仅使用通用方法,兼容所有版本)
|
||||
WebSocketConnectionManager manager = new WebSocketConnectionManager(
|
||||
webSocketClient, customHandler, ukeyProperties.getWsUrl()
|
||||
);
|
||||
manager.setAutoStartup(true); // 自动启动
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效WebSocketSession(替代getSession())
|
||||
*/
|
||||
private WebSocketSession getValidSession() {
|
||||
WebSocketSession session = sessionRef.get();
|
||||
// 校验Session是否有效
|
||||
if (session == null || !session.isOpen()) {
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳检测(保活+重连)
|
||||
*/
|
||||
private void startHeartbeat() {
|
||||
heartbeatExecutor.scheduleAtFixedRate(() -> {
|
||||
// 连接失效则重连
|
||||
if (!isConnected.get()) {
|
||||
reconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送心跳包
|
||||
WebSocketSession session = getValidSession();
|
||||
if (session != null) {
|
||||
try {
|
||||
UkeyHeartbeatRequest heartbeat = new UkeyHeartbeatRequest();
|
||||
heartbeat.setId(UUID.randomUUID().toString());
|
||||
session.sendMessage(new TextMessage(JSONUtil.toJsonStr(heartbeat)));
|
||||
log.debug("发送心跳包 | sessionId={}", session.getId());
|
||||
} catch (Exception e) {
|
||||
log.error("发送心跳包异常", e);
|
||||
isConnected.set(false);
|
||||
sessionRef.set(null);
|
||||
}
|
||||
} else {
|
||||
isConnected.set(false);
|
||||
}
|
||||
}, 0, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连WebSocket(加锁避免并发重连)
|
||||
*/
|
||||
private void reconnect() {
|
||||
synchronized (connectLock) {
|
||||
if (isConnected.get()) {
|
||||
return;
|
||||
}
|
||||
log.info("开始重连WebSocket...");
|
||||
|
||||
try {
|
||||
// 停止旧连接
|
||||
if (wsConnManager.isRunning()) {
|
||||
wsConnManager.stop();
|
||||
}
|
||||
// 启动新连接
|
||||
wsConnManager.start();
|
||||
|
||||
// 等待连接建立(重试机制)
|
||||
int retry = 0;
|
||||
while (!isConnected.get() && retry < RECONNECT_MAX_RETRY) {
|
||||
Thread.sleep(RECONNECT_RETRY_DELAY_MS);
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (isConnected.get()) {
|
||||
log.info("WebSocket重连成功(重试{}次)", retry);
|
||||
} else {
|
||||
log.error("WebSocket重连失败(已重试{}次)", RECONNECT_MAX_RETRY);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("WebSocket重连异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败所有待处理请求
|
||||
*/
|
||||
private void failAllPendingFuture(Throwable e) {
|
||||
if (requestFutureMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.error("批量失败待处理请求 | 原因={}", e.getMessage());
|
||||
requestFutureMap.forEach((requestId, future) -> {
|
||||
future.completeExceptionally(e);
|
||||
log.error("请求失败 | id={}", requestId, e);
|
||||
});
|
||||
requestFutureMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误通知
|
||||
*/
|
||||
private void sendErrorNotify(UkeyResponse response, String errorMsg) {
|
||||
try {
|
||||
WebSocketWrapper wrapper = new WebSocketWrapper();
|
||||
wrapper.setSessionId(MDC.get(TRACE_ID));
|
||||
wrapper.setPayload(JSONUtil.toJsonStr(response.get_args()));
|
||||
wrapper.setRequest(new SignRequest(response.get_id(), errorMsg));
|
||||
wrapper.setSignResult(new SignResult().setSuccess(false));
|
||||
|
||||
emailNotifyStrategy.notify(new EmailNotifyRecord(wrapper, response));
|
||||
} catch (Exception e) {
|
||||
log.error("发送错误通知异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- 对外核心方法 --------------------
|
||||
|
||||
/**
|
||||
* 同步获取U-Key加签结果(兼容原有调用方式)
|
||||
*/
|
||||
public UkeyResponse.Args getUkeyResponseArgs(@NonNull UkeyRequest ukeyRequest) throws Exception {
|
||||
// 1. 参数校验
|
||||
if (ukeyRequest.get_id() == null) {
|
||||
throw new IllegalArgumentException("UkeyRequest的_id不能为空");
|
||||
}
|
||||
String requestId = String.valueOf(ukeyRequest.get_id());
|
||||
|
||||
// 2. 确保连接可用
|
||||
if (!isConnected.get()) {
|
||||
reconnect();
|
||||
if (!isConnected.get()) {
|
||||
throw new UkeyConnectionException("WebSocket连接不可用");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建Future并关联
|
||||
CompletableFuture<UkeyResponse.Args> future = new CompletableFuture<>();
|
||||
requestFutureMap.put(requestId, future);
|
||||
|
||||
try {
|
||||
// 4. 获取有效Session并发送请求
|
||||
WebSocketSession session = getValidSession();
|
||||
if (session == null) {
|
||||
throw new UkeyConnectionException("WebSocket会话无效");
|
||||
}
|
||||
|
||||
String requestJson = JSONUtil.toJsonStr(ukeyRequest);
|
||||
log.info("发送U-Key请求 | id={}, payload={}", requestId, requestJson);
|
||||
session.sendMessage(new TextMessage(requestJson));
|
||||
|
||||
// 5. 等待响应(带超时)
|
||||
UkeyResponse.Args result = future.get(RESPONSE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
if (result == null) {
|
||||
throw new UkeySignException("U-Key返回空结果");
|
||||
}
|
||||
return result;
|
||||
} catch (TimeoutException e) {
|
||||
log.error("U-Key请求超时 | id={}", requestId);
|
||||
requestFutureMap.remove(requestId);
|
||||
throw new TimeoutException();
|
||||
} catch (ExecutionException e) {
|
||||
requestFutureMap.remove(requestId);
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof UkeySignException) {
|
||||
throw (UkeySignException) cause;
|
||||
} else if (cause instanceof UkeyConnectionException) {
|
||||
throw (UkeyConnectionException) cause;
|
||||
} else {
|
||||
throw new UkeySignException("加签处理失败: " + cause.getMessage(), cause);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
requestFutureMap.remove(requestId);
|
||||
throw new UkeyConnectionException("请求发送失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步获取U-Key加签结果(推荐高并发场景)
|
||||
*/
|
||||
public CompletableFuture<UkeyResponse.Args> getUkeyResponseArgsAsync(@NonNull UkeyRequest ukeyRequest) {
|
||||
// 1. 参数校验
|
||||
if (ukeyRequest.get_id() == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalArgumentException("UkeyRequest的_id不能为空"));
|
||||
}
|
||||
String requestId = ukeyRequest.get_id().toString();
|
||||
|
||||
// 2. 连接校验
|
||||
if (!isConnected.get()) {
|
||||
reconnect();
|
||||
if (!isConnected.get()) {
|
||||
return CompletableFuture.failedFuture(new UkeyConnectionException("WebSocket连接不可用"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建Future
|
||||
CompletableFuture<UkeyResponse.Args> future = new CompletableFuture<>();
|
||||
requestFutureMap.put(requestId, future);
|
||||
|
||||
// 4. 发送请求
|
||||
try {
|
||||
WebSocketSession session = getValidSession();
|
||||
if (session == null) {
|
||||
throw new UkeyConnectionException("WebSocket会话无效");
|
||||
}
|
||||
|
||||
String requestJson = JSONUtil.toJsonStr(ukeyRequest);
|
||||
log.info("异步发送U-Key请求 | id={}, payload={}", requestId, requestJson);
|
||||
session.sendMessage(new TextMessage(requestJson));
|
||||
} catch (Exception e) {
|
||||
requestFutureMap.remove(requestId);
|
||||
return CompletableFuture.failedFuture(new UkeyConnectionException("请求发送失败", e));
|
||||
}
|
||||
|
||||
// 5. 超时+异常处理
|
||||
return future.orTimeout(RESPONSE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.exceptionally(ex -> {
|
||||
requestFutureMap.remove(requestId);
|
||||
log.error("异步处理失败 | id={}", requestId, ex);
|
||||
if (ex instanceof TimeoutException) {
|
||||
try {
|
||||
throw new Exception();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else if (ex instanceof UkeySignException) {
|
||||
throw new CompletionException(ex);
|
||||
} else {
|
||||
try {
|
||||
throw new Exception();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁资源(Spring容器关闭时调用)
|
||||
*/
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
// 停止心跳线程池
|
||||
heartbeatExecutor.shutdown();
|
||||
try {
|
||||
if (!heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
heartbeatExecutor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
heartbeatExecutor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
// 停止WebSocket连接
|
||||
if (wsConnManager.isRunning()) {
|
||||
wsConnManager.stop();
|
||||
}
|
||||
|
||||
// 清空缓存
|
||||
sessionRef.set(null);
|
||||
requestFutureMap.clear();
|
||||
log.info("U-Key加签服务资源已释放");
|
||||
}
|
||||
|
||||
// -------------------- 异常定义 --------------------
|
||||
public static class UkeySignException extends Exception {
|
||||
public UkeySignException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UkeySignException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
public static class UkeyConnectionException extends Exception {
|
||||
public UkeyConnectionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UkeyConnectionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class UkeyHeartbeatRequest {
|
||||
private String id;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class FrameworkConstant {
|
||||
public static final String TRACE_ID = "TRACE_ID";
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package cn.alphahub.eport.signature.core.v121;
|
||||
|
||||
// 暂时未使用该类
|
||||
+12
@@ -1,6 +1,8 @@
|
||||
package cn.alphahub.eport.signature.core.web;
|
||||
|
||||
import cn.alphahub.eport.signature.config.ChinaEportProperties;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -34,4 +36,14 @@ class EportReportResultHttpClientTest {
|
||||
Assertions.assertNotNull(data, "621回执结果不为空");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查询 900 报错信息")
|
||||
void getCeb900msg() {
|
||||
String currDate = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
|
||||
eportReportResultHttpClient.getCeb900msgResult(chinaEportProperties.getDxpId(), currDate).subscribe(data -> {
|
||||
log.debug("查询 900 报错信息:{}", data);
|
||||
Assertions.assertNotNull(data, "311回执结果不为空");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class CommandClientTest {
|
||||
@Test
|
||||
void execute2() {
|
||||
CommandClient.getSharedInstance().execute("""
|
||||
java -jar /Users/weasley/Development/IdeaProjects/chinaport-data-signature/target/chinaport-data-signature.jar
|
||||
java -jar /Users/julian/Development/IdeaProjects/chinaport-data-signature/target/chinaport-data-signature.jar
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ else
|
||||
echo "未检测到 sptn 命令,跳过执行"
|
||||
fi
|
||||
|
||||
SETTINGS="/Users/weasley/Development/program/apache-maven/conf/settings-maven-central.xml"
|
||||
SETTINGS="/Users/$USER/Development/program/apache-maven/conf/settings-maven-central.xml"
|
||||
MODULE="chinaport-data-signature-data-model"
|
||||
|
||||
clear && mvn deploy -pl :$MODULE -am --settings $SETTINGS
|
||||
|
||||
Reference in New Issue
Block a user