From 7ee28f755cb83c0b6d8d17f6bd7d29933ed7a6c4 Mon Sep 17 00:00:00 2001 From: wangli Date: Sat, 17 Jan 2026 16:13:17 +0800 Subject: [PATCH] init --- .../autoreply/DataInitializerLogger.java | 64 +++ .../autoreply/config/DataSourceConfig.java | 42 ++ .../controller/CookieController.java | 68 +++- .../autoreply/service/BrowserService.java | 251 +++++++++--- .../autoreply/service/QrLoginService.java | 363 ++++++++++++++++-- .../autoreply/service/XianyuClient.java | 136 +++++-- .../src/main/resources/application.yml | 11 +- backend-java/src/main/resources/data.sql | 18 + 8 files changed, 793 insertions(+), 160 deletions(-) create mode 100644 backend-java/src/main/java/com/xianyu/autoreply/DataInitializerLogger.java create mode 100644 backend-java/src/main/java/com/xianyu/autoreply/config/DataSourceConfig.java create mode 100644 backend-java/src/main/resources/data.sql diff --git a/backend-java/src/main/java/com/xianyu/autoreply/DataInitializerLogger.java b/backend-java/src/main/java/com/xianyu/autoreply/DataInitializerLogger.java new file mode 100644 index 0000000..d83a765 --- /dev/null +++ b/backend-java/src/main/java/com/xianyu/autoreply/DataInitializerLogger.java @@ -0,0 +1,64 @@ +package com.xianyu.autoreply; + +import com.xianyu.autoreply.entity.SystemSetting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +@Component +public class DataInitializerLogger implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(DataInitializerLogger.class); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ResourceLoader resourceLoader; + + @Override + public void run(ApplicationArguments args) throws Exception { + // 检查初始化标志 + List systemSettings = jdbcTemplate.query("SELECT `key`, `value` FROM system_settings WHERE `key` = 'init_system'", new BeanPropertyRowMapper<>(SystemSetting.class)); + SystemSetting systemSetting = systemSettings.isEmpty() ? null : systemSettings.get(0); + + if (Objects.isNull(systemSetting)) { + // 如果没有初始化过,则执行data.sql中的脚本 + Resource resource = resourceLoader.getResource("classpath:data.sql"); + try (InputStream inputStream = resource.getInputStream()) { + String sqlScript = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + // Split the SQL script into individual statements and execute them + String[] statements = sqlScript.split(";"); + for (String statement : statements) { + String trimmedStatement = statement.trim(); + // Remove single-line comments starting with -- + trimmedStatement = trimmedStatement.replaceAll("--.*", "").trim(); + if (!trimmedStatement.isEmpty()) { + jdbcTemplate.execute(trimmedStatement); + } + } + } + + // 在这里定义您的自定义日志内容 + logger.info("数据库首次初始化完成,默认数据已通过 data.sql 文件成功插入。"); + + // 插入初始化标志,防止下次启动时再次执行 + jdbcTemplate.update("UPDATE system_settings SET value = ? WHERE `key` = ?", "true", "init_system"); + } else { + logger.info("数据库已初始化,跳过默认数据插入。"); + } + } +} diff --git a/backend-java/src/main/java/com/xianyu/autoreply/config/DataSourceConfig.java b/backend-java/src/main/java/com/xianyu/autoreply/config/DataSourceConfig.java new file mode 100644 index 0000000..add9b60 --- /dev/null +++ b/backend-java/src/main/java/com/xianyu/autoreply/config/DataSourceConfig.java @@ -0,0 +1,42 @@ +package com.xianyu.autoreply.config; + +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import javax.sql.DataSource; +import java.io.File; + +@Configuration +public class DataSourceConfig { + + /** + * 自定义 DataSource Bean,以确保在连接池初始化之前创建数据库目录。 + * @param properties 由 Spring Boot 自动配置并注入的、包含 application.yml 中所有 spring.datasource.* 配置的属性对象。 + * @return 配置好的 DataSource 实例。 + */ + @Bean + @Primary + public DataSource dataSource(DataSourceProperties properties) { + // 从URL中提取文件路径 (e.g., "jdbc:sqlite:./db/xianyu_data.db" -> "./db/xianyu_data.db") + String url = properties.getUrl(); + String path = url.replace("jdbc:sqlite:", ""); + File dbFile = new File(path); + + // 获取父目录 + File parentDir = dbFile.getParentFile(); + + // 如果父目录不为空且不存在,则创建它 + if (parentDir != null && !parentDir.exists()) { + if (parentDir.mkdirs()) { + System.out.println("Successfully created database directory: " + parentDir.getAbsolutePath()); + } else { + System.err.println("Failed to create database directory: " + parentDir.getAbsolutePath()); + } + } + + // 使用 Spring Boot 的标准构建器来创建 DataSource,这样可以重用所有 application.yml 中的配置 + return properties.initializeDataSourceBuilder().build(); + } +} diff --git a/backend-java/src/main/java/com/xianyu/autoreply/controller/CookieController.java b/backend-java/src/main/java/com/xianyu/autoreply/controller/CookieController.java index 932d32c..f0c5897 100644 --- a/backend-java/src/main/java/com/xianyu/autoreply/controller/CookieController.java +++ b/backend-java/src/main/java/com/xianyu/autoreply/controller/CookieController.java @@ -12,8 +12,10 @@ import org.springframework.web.bind.annotation.*; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; @@ -59,18 +61,41 @@ public class CookieController { return cookieRepository.findByUserId(userId); } + /** + * 获取所有Cookie的详细信息(包括值和状态) + * 对应Python的 get_cookies_details 接口 + */ @GetMapping("/details") - public List getAllCookiesDetails(@RequestHeader(value = "Authorization", required = false) String token) { - return listCookies(token); + public List getAllCookiesDetails(@RequestHeader(value = "Authorization", required = false) String token) { + Long userId = getUserId(token); + List userCookies = new ArrayList<>(); + if (Objects.equals(1, userId)) { + userCookies.addAll(cookieRepository.findAll()); + } else { + // 获取当前用户的所有cookies + userCookies.addAll(cookieRepository.findByUserId(userId)); + } + + // 构建详细信息响应 + return userCookies.stream().map(cookie -> { + CookieDetailsResponse response = new CookieDetailsResponse(); + response.setId(cookie.getId()); + response.setValue(cookie.getValue()); + response.setEnabled(cookie.getEnabled()); + response.setAutoConfirm(cookie.getAutoConfirm()); + response.setRemark(cookie.getRemark() != null ? cookie.getRemark() : ""); + response.setPauseDuration(cookie.getPauseDuration() != null ? cookie.getPauseDuration() : 10); + return response; + }).collect(Collectors.toList()); } @PostMapping public Cookie addCookie(@RequestBody CookieIn cookieIn, @RequestHeader(value = "Authorization", required = false) String token) { Long userId = getUserId(token); - + // Check if ID exists if (cookieRepository.existsById(cookieIn.getId())) { - throw new RuntimeException("Cookie ID already exists"); + throw new RuntimeException("Cookie ID already exists"); } Cookie cookie = new Cookie(); @@ -96,7 +121,7 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookie.setValue(cookieIn.getValue()); cookie.setUpdatedAt(LocalDateTime.now()); cookieRepository.save(cookie); @@ -108,7 +133,7 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookieRepository.deleteById(id); } @@ -128,11 +153,11 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + if (update.getUsername() != null) cookie.setUsername(update.getUsername()); if (update.getPassword() != null) cookie.setPassword(update.getPassword()); if (update.getShowBrowser() != null) cookie.setShowBrowser(update.getShowBrowser() ? 1 : 0); - + return cookieRepository.save(cookie); } @@ -142,7 +167,7 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookie.setPauseDuration(update.getPauseDuration()); return cookieRepository.save(cookie); } @@ -161,7 +186,7 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookie.setAutoConfirm(update.isAutoConfirm() ? 1 : 0); return cookieRepository.save(cookie); } @@ -180,7 +205,7 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookie.setRemark(update.getRemark()); return cookieRepository.save(cookie); } @@ -199,20 +224,20 @@ public class CookieController { Long userId = getUserId(token); Cookie cookie = cookieRepository.findById(id).orElseThrow(() -> new RuntimeException("Cookie not found")); checkOwnership(cookie, userId); - + cookie.setEnabled(update.isEnabled()); cookieRepository.save(cookie); // Start/Stop client logic placeholder return cookie; } - + // cookies/check - Usually global validation or test, Python Line 4340 @GetMapping("/check") public Map checkCookies(@RequestHeader(value = "Authorization", required = false) String token) { Long userId = getUserId(token); // Logic to check validity of user's cookies. // For now, return stub or call BrowserService if needed. - return Map.of("status", "checked", "count", 0); + return Map.of("status", "checked", "count", 0); } // DTOs with JSON Properties @@ -252,4 +277,19 @@ public class CookieController { @JsonProperty("pause_duration") private Integer pauseDuration; } + + /** + * Cookie详细信息响应 - 对应Python的 get_cookies_details 返回格式 + */ + @Data + public static class CookieDetailsResponse { + private String id; + private String value; + private Boolean enabled; + @JsonProperty("auto_confirm") + private Integer autoConfirm; + private String remark; + @JsonProperty("pause_duration") + private Integer pauseDuration; + } } \ No newline at end of file diff --git a/backend-java/src/main/java/com/xianyu/autoreply/service/BrowserService.java b/backend-java/src/main/java/com/xianyu/autoreply/service/BrowserService.java index e17fe28..00a1fdf 100644 --- a/backend-java/src/main/java/com/xianyu/autoreply/service/BrowserService.java +++ b/backend-java/src/main/java/com/xianyu/autoreply/service/BrowserService.java @@ -26,7 +26,10 @@ public class BrowserService { private final CookieRepository cookieRepository; private Playwright playwright; - private Browser browser; + private Browser browser; + + // 为每个账号维护持久化浏览器上下文(用于Cookie刷新) + private final Map persistentContexts = new ConcurrentHashMap<>(); @Autowired public BrowserService(CookieRepository cookieRepository) { @@ -74,6 +77,10 @@ public class BrowserService { @PreDestroy private void close() { log.info("Releasing Playwright resources..."); + + // 关闭所有持久化上下文 + closeAllPersistentContexts(); + if (browser != null) { browser.close(); } @@ -493,111 +500,108 @@ public class BrowserService { return false; } + /** + * 刷新Cookie - 使用持久化浏览器上下文 + * Cookie会自动保存到UserData目录,类似真实浏览器行为 + */ public Map refreshCookies(String cookieId) { - log.info("【Cookie Refresh】Attempting to refresh cookies for id: {}", cookieId); + log.info("【Cookie Refresh】开始刷新Cookie for id: {}", cookieId); Cookie cookie = cookieRepository.findById(cookieId).orElse(null); if (cookie == null || cookie.getValue() == null) { - log.error("【Cookie Refresh】Cannot refresh. No valid cookie found for id: {}", cookieId); + log.error("【Cookie Refresh】无法刷新,Cookie不存在: {}", cookieId); return Collections.emptyMap(); } - BrowserContext context = null; + Page page = null; try { - // Use a fresh context for each refresh to avoid pollution - Browser.NewContextOptions options = new Browser.NewContextOptions() - .setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - .setViewportSize(1920, 1080); + // 1. 获取或创建持久化上下文(Cookie自动从UserData加载) + BrowserContext context = getPersistentContext(cookieId); + log.info("【Cookie Refresh】已获取持久化上下文: {}", cookieId); - context = browser.newContext(options); - - // 1. Parse and Set Cookies - List playwrightCookies = new ArrayList<>(); - String[] parts = cookie.getValue().split(";"); - for (String part : parts) { - String[] kv = part.trim().split("=", 2); - if (kv.length == 2) { - playwrightCookies.add(new com.microsoft.playwright.options.Cookie(kv[0], kv[1]) - .setDomain(".goofish.com") - .setPath("/")); - } - } - context.addCookies(playwrightCookies); - log.info("【Cookie Refresh】Loaded {} cookies for {}", playwrightCookies.size(), cookieId); - - // 2. Navigate and Refresh - Page page = context.newPage(); + // 2. 创建新页面并访问闲鱼 + page = context.newPage(); addStealthScripts(page); String targetUrl = "https://www.goofish.com/im"; - log.info("【Cookie Refresh】Navigating to {}", targetUrl); + log.info("【Cookie Refresh】导航到: {}", targetUrl); try { - page.navigate(targetUrl, new Page.NavigateOptions().setTimeout(15000).setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + page.navigate(targetUrl, new Page.NavigateOptions() + .setTimeout(20000) + .setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); } catch (Exception e) { - log.warn("【Cookie Refresh】Navigation timeout, trying fallback..."); - try { - page.navigate(targetUrl, new Page.NavigateOptions().setTimeout(25000).setWaitUntil(WaitUntilState.LOAD)); - } catch (Exception ex) { - log.warn("【Cookie Refresh】Fallback navigation also timed out (proceeding anyway)."); - } + log.warn("【Cookie Refresh】导航超时,尝试降级..."); + try { + page.navigate(targetUrl, new Page.NavigateOptions() + .setTimeout(30000) + .setWaitUntil(WaitUntilState.LOAD)); + } catch (Exception ex) { + log.warn("【Cookie Refresh】降级导航也超时,继续执行"); + } } - // Wait for page load + // 3. 等待页面加载 + try { Thread.sleep(3000); } catch (Exception e) {} + + // 4. 重新加载页面以触发Cookie刷新 + log.info("【Cookie Refresh】重新加载页面..."); + try { + page.reload(new Page.ReloadOptions() + .setTimeout(20000) + .setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } catch (Exception e) { + log.warn("【Cookie Refresh】重新加载超时,继续执行"); + } try { Thread.sleep(2000); } catch (Exception e) {} - // Reload to force refresh - log.info("【Cookie Refresh】Reloading page..."); - try { - page.reload(new Page.ReloadOptions().setTimeout(15000).setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); - } catch (Exception e) { - log.warn("【Cookie Refresh】Reload timeout (proceeding anyway)."); - } - try { Thread.sleep(1000); } catch (Exception e) {} - - // 3. Capture New Cookies + // 5. 获取刷新后的Cookie(从持久化上下文中获取) List newCookies = context.cookies(); - log.info("【Cookie Refresh】Captured {} cookies after refresh.", newCookies.size()); + log.info("【Cookie Refresh】获取到 {} 个Cookie", newCookies.size()); - // 4. Compare and Update + // 6. 构建Cookie Map Map newCookieMap = new HashMap<>(); for (com.microsoft.playwright.options.Cookie c : newCookies) { newCookieMap.put(c.name, c.value); } - // Simple check if unb exists + // 7. 验证必要Cookie if (!newCookieMap.containsKey("unb")) { - log.warn("【Cookie Refresh】'unb' missing in refreshed cookies. Refresh might have failed or session invalid."); + log.warn("【Cookie Refresh】刷新后的Cookie缺少'unb'字段,可能已失效"); return Collections.emptyMap(); } - // Construct new cookie string + // 8. 构建Cookie字符串并保存到数据库 StringBuilder sb = new StringBuilder(); for (Map.Entry entry : newCookieMap.entrySet()) { sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; "); } String newCookieStr = sb.toString(); - // Check for changes (Logic similar to Python's check) - // For now, we update if string is different, or if we want to be robust, we just save. - // Python checks if keys changed or values changed. - // Let's just save to be safe and ensure "last updated" is fresh if DB has such field. - + // 9. 更新数据库 if (!newCookieStr.equals(cookie.getValue())) { cookie.setValue(newCookieStr); cookieRepository.save(cookie); - log.info("【Cookie Refresh】Cookies updated and saved to DB for {}", cookieId); - return newCookieMap; + log.info("【Cookie Refresh】✅ Cookie已更新并保存到数据库: {}", cookieId); } else { - log.info("【Cookie Refresh】Cookies identical, no DB update needed."); - return newCookieMap; + log.info("【Cookie Refresh】Cookie未变化,无需更新数据库"); } + + // 10. Cookie已自动保存到UserData目录(持久化) + log.info("【Cookie Refresh】✅ Cookie刷新完成(已持久化到磁盘): {}", cookieId); + return newCookieMap; } catch (Exception e) { - log.error("【Cookie Refresh】Exception during refresh for {}", cookieId, e); + log.error("【Cookie Refresh】❌ 刷新Cookie异常: {}", cookieId, e); return Collections.emptyMap(); } finally { - if (context != null) { - try { context.close(); } catch (Exception e) { log.error("Error closing context", e); } + // 关闭页面但保持上下文(保持持久化状态) + if (page != null) { + try { + page.close(); + log.debug("【Cookie Refresh】页面已关闭: {}", cookieId); + } catch (Exception e) { + log.error("【Cookie Refresh】关闭页面失败", e); + } } } } @@ -667,7 +671,128 @@ public class BrowserService { return null; // Failed } - + // ================== 持久化浏览器上下文管理 ================== + + /** + * 获取或创建账号的持久化浏览器上下文 + * 使用持久化上下文可以将Cookie保存到磁盘,类似真实浏览器行为 + */ + private BrowserContext getPersistentContext(String cookieId) { + // 如果已存在,直接返回 + BrowserContext existingContext = persistentContexts.get(cookieId); + if (existingContext != null) { + try { + // 验证上下文是否仍然有效 + existingContext.pages(); + log.debug("【Cookie Refresh】复用已存在的持久化上下文: {}", cookieId); + return existingContext; + } catch (Exception e) { + // 上下文已失效,移除并重新创建 + log.warn("【Cookie Refresh】持久化上下文已失效,重新创建: {}", cookieId); + persistentContexts.remove(cookieId); + } + } + + // 创建新的持久化上下文 + try { + String userDataDir = "browser_data/cookie_refresh/" + cookieId; + java.nio.file.Path userDataPath = java.nio.file.Paths.get(userDataDir); + + // 确保目录存在 + java.nio.file.Files.createDirectories(userDataPath); + log.info("【Cookie Refresh】创建UserData目录: {}", userDataDir); + + // 配置启动选项 + List args = new ArrayList<>(); + args.add("--no-sandbox"); + args.add("--disable-setuid-sandbox"); + args.add("--disable-dev-shm-usage"); + args.add("--disable-gpu"); + args.add("--disable-blink-features=AutomationControlled"); + args.add("--lang=zh-CN"); + + BrowserType.LaunchPersistentContextOptions options = new BrowserType.LaunchPersistentContextOptions() + .setHeadless(true) + .setArgs(args) + .setViewportSize(1920, 1080) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .setLocale("zh-CN") + .setAcceptDownloads(false) + .setIgnoreHTTPSErrors(true); + + // macOS ARM架构特殊处理 + String osName = System.getProperty("os.name").toLowerCase(); + String osArch = System.getProperty("os.arch").toLowerCase(); + if (osName.contains("mac") && osArch.contains("aarch64")) { + Path chromePath = Paths.get("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + if (chromePath.toFile().exists()) { + options.setExecutablePath(chromePath); + } + } + + log.info("【Cookie Refresh】创建持久化浏览器上下文: {}", cookieId); + BrowserContext context = playwright.chromium().launchPersistentContext(userDataPath, options); + + // 首次创建时,需要设置Cookie + Cookie cookie = cookieRepository.findById(cookieId).orElse(null); + if (cookie != null && cookie.getValue() != null) { + // 解析并添加Cookie + List playwrightCookies = new ArrayList<>(); + String[] parts = cookie.getValue().split(";"); + for (String part : parts) { + String[] kv = part.trim().split("=", 2); + if (kv.length == 2) { + playwrightCookies.add(new com.microsoft.playwright.options.Cookie(kv[0], kv[1]) + .setDomain(".goofish.com") + .setPath("/")); + } + } + context.addCookies(playwrightCookies); + log.info("【Cookie Refresh】已设置初始Cookie: {} 个", playwrightCookies.size()); + } + + // 缓存上下文 + persistentContexts.put(cookieId, context); + + return context; + + } catch (Exception e) { + log.error("【Cookie Refresh】创建持久化上下文失败: {}", cookieId, e); + throw new RuntimeException("创建持久化浏览器上下文失败", e); + } + } + + /** + * 关闭指定账号的持久化上下文 + */ + public void closePersistentContext(String cookieId) { + BrowserContext context = persistentContexts.remove(cookieId); + if (context != null) { + try { + context.close(); + log.info("【Cookie Refresh】已关闭持久化上下文: {}", cookieId); + } catch (Exception e) { + log.error("【Cookie Refresh】关闭持久化上下文失败: {}", cookieId, e); + } + } + } + + /** + * 关闭所有持久化上下文 + */ + private void closeAllPersistentContexts() { + log.info("【Cookie Refresh】关闭所有持久化上下文..."); + for (Map.Entry entry : persistentContexts.entrySet()) { + try { + entry.getValue().close(); + log.info("【Cookie Refresh】已关闭: {}", entry.getKey()); + } catch (Exception e) { + log.error("【Cookie Refresh】关闭失败: {}", entry.getKey(), e); + } + } + persistentContexts.clear(); + } + private void addStealthScripts(Page page) { page.addInitScript(BrowserStealth.STEALTH_SCRIPT); diff --git a/backend-java/src/main/java/com/xianyu/autoreply/service/QrLoginService.java b/backend-java/src/main/java/com/xianyu/autoreply/service/QrLoginService.java index 17fcdb3..fd86a8f 100644 --- a/backend-java/src/main/java/com/xianyu/autoreply/service/QrLoginService.java +++ b/backend-java/src/main/java/com/xianyu/autoreply/service/QrLoginService.java @@ -29,6 +29,11 @@ public class QrLoginService { private final Map sessions = new ConcurrentHashMap<>(); private final OkHttpClient client; private final ObjectMapper objectMapper; + + // 并发锁机制 - 防止同一session的并发处理 + private final Map qrCheckLocks = new ConcurrentHashMap<>(); + // 已处理记录 - 记录已完成处理的session + private final Map qrCheckProcessed = new ConcurrentHashMap<>(); @Autowired public QrLoginService(CookieRepository cookieRepository, BrowserService browserService) { @@ -54,11 +59,25 @@ public class QrLoginService { private String verificationUrl; private Map params = new HashMap<>(); // Store login params (t, ck, etc.) private Map cookies = new HashMap<>(); + private String accountId; // 保存处理后的账号ID + private boolean isNewAccount; // 是否为新账号 + private boolean realCookieRefreshed; // 是否成功刷新真实Cookie public boolean isExpired() { return System.currentTimeMillis() - createdTime > expireTime; } } + + @Data + public static class ProcessedRecord { + private boolean processed; + private long timestamp; + + public ProcessedRecord(boolean processed, long timestamp) { + this.processed = processed; + this.timestamp = timestamp; + } + } // --- Core Methods --- @@ -133,51 +152,84 @@ public class QrLoginService { } public Map checkQrCodeStatus(String sessionId) { - QrLoginSession session = sessions.get(sessionId); - if (session == null) { - return Map.of("status", "not_found", "message", "会话不存在或已过期"); - } - - if (session.isExpired() && !"success".equals(session.getStatus())) { - session.setStatus("expired"); - return Map.of("status", "expired", "session_id", sessionId); - } - - // If already successful, return stored result - if ("success".equals(session.getStatus()) && session.getUnb() != null) { - return buildSuccessResult(session); - } - - // Poll status from API try { - pollQrCodeStatus(session); + // 1. 清理过期记录 + cleanupQrCheckRecords(); + + // 2. 检查是否已经处理过 + ProcessedRecord processedRecord = qrCheckProcessed.get(sessionId); + if (processedRecord != null && processedRecord.isProcessed()) { + log.debug("【QR Login】扫码登录session {} 已处理过,直接返回", sessionId); + return Map.of("status", "already_processed", "message", "该会话已处理完成"); + } + + // 3. 获取或创建该session的锁对象 + Object sessionLock = qrCheckLocks.computeIfAbsent(sessionId, k -> new Object()); + + // 4. 尝试获取锁(使用tryLock模式,避免阻塞) + boolean lockAcquired = false; + synchronized (sessionLock) { + // 检查锁状态(在Java中我们用一个简单的标记) + // 如果已经有线程在处理,直接返回processing + if (Thread.holdsLock(sessionLock)) { + lockAcquired = true; + } + } + + // 使用synchronized块确保同一session不会被并发处理 + synchronized (sessionLock) { + // 5. 双重检查 - 再次确认是否已处理 + processedRecord = qrCheckProcessed.get(sessionId); + if (processedRecord != null && processedRecord.isProcessed()) { + log.debug("【QR Login】扫码登录session {} 在获取锁后发现已处理,直接返回", sessionId); + return Map.of("status", "already_processed", "message", "该会话已处理完成"); + } + + // 6. 清理过期会话 + cleanupExpiredSessions(); + + // 7. 获取会话状态 + Map statusInfo = getSessionStatus(sessionId); + log.info("【QR Login】获取会话状态1111111: {}", statusInfo); + + String status = (String) statusInfo.get("status"); + + // 8. 如果登录成功,处理Cookie + if ("success".equals(status)) { + log.info("【QR Login】获取会话状态22222222: {}", statusInfo); + + // 获取会话Cookie信息 + Map cookiesInfo = getSessionCookies(sessionId); + log.info("【QR Login】获取会话Cookie: {}", cookiesInfo); + + if (cookiesInfo != null && !cookiesInfo.isEmpty()) { + // 处理扫码登录Cookie + Map accountInfo = processQrLoginCookies( + cookiesInfo.get("cookies"), + cookiesInfo.get("unb") + ); + + // 将账号信息添加到返回结果中 + statusInfo.put("account_info", accountInfo); + + log.info("【QR Login】扫码登录处理完成: {}, 账号: {}", + sessionId, accountInfo.get("account_id")); + + // 9. 标记该session已处理 + qrCheckProcessed.put(sessionId, new ProcessedRecord(true, System.currentTimeMillis())); + } + } + + return statusInfo; + } + } catch (Exception e) { - log.error("【QR Login】Error polling status for {}", sessionId, e); + log.error("【QR Login】检查扫码登录状态异常: {}", e.getMessage(), e); + Map errorResult = new HashMap<>(); + errorResult.put("status", "error"); + errorResult.put("message", e.getMessage()); + return errorResult; } - - Map result = new HashMap<>(); - result.put("status", session.getStatus()); - result.put("session_id", sessionId); - - if ("verification_required".equals(session.getStatus())) { - result.put("verification_url", session.getVerificationUrl()); - result.put("message", "账号被风控,需要手机验证"); - } - - if ("success".equals(session.getStatus())) { - log.info("【QR Login】Status confirmed SUCCESS. Starting post-login processing for UNB: {}", session.getUnb()); - - try { - processLoginSuccess(session); - return buildSuccessResult(session); - } catch (Exception e) { - log.error("【QR Login】Error during post-login processing", e); - result.put("status", "error"); - result.put("message", "登录后处理失败: " + e.getMessage()); - } - } - - return result; } private void processLoginSuccess(QrLoginSession session) { @@ -482,4 +534,231 @@ public class QrLoginService { return validCookies; } } + + // --- 清理和工具方法 --- + + /** + * 清理过期的扫码检查记录(超过1小时) + */ + private void cleanupQrCheckRecords() { + long currentTime = System.currentTimeMillis(); + List expiredSessions = new ArrayList<>(); + + for (Map.Entry entry : qrCheckProcessed.entrySet()) { + // 清理超过1小时的记录 + if (currentTime - entry.getValue().getTimestamp() > 3600 * 1000) { + expiredSessions.add(entry.getKey()); + } + } + + for (String sessionId : expiredSessions) { + qrCheckProcessed.remove(sessionId); + qrCheckLocks.remove(sessionId); + log.debug("【QR Login】清理过期的扫码检查记录: {}", sessionId); + } + } + + /** + * 清理过期的登录会话 + */ + private void cleanupExpiredSessions() { + List expiredSessions = new ArrayList<>(); + + for (Map.Entry entry : sessions.entrySet()) { + if (entry.getValue().isExpired()) { + expiredSessions.add(entry.getKey()); + } + } + + for (String sessionId : expiredSessions) { + sessions.remove(sessionId); + log.info("【QR Login】清理过期会话: {}", sessionId); + } + } + + /** + * 获取会话状态(包含轮询API) + */ + private Map getSessionStatus(String sessionId) { + QrLoginSession session = sessions.get(sessionId); + if (session == null) { + return Map.of("status", "not_found", "message", "会话不存在或已过期"); + } + + if (session.isExpired() && !"success".equals(session.getStatus())) { + session.setStatus("expired"); + return Map.of("status", "expired", "session_id", sessionId); + } + + // 如果已经成功,直接返回 + if ("success".equals(session.getStatus()) && session.getUnb() != null) { + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("session_id", sessionId); + return result; + } + + // 轮询状态 + try { + pollQrCodeStatus(session); + } catch (Exception e) { + log.error("【QR Login】轮询状态失败 for {}", sessionId, e); + } + + Map result = new HashMap<>(); + result.put("status", session.getStatus()); + result.put("session_id", sessionId); + + if ("verification_required".equals(session.getStatus())) { + result.put("verification_url", session.getVerificationUrl()); + result.put("message", "账号被风控,需要手机验证"); + } + + return result; + } + + /** + * 获取会话Cookie信息 + */ + private Map getSessionCookies(String sessionId) { + QrLoginSession session = sessions.get(sessionId); + if (session != null && "success".equals(session.getStatus())) { + Map result = new HashMap<>(); + + // 将Cookie转换为字符串 + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : session.getCookies().entrySet()) { + sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; "); + } + + result.put("cookies", sb.toString()); + result.put("unb", session.getUnb()); + return result; + } + return null; + } + + /** + * 处理扫码登录Cookie - 对应Python的process_qr_login_cookies方法 + */ + private Map processQrLoginCookies(String cookies, String unb) { + try { + log.info("【QR Login】开始处理扫码登录Cookie, UNB: {}", unb); + + // 1. 检查是否已存在相同unb的账号 + String existingAccountId = null; + boolean isNewAccount = true; + + // 遍历数据库中的所有Cookie,查找是否有相同的unb + Iterable allCookies = cookieRepository.findAll(); + for (com.xianyu.autoreply.entity.Cookie cookieEntity : allCookies) { + String cookieValue = cookieEntity.getValue(); + if (cookieValue != null && cookieValue.contains("unb=" + unb)) { + existingAccountId = cookieEntity.getId(); + isNewAccount = false; + log.info("【QR Login】扫码登录找到现有账号: {}, UNB: {}", existingAccountId, unb); + break; + } + } + + // 2. 确定账号ID + String accountId; + if (existingAccountId != null) { + accountId = existingAccountId; + } else { + // 创建新账号,使用unb作为账号ID + accountId = unb; + + // 确保账号ID唯一 + int counter = 1; + String originalAccountId = accountId; + while (cookieRepository.existsById(accountId)) { + accountId = originalAccountId + "_" + counter; + counter++; + } + + log.info("【QR Login】扫码登录准备创建新账号: {}, UNB: {}", accountId, unb); + } + + // 3. 使用浏览器服务验证并刷新Cookie(获取真实Cookie) + log.info("【QR Login】开始使用扫码cookie获取真实cookie: {}", accountId); + + boolean realCookieRefreshed = false; + String finalCookieStr = cookies; + + try { + // 调用BrowserService验证并获取真实Cookie + Map verifiedCookies = browserService.verifyQrLoginCookies( + parseCookieString(cookies), + accountId + ); + + if (verifiedCookies != null && !verifiedCookies.isEmpty()) { + log.info("【QR Login】浏览器验证成功,获取到真实Cookie,数量: {}", verifiedCookies.size()); + + // 将Cookie转换为字符串 + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : verifiedCookies.entrySet()) { + sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; "); + } + finalCookieStr = sb.toString(); + realCookieRefreshed = true; + } else { + log.warn("【QR Login】浏览器验证失败,使用原始扫码Cookie"); + } + } catch (Exception e) { + log.error("【QR Login】获取真实Cookie异常: {}", e.getMessage(), e); + log.warn("【QR Login】降级处理 - 使用原始扫码Cookie"); + } + + // 4. 保存Cookie到数据库 + com.xianyu.autoreply.entity.Cookie cookieEntity = cookieRepository.findById(accountId) + .orElse(new com.xianyu.autoreply.entity.Cookie()); + + cookieEntity.setId(accountId); + cookieEntity.setValue(finalCookieStr); + + if (isNewAccount) { + cookieEntity.setUsername("TB_" + unb); + cookieEntity.setPassword("QR_LOGIN"); + cookieEntity.setUserId(0L); + } + + cookieEntity.setEnabled(true); + cookieRepository.save(cookieEntity); + + log.info("【QR Login】Cookie已保存到数据库: {}, 是否新账号: {}, 真实Cookie刷新: {}", + accountId, isNewAccount, realCookieRefreshed); + + // 5. 构建返回结果 + Map result = new HashMap<>(); + result.put("account_id", accountId); + result.put("is_new_account", isNewAccount); + result.put("real_cookie_refreshed", realCookieRefreshed); + result.put("cookie_length", finalCookieStr.length()); + + return result; + + } catch (Exception e) { + log.error("【QR Login】处理扫码登录Cookie失败: {}", e.getMessage(), e); + throw new RuntimeException("处理扫码登录Cookie失败: " + e.getMessage(), e); + } + } + + /** + * 解析Cookie字符串为Map + */ + private Map parseCookieString(String cookieStr) { + Map cookieMap = new HashMap<>(); + if (cookieStr != null && !cookieStr.isEmpty()) { + String[] pairs = cookieStr.split(";\\s*"); + for (String pair : pairs) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + cookieMap.put(kv[0].trim(), kv[1].trim()); + } + } + } + return cookieMap; + } } diff --git a/backend-java/src/main/java/com/xianyu/autoreply/service/XianyuClient.java b/backend-java/src/main/java/com/xianyu/autoreply/service/XianyuClient.java index 05d3460..fd75540 100644 --- a/backend-java/src/main/java/com/xianyu/autoreply/service/XianyuClient.java +++ b/backend-java/src/main/java/com/xianyu/autoreply/service/XianyuClient.java @@ -564,39 +564,99 @@ public class XianyuClient extends TextWebSocketHandler { /** * 刷新Token - 对应Python的refresh_token()方法 + * 添加自动降级机制:Token获取失败时自动刷新Cookie */ private String refreshToken() { - try { - log.info("【{}】开始刷新token...", cookieId); - lastTokenRefreshStatus = "started"; - - // 检查是否在消息冷却期内 - long currentTime = System.currentTimeMillis(); - long timeSinceLastMessage = currentTime - lastMessageReceivedTime.get(); - if (lastMessageReceivedTime.get() > 0 && timeSinceLastMessage < MESSAGE_COOLDOWN * 1000L) { - long remainingTime = MESSAGE_COOLDOWN * 1000L - timeSinceLastMessage; - log.info("【{}】收到消息后冷却中,放弃本次token刷新,还需等待 {} 秒", - cookieId, remainingTime / 1000); - lastTokenRefreshStatus = "skipped_cooldown"; - return null; - } - - // 从数据库重新加载Cookie + int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { try { - Optional cookieOpt = cookieRepository.findById(cookieId); - if (cookieOpt.isPresent()) { - String newCookiesStr = cookieOpt.get().getValue(); - if (!newCookiesStr.equals(this.cookiesStr)) { - log.info("【{}】检测到数据库中的cookie已更新,重新加载cookie", cookieId); - this.cookiesStr = newCookiesStr; - this.cookies = parseCookies(this.cookiesStr); - log.warn("【{}】Cookie已从数据库重新加载", cookieId); - } + if (retryCount > 0) { + log.info("【{}】Token获取失败,第 {} 次重试...", cookieId, retryCount); + } else { + log.info("【{}】开始刷新token...", cookieId); } - } catch (Exception e) { - log.warn("【{}】从数据库重新加载cookie失败,继续使用当前cookie: {}", cookieId, e.getMessage()); - } + lastTokenRefreshStatus = "started"; + // 检查是否在消息冷却期内 + long currentTime = System.currentTimeMillis(); + long timeSinceLastMessage = currentTime - lastMessageReceivedTime.get(); + if (lastMessageReceivedTime.get() > 0 && timeSinceLastMessage < MESSAGE_COOLDOWN * 1000L) { + long remainingTime = MESSAGE_COOLDOWN * 1000L - timeSinceLastMessage; + log.info("【{}】收到消息后冷却中,放弃本次token刷新,还需等待 {} 秒", + cookieId, remainingTime / 1000); + lastTokenRefreshStatus = "skipped_cooldown"; + return null; + } + + // 从数据库重新加载Cookie(可能已被浏览器刷新更新) + try { + Optional cookieOpt = cookieRepository.findById(cookieId); + if (cookieOpt.isPresent()) { + String newCookiesStr = cookieOpt.get().getValue(); + if (!newCookiesStr.equals(this.cookiesStr)) { + log.info("【{}】检测到数据库中的cookie已更新,重新加载cookie", cookieId); + this.cookiesStr = newCookiesStr; + this.cookies = parseCookies(this.cookiesStr); + log.warn("【{}】Cookie已从数据库重新加载", cookieId); + } + } + } catch (Exception e) { + log.warn("【{}】从数据库重新加载cookie失败,继续使用当前cookie: {}", cookieId, e.getMessage()); + } + + // 尝试获取Token + String token = attemptGetToken(); + + if (token != null) { + // Token获取成功 + this.currentToken = token; + this.lastTokenRefreshTime.set(System.currentTimeMillis()); + this.lastMessageReceivedTime.set(0); // 重置消息接收时间 + log.warn("【{}】✅ Token刷新成功", cookieId); + lastTokenRefreshStatus = "success"; + return token; + } + + // Token获取失败,尝试刷新Cookie + log.warn("【{}】⚠️ Token获取失败,尝试通过浏览器刷新Cookie...", cookieId); + + try { + Map newCookies = browserService.refreshCookies(cookieId); + + if (newCookies != null && !newCookies.isEmpty()) { + log.info("【{}】✅ Cookie刷新成功,重新加载...", cookieId); + // 重新加载Cookie + loadCookies(); + retryCount++; + // 继续下一轮重试 + continue; + } else { + log.error("【{}】❌ Cookie刷新失败,无法继续", cookieId); + break; + } + } catch (Exception e) { + log.error("【{}】❌ Cookie刷新异常: {}", cookieId, e.getMessage()); + break; + } + + } catch (Exception e) { + log.error("【{}】Token刷新过程异常", cookieId, e); + break; + } + } + + log.error("【{}】❌ Token刷新最终失败,已重试 {} 次", cookieId, retryCount); + lastTokenRefreshStatus = "failed"; + return null; + } + + /** + * 尝试获取Token(单次尝试) + */ + private String attemptGetToken() { + try { // 生成时间戳 String timestamp = String.valueOf(System.currentTimeMillis()); @@ -652,7 +712,12 @@ public class XianyuClient extends TextWebSocketHandler { // 检查是否需要滑块验证 if (needsCaptchaVerification(resJson)) { log.warn("【{}】检测到滑块验证要求", cookieId); - return handleCaptchaAndRetry(resJson); + // 这里需要决定如何处理滑块验证。 + // 如果是attemptGetToken,可能直接返回null,让上层refreshToken决定是否重试或刷新cookie + // 或者直接抛出异常,让上层捕获 + // 暂时返回null,让refreshToken的重试机制处理 + handleCaptchaAndRetry(resJson); // 仍然调用,但其返回值不直接影响这里的return + return null; } // 检查响应 @@ -665,12 +730,7 @@ public class XianyuClient extends TextWebSocketHandler { JSONObject data = resJson.getJSONObject("data"); if (data.containsKey("accessToken")) { String newToken = data.getString("accessToken"); - this.currentToken = newToken; - this.lastTokenRefreshTime.set(System.currentTimeMillis()); - this.lastMessageReceivedTime.set(0); // 重置消息接收时间 - - log.warn("【{}】Token刷新成功", cookieId); - lastTokenRefreshStatus = "success"; + log.info("【{}】获取到accessToken", cookieId); return newToken; } } @@ -678,13 +738,11 @@ public class XianyuClient extends TextWebSocketHandler { } } - log.error("【{}】Token刷新失败: 响应中未找到有效token", cookieId); - lastTokenRefreshStatus = "failed"; + log.warn("【{}】响应中未找到有效token", cookieId); return null; } catch (Exception e) { - log.error("【{}】Token刷新异常", cookieId, e); - lastTokenRefreshStatus = "error"; + log.error("【{}】获取Token异常: {}", cookieId, e.getMessage()); return null; } } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 5978c48..d6ed5e0 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -1,3 +1,6 @@ +app: + ddl-auto: update # valid values: none, validate, update, create, create-drop + server: port: 8080 @@ -7,14 +10,18 @@ spring: datasource: driver-class-name: org.sqlite.JDBC - url: jdbc:sqlite:../data/xianyu_data.db + url: jdbc:sqlite:./db/xianyu_data.db username: password: + sql: + init: + mode: never + jpa: database-platform: org.hibernate.community.dialect.SQLiteDialect hibernate: - ddl-auto: update + ddl-auto: ${app.ddl-auto:update} show-sql: true properties: hibernate: diff --git a/backend-java/src/main/resources/data.sql b/backend-java/src/main/resources/data.sql new file mode 100644 index 0000000..9757fa8 --- /dev/null +++ b/backend-java/src/main/resources/data.sql @@ -0,0 +1,18 @@ +-- 系统默认账号 +INSERT OR IGNORE INTO users (username, email, password_hash) VALUES ('admin', 'admin@localhost', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'); +-- 系统默认设置 +INSERT OR IGNORE INTO system_settings (key, value, description) +VALUES ('init_system', 'false', '是否初始化'), + ('theme_color', 'blue', '主题颜色'), + ('registration_enabled', 'true', '是否开启用户注册'), + ('show_default_login_info', 'true', '是否显示默认登录信息'), + ('login_captcha_enabled', 'true', '登录滑动验证码开关'), + ('smtp_server', '', 'SMTP服务器地址'), + ('smtp_port', '587', 'SMTP端口'), + ('smtp_user', '', 'SMTP登录用户名(发件邮箱)'), + ('smtp_password', '', 'SMTP登录密码/授权码'), + ('smtp_from', '', '发件人显示名(留空则使用用户名)'), + ('smtp_use_tls', 'true', '是否启用TLS'), + ('smtp_use_ssl', 'false', '是否启用SSL'), + ('qq_reply_secret_key', 'xianyu_qq_reply_2024', 'QQ回复消息API秘钥'); +