From e77f64262868e54f5e236849f89d4aac9bfb584d Mon Sep 17 00:00:00 2001 From: wangli Date: Sat, 31 Jan 2026 00:07:45 +0800 Subject: [PATCH] init --- .../enums/WebSocketConnectionState.java | 28 ++ xianyu-goofish/pom.xml | 4 + .../goofish/service/BrowserService.java | 40 +- .../goofish/service/GoofishApiService.java | 2 +- .../service/GoofishPwdLoginService.java | 8 +- .../xianyu/goofish/util/CookieUtils.java | 182 +++++++++ .../xianyu/goofish/util/XianyuUtils.java | 37 ++ .../websocket/GoofishAccountWebsocket.java | 369 ++++++++++++++++++ .../server/config/ThreadPoolConfig.java | 29 ++ 9 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 xianyu-common/src/main/java/top/biwin/xinayu/common/enums/WebSocketConnectionState.java create mode 100644 xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/XianyuUtils.java create mode 100644 xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/websocket/GoofishAccountWebsocket.java create mode 100644 xianyu-server/src/main/java/top/biwin/xinayu/server/config/ThreadPoolConfig.java diff --git a/xianyu-common/src/main/java/top/biwin/xinayu/common/enums/WebSocketConnectionState.java b/xianyu-common/src/main/java/top/biwin/xinayu/common/enums/WebSocketConnectionState.java new file mode 100644 index 0000000..d31c666 --- /dev/null +++ b/xianyu-common/src/main/java/top/biwin/xinayu/common/enums/WebSocketConnectionState.java @@ -0,0 +1,28 @@ +package top.biwin.xinayu.common.enums; + +/** + * TODO + * + * @author wangli + * @since 2026-01-30 22:21 + */ +public enum WebSocketConnectionState { + INIT("init"), + DISCONNECTED("disconnected"), + CONNECTING("connecting"), + CONNECTED("connected"), + REGISTER("register"), + RECONNECTING("reconnecting"), + FAILED("failed"), + CLOSED("closed"); + + private final String value; + + WebSocketConnectionState(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/xianyu-goofish/pom.xml b/xianyu-goofish/pom.xml index 685427a..bbfe696 100644 --- a/xianyu-goofish/pom.xml +++ b/xianyu-goofish/pom.xml @@ -17,6 +17,10 @@ xianyu-core ${revision} + + org.springframework.boot + spring-boot-starter-websocket + com.microsoft.playwright playwright diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/BrowserService.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/BrowserService.java index ee794c4..93c41d0 100644 --- a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/BrowserService.java +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/BrowserService.java @@ -10,10 +10,12 @@ import com.microsoft.playwright.options.Cookie; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import top.biwin.xianyu.goofish.util.CookieUtils; import java.io.IOException; import java.nio.file.Files; @@ -27,6 +29,9 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import static top.biwin.xianyu.goofish.BrowserConstant.STEALTH_SCRIPT; +import static top.biwin.xianyu.goofish.util.CookieUtils.buildCookieStr; + /** * TODO * @@ -44,6 +49,8 @@ public class BrowserService { private String browserLocation; @Value("${browser.ua}") private String ua; + @Autowired + private GoofishApiService goofishApiService; // 为每个账号维护持久化浏览器上下文(用于Cookie刷新) private final Map persistentContexts = new ConcurrentHashMap<>(); private Playwright playwright; @@ -277,6 +284,9 @@ public class BrowserService { Path sourceUserDataPath = Paths.get(userDataDir); Path targetUserDataPath = sourceUserDataPath.resolveSibling("user_" + targetId); try { + if (Files.exists(targetUserDataPath)) { + Files.delete(targetUserDataPath); + } Files.move(sourceUserDataPath, targetUserDataPath, StandardCopyOption.ATOMIC_MOVE); log.debug("【{}-renamePersistentContext】重命名持久化浏览器数据成功,原目录: {},目标目录: {}", sourceId, sourceUserDataPath, targetUserDataPath); @@ -289,10 +299,35 @@ public class BrowserService { return true; } catch (Exception e) { log.error("【{}-renamePersistentContext】重命名持久化浏览器数据异常,原目录: {},目标目录: {},异常信息: {}", sourceId, sourceUserDataPath, targetUserDataPath, e.getMessage(), e); + try { + Files.deleteIfExists(sourceUserDataPath); + } catch (IOException ex) { + log.error("【{}-renamePersistentContext】删除原目录失败,异常信息: {}", sourceId, ex.getMessage(), ex); + } } return false; } + public String refreshGoofishAccountCookie(String goofishId, Boolean showBrowser, Double slowMo, String goofishCookieStr) { + BrowserContext browserContext = loadPersistentContext(goofishId, showBrowser, slowMo, goofishCookieStr); + addPersistentContextCookie(goofishId, goofishCookieStr); + + // TODO 刷新账号信息,并返回新的 cookie + Page page = browserContext.pages().isEmpty() ? browserContext.newPage() : browserContext.pages().get(0); + page.addInitScript(STEALTH_SCRIPT); + log.debug("【{}】正在导航至登录页... url: http://www.goofish.com/im", goofishId); + page.navigate("https://www.goofish.com/im"); + log.debug("【{}】等待页面加载,查找登录框... url: http://www.goofish.com/im", goofishId); + // 确保页面加载完成 + page.getByText("登录后可以更懂你,推荐你喜欢的商品!"); + String cookieStr = buildCookieStr(browserContext); + Long goofishUserId = goofishApiService.getUserId(goofishId, cookieStr); + if (goofishUserId > 0L) { + return cookieStr; + } + return ""; + } + public void closePersistentContext(String goofishId) { BrowserContext browserContext = persistentContexts.get(goofishId); if (Objects.isNull(browserContext)) { @@ -304,14 +339,15 @@ public class BrowserService { /** * 该方法一般是在 loadPersistentContext 函数后面调用,设置 cookie */ - public void addPersistentContextCookie(String goofishId, List cookies) { + public void addPersistentContextCookie(String goofishId, String goofishCookieStr) { BrowserContext browserContext = persistentContexts.get(goofishId); if (Objects.isNull(browserContext)) { throw new IllegalStateException("未找到" + goofishId + "的浏览器持久化数据"); } - if (CollectionUtils.isEmpty(cookies)) { + if (!StringUtils.hasText(goofishCookieStr)) { return; } + List cookies = CookieUtils.parseCookieList(goofishCookieStr); browserContext.addCookies(cookies); } diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishApiService.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishApiService.java index bacd0ee..8543388 100644 --- a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishApiService.java +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishApiService.java @@ -34,7 +34,7 @@ public class GoofishApiService { public String getAccount(String goofishId, @Nullable String cookieStr) { GoofishApi goofishApi = getApi("getAccount"); if (!StringUtils.hasText(cookieStr)) { - cookieStr = goofishAccountRepository.findByUsername(goofishId) + cookieStr = goofishAccountRepository.findById(goofishId) .orElseThrow(() -> new IllegalArgumentException("无法获取闲鱼用户名,缺少 Cookie 信息")) .getCookie(); } diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishPwdLoginService.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishPwdLoginService.java index 0925e7d..4a2b8d1 100644 --- a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishPwdLoginService.java +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/service/GoofishPwdLoginService.java @@ -131,13 +131,7 @@ public class GoofishPwdLoginService { } } - private String buildCookieStr(BrowserContext context) { - List cookies = context.cookies().stream().filter(i -> Objects.equals(i.domain, ".goofish.com")).toList(); - if (CollUtil.isEmpty(cookies)) { - return null; - } - return CookieUtils.buildCookieStr(cookies); - } + private Frame findLoginFrame(Page page, String account) { String[] selectors = { diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/CookieUtils.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/CookieUtils.java index e5e12a3..388290e 100644 --- a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/CookieUtils.java +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/CookieUtils.java @@ -1,9 +1,17 @@ package top.biwin.xianyu.goofish.util; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.microsoft.playwright.BrowserContext; import com.microsoft.playwright.options.Cookie; +import com.microsoft.playwright.options.SameSiteAttribute; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; /** * TODO @@ -15,6 +23,14 @@ public final class CookieUtils { private CookieUtils() { } + public static String buildCookieStr(BrowserContext context) { + List cookies = context.cookies().stream().filter(i -> Objects.equals(i.domain, ".goofish.com")).toList(); + if (CollUtil.isEmpty(cookies)) { + return null; + } + return buildCookieStr(cookies); + } + public static String buildCookieStr(List cookies) { if (CollUtil.isEmpty(cookies)) { return ""; @@ -25,4 +41,170 @@ public final class CookieUtils { }); return sb.substring(0, sb.length() - 1); } + + public static Map parseCookieMap(String cookiesStr) { + Map cookieMap = new HashMap<>(); + if (StrUtil.isBlank(cookiesStr)) { + return cookieMap; + } + + String[] parts = cookiesStr.split("; "); + for (String part : parts) { + if (part.contains("=")) { + String[] kv = part.split("=", 2); + cookieMap.put(kv[0].trim(), kv[1].trim()); + } + } + return cookieMap; + } + + /** + * [ { + * "name" : "mtop_partitioned_detect", + * "value" : "1", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.769793740505499E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "_m_h5_tk", + * "value" : "77506c33a992091889447f9e230f16a6_1769796621003", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.769793740505528E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "_m_h5_tk_enc", + * "value" : "c28c39dad575a9c69126cbd712cf29af", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.76979374050555E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "cna", + * "value" : "tcEDIuxw9H8CAXWw8c5fFeXx", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.804348340644934E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "_samesite_flag_", + * "value" : "true", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : -1, + * "httpOnly" : true, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "cookie2", + * "value" : "1cac0f4e3ec7d9106ba61c242c77ac3d", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : -1, + * "httpOnly" : true, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "t", + * "value" : "30d3981c3f2e9def630bbfb7732a9c0a", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.777593140747377E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "_tb_token_", + * "value" : "ad33e777ae76", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : -1, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "xlly_s", + * "value" : "1", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.77004754E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "tracknick", + * "value" : "%E5%8D%8A%E5%A4%8F%E8%90%BD%E5%9C%B0", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.801353161098281E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "unb", + * "value" : "2045669855", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.770076361098333E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "sgcookie", + * "value" : "E100I7t2q%2Bjh2Ba0MMqWIxCXbauG%2FGhYHOChQo2y8mldHquuVvn8f3oniyWiwk5xDfilIeWkgQswlKG%2FDl94ZJpfwFPe0hbEuszls7JPcNyQRLo%3D", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.801353161098246E9, + * "httpOnly" : true, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "csg", + * "value" : "267bda7a", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : -1, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * }, { + * "name" : "tfstk", + * "value" : "gofnChsUwhib7Emai0RQCCNMO329pBOWpghJ2QKzQh-svDhdz0jlbiQK9_Qyqgj9fwpdZaGkzaS7R3UQBw_BNQrvqoFARwNRMblYXQzP7QLlZMSsZw_BNVgt4-BNR7bsFCTeagRwQEYHaURrLPmwuhlezXRybPYWbQlETvuZbUT-Uv-F4NzMPh-yapSy_PYWb3Ryaf30YH5P91rDHPTW-E-csevH0w2jabfiMpxV8hcz01YHVn7ejYky0scLUaSzqbdVhGfk3gNo1BQcQC8h4Sre_Z6OYFjzmYxNn61kH6qmhH5BViYcx83WxO7A9HQmuf7Mn15pOOqmMnW1z_vNRoDXWT7A9TjLfb-dnNSMH1ntMC7VICjrKAk42Zc7SDvjFY9e5hxjrINzm2F1SzUgSxxWLFTeDP4iF7Je5Fj_SPDqgp86--C..", + * "domain" : ".goofish.com", + * "path" : "/", + * "expires" : 1.785340361E9, + * "httpOnly" : false, + * "secure" : true, + * "sameSite" : "NONE" + * } ] + * @param cookiesStr + * @return + */ + public static List parseCookieList(String cookiesStr) { + Map cookieMap = parseCookieMap(cookiesStr); + List cookies = new ArrayList<>(); + + List httpOnlyKeys = Arrays.asList("_samesite_flag_", "cookie2", "sgcookie"); + cookieMap.forEach((k, v) -> { + Cookie cookie = new Cookie(k, v); + cookie.setDomain(".goofish.com"); + cookie.setPath("/"); + cookie.setExpires(-1); + + cookie.setHttpOnly(httpOnlyKeys.contains(k)); + cookie.setSecure(true); + cookie.setSameSite(SameSiteAttribute.NONE); + cookies.add(cookie); + }); + return cookies; + } } diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/XianyuUtils.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/XianyuUtils.java new file mode 100644 index 0000000..56e80f3 --- /dev/null +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/util/XianyuUtils.java @@ -0,0 +1,37 @@ +package top.biwin.xianyu.goofish.util; + +import java.util.Random; + +/** + * TODO + * + * @author wangli + * @since 2026-01-30 22:43 + */ +public class XianyuUtils { + private XianyuUtils() { + } + + public static String generateDeviceId(String userId) { + String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + StringBuilder result = new StringBuilder(); + Random random = new Random(); + + for (int i = 0; i < 36; i++) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + result.append("-"); + } else if (i == 14) { + result.append("4"); + } else { + if (i == 19) { + int randVal = (int) (16 * random.nextDouble()); + result.append(chars.charAt((randVal & 0x3) | 0x8)); + } else { + int randVal = (int) (16 * random.nextDouble()); + result.append(chars.charAt(randVal)); + } + } + } + return result.toString() + "-" + userId; + } +} diff --git a/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/websocket/GoofishAccountWebsocket.java b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/websocket/GoofishAccountWebsocket.java new file mode 100644 index 0000000..a1c1d64 --- /dev/null +++ b/xianyu-goofish/src/main/java/top/biwin/xianyu/goofish/websocket/GoofishAccountWebsocket.java @@ -0,0 +1,369 @@ +package top.biwin.xianyu.goofish.websocket; + +import cn.hutool.core.util.StrUtil; +import com.microsoft.playwright.BrowserContext; +import jakarta.websocket.ContainerProvider; +import jakarta.websocket.WebSocketContainer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.util.StringUtils; +import org.springframework.util.concurrent.ListenableFuture; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import top.biwin.xianyu.core.entity.GoofishAccountEntity; +import top.biwin.xianyu.core.repository.GoofishAccountRepository; +import top.biwin.xianyu.goofish.service.BrowserService; +import top.biwin.xianyu.goofish.service.GoofishApiService; +import top.biwin.xianyu.goofish.util.CookieUtils; +import top.biwin.xianyu.goofish.util.XianyuUtils; +import top.biwin.xinayu.common.enums.WebSocketConnectionState; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * TODO + * + * @author wangli + * @since 2026-01-30 22:18 + */ +@Slf4j +public class GoofishAccountWebsocket extends TextWebSocketHandler { + private static final String WEBSOCKET_URL = "wss://wss-goofish.dingtalk.com/"; + private final AtomicBoolean connected = new AtomicBoolean(false); + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile WebSocketConnectionState connectionState = WebSocketConnectionState.DISCONNECTED; + private final AtomicLong lastStateChangeTime = new AtomicLong(System.currentTimeMillis()); + private final AtomicInteger connectionFailures = new AtomicInteger(0); + private static final int MAX_CONNECTION_FAILURES = 3; + private WebSocketSession webSocketSession; + private String cookiesStr; + private Long userId; + private Map cookies; + private String myId; + private String deviceId; + + private final String goofishId; + private final GoofishAccountRepository goofishAccountRepository; + private final BrowserService browserService; + private final GoofishApiService goofishApiService; + private final ExecutorService scheduledExecutor; + + public GoofishAccountWebsocket(String goofishId, + GoofishAccountRepository goofishAccountRepository, + BrowserService browserService, + GoofishApiService goofishApiService, + @Qualifier("goofishAccountWebSocketExecutor") ExecutorService scheduledExecutor) { + super(); + this.goofishId = goofishId; + this.goofishAccountRepository = goofishAccountRepository; + this.browserService = browserService; + this.goofishApiService = goofishApiService; + this.scheduledExecutor = scheduledExecutor; + } + + public void start() { + if (running.get()) { + log.warn("【{}】客户端已在运行中", goofishId); + return; + } + + running.set(true); + log.info("【{}】开始启动 GoofishAccountWebsocket...", goofishId); + + // 加载Cookie + if (!loadGoofishAccount()) { + log.error("【{}】加载闲鱼账号失败,无法启动 GoofishAccountWebsocket。", goofishId); + running.set(false); + return; + } + + // 启动WebSocket连接循环 + CompletableFuture.runAsync(this::connecting, scheduledExecutor); + } + + private boolean loadGoofishAccount() { + try { + log.info("【{}】开始加载闲鱼账号...", goofishId); + Optional accountOpt = goofishAccountRepository.findById(goofishId); + if (accountOpt.isEmpty()) { + log.error("【{}】闲鱼账号不存在", goofishId); + return false; + } + + GoofishAccountEntity account = accountOpt.get(); + this.cookiesStr = account.getCookie(); + this.userId = account.getUserId(); + + if (StrUtil.isBlank(cookiesStr)) { + log.error("【{}】Cookie值为空", goofishId); + return false; + } + + String accountName = goofishApiService.getAccount(account.getId(), cookiesStr); + if(!StringUtils.hasText(accountName)) { + // 说明 cookie 失效,尝试重新登录 + this.cookiesStr = browserService.refreshGoofishAccountCookie(account.getId(), account.getShowBrowser() == 1, 1000D, cookiesStr); + } + + // 解析Cookie + this.cookies = CookieUtils.parseCookieMap(cookiesStr); + log.info("【{}】Cookie解析完成,包含字段: {}", goofishId, cookies.keySet()); + + // 获取unb字段 + String unb = cookies.get("unb"); + if (StrUtil.isBlank(unb)) { + log.error("【{}】Cookie中缺少必需的'unb'字段", goofishId); + return false; + } + + this.myId = unb; + this.deviceId = XianyuUtils.generateDeviceId(myId); + + log.info("【{}】用户ID: {}, 设备ID: {}", goofishId, myId, deviceId); + return true; + } catch (Exception e) { + log.error("【{}】加载闲鱼账号异常: {}", goofishId, e.getMessage(), e); + return false; + } + } + + /** + * 检查账号是否启用 + */ + private boolean isAccountEnabled() { + try { + Optional accountOpt = goofishAccountRepository.findById(goofishId); + return accountOpt.isPresent() && Boolean.TRUE.equals(accountOpt.get().getEnabled()); + } catch (Exception e) { + log.error("【{}】检查账号状态失败", goofishId, e); + return false; + } + } + + /** + * 可中断的Sleep + */ + private void sleepWithInterruptCheck(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.warn("【{}】Sleep被中断", goofishId); + } + } + + /** + * 等待WebSocket连接断开 + */ + private void waitForDisconnection() { + log.info("【{}】WebSocket连接已建立,等待连接断开...", goofishId); + while (connected.get() && running.get()) { + sleepWithInterruptCheck(1000); + } + } + + + public void stop() { + if (!running.get()) { + log.warn("【{}】客户端未运行", goofishId); + return; + } + + log.info("【{}】开始停止 GoofishAccountWebsocket...", goofishId); + running.set(false); + connected.set(false); + + // 取消所有后台任务 +// cancelAllBackgroundTasks(); + + // 关闭WebSocket连接 +// closeWebSocket(); + + // 清理实例缓存 +// cleanupInstanceCaches(); + + // 从全局字典中注销实例 +// unregisterInstance(); + + log.info("【{}】GoofishAccountWebsocket 已停止", goofishId); + } + + /** + * 设置连接状态并记录日志 + * 对应Python的_set_connection_state()方法 + */ + private void setConnectionState(WebSocketConnectionState newState, String reason) { + if (connectionState != newState) { + WebSocketConnectionState oldState = connectionState; + connectionState = newState; + lastStateChangeTime.set(System.currentTimeMillis()); + + // 记录状态转换 + String stateMsg = String.format("【%s】连接状态: %s → %s", goofishId, oldState.getValue(), newState.getValue()); + if (StrUtil.isNotBlank(reason)) { + stateMsg += " (" + reason + ")"; + } + + // 根据状态严重程度选择日志级别 + switch (newState) { + case FAILED: + log.error(stateMsg); + break; + case RECONNECTING: + log.warn(stateMsg); + break; + case CONNECTED: + log.info(stateMsg); // 成功状态用info级别 + break; + default: + log.info(stateMsg); + } + } + } + + /** + * 处理连接错误 + * 对应Python的handleConnectionError()方法(隐式) + */ + private void handleConnectionError(Exception e) { + connectionFailures.incrementAndGet(); + log.error("【{}】WebSocket连接错误(失败次数: {})", goofishId, connectionFailures.get(), e); + + if (connectionFailures.get() >= MAX_CONNECTION_FAILURES) { + log.error("【{}】连接失败次数过多,停止重连", goofishId); + setConnectionState(WebSocketConnectionState.FAILED, "连接失败次数过多"); + running.set(false); + } else { + setConnectionState(WebSocketConnectionState.RECONNECTING, e.getMessage()); + } + } + + /** + * 计算重试延迟(秒) + * 对应Python的_calculate_retry_delay()方法 + */ + private int calculateRetryDelay(int failures) { + // 根据失败次数计算延迟:3秒 * 失败次数,最多30秒 + return Math.min(3 * failures, 30); + } + + /** + * 构建WebSocket请求头 + */ + private WebSocketHttpHeaders buildWebSocketHeaders() { + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); + headers.add("Accept-Encoding", "gzip, deflate, br, zstd"); + headers.add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"); + headers.add("Cache-Control", "no-cache"); + headers.add("Connection", "Upgrade"); + headers.add("Host", "wss-goofish.dingtalk.com"); + headers.add("Origin", "https://www.goofish.com"); + headers.add("Pragma", "no-cache"); + headers.add("Sec-websocket-extensions", "permessage-deflate; client_max_window_bits"); + headers.add("sec-websocket-key", "Q5ejXOphWkfkyDZTTSrU2A=="); + headers.add("sec-websocket-version", "13"); + headers.add("upgrade", "websocket"); + headers.add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"); + return headers; + } + + /** + * 创建WebSocket连接 - 重构版本,纯粹的单次连接尝试 + * 失败直接抛异常,由 connectionLoop() 统一处理重试 + */ + private void connectWebSocket() throws Exception { + log.info("【{}】开始建立WebSocket连接...", goofishId); + + // 配置WebSocket容器,设置缓冲区大小为10MB(解决1009错误:消息过大) + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + container.setDefaultMaxTextMessageBufferSize(10 * 1024 * 1024); // 10MB + container.setDefaultMaxBinaryMessageBufferSize(10 * 1024 * 1024); // 10MB + + // 使用配置好的容器创建WebSocket客户端 + WebSocketClient client = new StandardWebSocketClient(container); + + // 准备请求头 + WebSocketHttpHeaders headers = buildWebSocketHeaders(); + + try { + // 发起WebSocket握手 + ListenableFuture future = + client.doHandshake(this, headers, URI.create(WEBSOCKET_URL)); + + // 等待连接完成(超时30秒) + // 注意:由于 afterConnectionEstablished 已异步化,这个超时仅用于 WebSocket 握手本身 + this.webSocketSession = future.get(30, TimeUnit.SECONDS); + log.info("【{}】WebSocket连接建立成功", goofishId); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new Exception("WebSocket连接被中断", e); + } catch (ExecutionException e) { + throw new Exception("WebSocket连接执行失败: " + e.getMessage(), e); + } catch (TimeoutException e) { + throw new Exception("WebSocket连接超时", e); + } + } + + public void connecting() { + while (running.get()) { + try { + // 检查账号是否启用 + if (!isAccountEnabled()) { + log.info("【{}】账号已禁用,停止连接循环", goofishId); + break; + } + + // 更新连接状态 + setConnectionState(WebSocketConnectionState.INIT, "准备建立WebSocket连接"); + log.info("【{}】WebSocket目标地址: {}", goofishId, WEBSOCKET_URL); + + // 单次连接尝试 + connectWebSocket(); + + // 连接成功后,进入等待循环,直到连接断开 + waitForDisconnection(); + + log.info("【{}】WebSocket连接已断开", goofishId); + + } catch (Exception e) { + // 统一处理连接错误 + handleConnectionError(e); + } + + // 计算并执行重连延迟 + if (running.get()) { + int retryDelay = calculateRetryDelay(connectionFailures.get()); + log.info("【{}】{}秒后尝试重连...", goofishId, retryDelay); + sleepWithInterruptCheck(retryDelay * 1000L); + } + } + + log.info("【{}】WebSocket 连接循环已退出", goofishId); + } + + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + super.afterConnectionEstablished(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + super.afterConnectionClosed(session, status); + } +} diff --git a/xianyu-server/src/main/java/top/biwin/xinayu/server/config/ThreadPoolConfig.java b/xianyu-server/src/main/java/top/biwin/xinayu/server/config/ThreadPoolConfig.java new file mode 100644 index 0000000..8a3a0a2 --- /dev/null +++ b/xianyu-server/src/main/java/top/biwin/xinayu/server/config/ThreadPoolConfig.java @@ -0,0 +1,29 @@ +package top.biwin.xinayu.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * TODO + * + * @author wangli + * @since 2026-01-30 22:48 + */ +@Configuration +public class ThreadPoolConfig { + + @Bean(name = "goofishAccountWebSocketExecutor") + public ExecutorService executor() { + // 创建定时任务线程池 + return Executors.newScheduledThreadPool(5, r -> { + Thread t = new Thread(r); + t.setName("GoofishAccountWebSocket-" + t.getId()); + t.setDaemon(true); + return t; + }); + } + +}