This commit is contained in:
wangli 2026-01-17 16:13:17 +08:00
parent 775cad7ac8
commit 7ee28f755c
8 changed files with 793 additions and 160 deletions

View File

@ -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<SystemSetting> 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("数据库已初始化,跳过默认数据插入。");
}
}
}

View File

@ -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();
}
}

View File

@ -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<Cookie> getAllCookiesDetails(@RequestHeader(value = "Authorization", required = false) String token) {
return listCookies(token);
public List<CookieDetailsResponse> getAllCookiesDetails(@RequestHeader(value = "Authorization", required = false) String token) {
Long userId = getUserId(token);
List<Cookie> 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<String, Object> 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;
}
}

View File

@ -26,7 +26,10 @@ public class BrowserService {
private final CookieRepository cookieRepository;
private Playwright playwright;
private Browser browser;
private Browser browser;
// 为每个账号维护持久化浏览器上下文用于Cookie刷新
private final Map<String, BrowserContext> 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<String, String> 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<com.microsoft.playwright.options.Cookie> 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<com.microsoft.playwright.options.Cookie> 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<String, String> 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<String, String> 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<String> 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<com.microsoft.playwright.options.Cookie> 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<String, BrowserContext> 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);

View File

@ -29,6 +29,11 @@ public class QrLoginService {
private final Map<String, QrLoginSession> sessions = new ConcurrentHashMap<>();
private final OkHttpClient client;
private final ObjectMapper objectMapper;
// 并发锁机制 - 防止同一session的并发处理
private final Map<String, Object> qrCheckLocks = new ConcurrentHashMap<>();
// 已处理记录 - 记录已完成处理的session
private final Map<String, ProcessedRecord> qrCheckProcessed = new ConcurrentHashMap<>();
@Autowired
public QrLoginService(CookieRepository cookieRepository, BrowserService browserService) {
@ -54,11 +59,25 @@ public class QrLoginService {
private String verificationUrl;
private Map<String, String> params = new HashMap<>(); // Store login params (t, ck, etc.)
private Map<String, String> 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<String, Object> 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<String, Object> 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<String, String> cookiesInfo = getSessionCookies(sessionId);
log.info("【QR Login】获取会话Cookie: {}", cookiesInfo);
if (cookiesInfo != null && !cookiesInfo.isEmpty()) {
// 处理扫码登录Cookie
Map<String, Object> 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<String, Object> errorResult = new HashMap<>();
errorResult.put("status", "error");
errorResult.put("message", e.getMessage());
return errorResult;
}
Map<String, Object> 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<String> expiredSessions = new ArrayList<>();
for (Map.Entry<String, ProcessedRecord> 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<String> expiredSessions = new ArrayList<>();
for (Map.Entry<String, QrLoginSession> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, String> getSessionCookies(String sessionId) {
QrLoginSession session = sessions.get(sessionId);
if (session != null && "success".equals(session.getStatus())) {
Map<String, String> result = new HashMap<>();
// 将Cookie转换为字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> 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<String, Object> processQrLoginCookies(String cookies, String unb) {
try {
log.info("【QR Login】开始处理扫码登录Cookie, UNB: {}", unb);
// 1. 检查是否已存在相同unb的账号
String existingAccountId = null;
boolean isNewAccount = true;
// 遍历数据库中的所有Cookie查找是否有相同的unb
Iterable<com.xianyu.autoreply.entity.Cookie> 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<String, String> 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<String, String> 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<String, Object> 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<String, String> parseCookieString(String cookieStr) {
Map<String, String> 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;
}
}

View File

@ -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<Cookie> 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<Cookie> 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<String, String> 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;
}
}

View File

@ -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:

View File

@ -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秘钥');