This commit is contained in:
wangli 2026-01-17 00:46:58 +08:00
parent 706c64c8d8
commit 2b54a07332
2 changed files with 521 additions and 0 deletions

View File

@ -0,0 +1,36 @@
package com.xianyu.autoreply.controller;
import com.xianyu.autoreply.service.QrLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class QrLoginController {
private final QrLoginService qrLoginService;
@Autowired
public QrLoginController(QrLoginService qrLoginService) {
this.qrLoginService = qrLoginService;
}
@PostMapping("/qr-login/generate")
public Map<String, Object> generateQrCode() {
return qrLoginService.generateQrCode();
}
@GetMapping("/qr-login/check/{sessionId}")
public Map<String, Object> checkQrCodeStatus(@PathVariable String sessionId) {
return qrLoginService.checkQrCodeStatus(sessionId);
}
@PostMapping("/qr-login/refresh-cookie/{accountId}")
public Map<String, String> refreshCookie(@PathVariable String accountId) {
return qrLoginService.refreshCookie(accountId);
}
}

View File

@ -0,0 +1,485 @@
package com.xianyu.autoreply.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.xianyu.autoreply.repository.CookieRepository;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@Slf4j
public class QrLoginService {
private final CookieRepository cookieRepository;
private final BrowserService browserService;
private final Map<String, QrLoginSession> sessions = new ConcurrentHashMap<>();
private final OkHttpClient client;
private final ObjectMapper objectMapper;
@Autowired
public QrLoginService(CookieRepository cookieRepository, BrowserService browserService) {
this.cookieRepository = cookieRepository;
this.browserService = browserService;
this.client = new OkHttpClient.Builder()
.cookieJar(new InMemoryCookieJar())
.build();
this.objectMapper = new ObjectMapper();
}
// --- Session Classes ---
@Data
public static class QrLoginSession {
private String sessionId;
private String status = "waiting"; // waiting, scanned, success, expired, cancelled, verification_required
private String qrCodeUrl;
private String qrContent;
private String unb;
private long createdTime = System.currentTimeMillis();
private long expireTime = 300 * 1000; // 5 mins
private String verificationUrl;
private Map<String, String> params = new HashMap<>(); // Store login params (t, ck, etc.)
private Map<String, String> cookies = new HashMap<>();
public boolean isExpired() {
return System.currentTimeMillis() - createdTime > expireTime;
}
}
// --- Core Methods ---
public Map<String, Object> generateQrCode() {
String sessionId = UUID.randomUUID().toString();
log.info("【QR Login】Generating new QR code session: {}", sessionId);
QrLoginSession session = new QrLoginSession();
session.setSessionId(sessionId);
try {
// 1. Get m_h5_tk
getMh5tk(session);
log.info("【QR Login】Got m_h5_tk for session: {}", sessionId);
// 2. Get Login Params
Map<String, String> loginParams = getLoginParams(session);
log.info("【QR Login】Got login params for session: {}", sessionId);
// 3. Generate QR Code Data
// Construct URL: https://passport.goofish.com/newlogin/qrcode/generate.do
HttpUrl.Builder urlBuilder = HttpUrl.parse("https://passport.goofish.com/newlogin/qrcode/generate.do").newBuilder();
for (Map.Entry<String, String> entry : loginParams.entrySet()) {
urlBuilder.addQueryParameter(entry.getKey(), entry.getValue());
}
Request request = new Request.Builder()
.url(urlBuilder.build())
.headers(generateHeaders())
.get()
.build();
try (Response response = client.newCall(request).execute()) {
String responseBody = response.body().string();
log.debug("【QR Login Debug】Generate QR raw response: {}", responseBody);
Map<String, Object> result = objectMapper.readValue(responseBody, Map.class);
Map<String, Object> content = (Map<String, Object>) result.get("content");
Boolean success = (Boolean) content.get("success");
if (success != null && success) {
Map<String, Object> data = (Map<String, Object>) content.get("data");
// Update session with t and ck
session.getParams().put("t", String.valueOf(data.get("t")));
session.getParams().put("ck", (String) data.get("ck"));
String qrContent = (String) data.get("codeContent");
session.setQrContent(qrContent);
String qrBase64 = generateQrImageBase64(qrContent);
String qrDataUrl = "data:image/png;base64," + qrBase64;
session.setQrCodeUrl(qrDataUrl);
sessions.put(sessionId, session);
log.info("【QR Login】QR Code generated successfully: {}", sessionId);
return Map.of(
"success", true,
"session_id", sessionId,
"qr_code_url", qrDataUrl
);
} else {
throw new RuntimeException("Failed to generate QR code from API");
}
}
} catch (Exception e) {
log.error("【QR Login】Error generating QR code", e);
return Map.of("success", false, "message", "生成二维码失败: " + e.getMessage());
}
}
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);
} catch (Exception e) {
log.error("【QR Login】Error polling status 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", "账号被风控,需要手机验证");
}
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) {
String unb = session.getUnb();
if (unb == null) {
throw new RuntimeException("Logged in but UNB is missing!");
}
// 1. Determine AccountId
String accountId = unb; // Default to UNB
boolean isNewAccount = true;
if (cookieRepository.existsById(unb)) {
isNewAccount = false;
log.info("【QR Login】Found existing account by ID: {}", unb);
} else {
log.info("【QR Login】New account detected for UNB: {}", unb);
}
// 2. Verify and Refresh Cookies via BrowserService
Map<String, String> verifiedCookies = browserService.verifyQrLoginCookies(session.getCookies(), accountId);
if (verifiedCookies != null && !verifiedCookies.isEmpty()) {
log.info("【QR Login】Browser verification SUCCESS. Cookies verified: {}", verifiedCookies.size());
// Build cookie string
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : verifiedCookies.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; ");
}
String finalCookieStr = sb.toString();
// 3. Save to DB
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); // Placeholder
cookieEntity.setPassword("QR_LOGIN"); // Placeholder
cookieEntity.setUserId(0L);
}
cookieEntity.setEnabled(true);
cookieRepository.save(cookieEntity);
log.info("【QR Login】Account saved to DB: {}", accountId);
// Update session
session.setCookies(verifiedCookies);
} else {
log.warn("【QR Login】Browser verification FAILED. Falling back to simple API cookies.");
// Fallback: Save original API cookies
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : session.getCookies().entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; ");
}
String finalCookieStr = sb.toString();
com.xianyu.autoreply.entity.Cookie cookieEntity = cookieRepository.findById(accountId)
.orElse(new com.xianyu.autoreply.entity.Cookie());
cookieEntity.setId(accountId);
cookieEntity.setValue(finalCookieStr);
cookieEntity.setEnabled(true);
cookieRepository.save(cookieEntity);
log.info("【QR Login】Fallback: Original API cookies saved for {}", accountId);
}
}
private Map<String, Object> buildSuccessResult(QrLoginSession session) {
Map<String, Object> result = new HashMap<>();
result.put("status", "success");
result.put("session_id", session.getSessionId());
result.put("unb", session.getUnb());
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());
return result;
}
// --- Helper Methods ---
/**
* Refresh existing cookies using browser (Keep-alive)
*/
public Map<String, String> refreshCookie(String accountId) {
log.info("【QR Login】Triggering cookie refresh for account: {}", accountId);
return browserService.refreshCookies(accountId);
}
private void getMh5tk(QrLoginSession session) throws IOException {
String apiH5Tk = "https://h5api.m.goofish.com/h5/mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get/1.0/";
String appKey = "34839810";
String dataStr = "{\"bizScene\":\"home\"}";
String t = String.valueOf(System.currentTimeMillis());
// 1. Initial Get to get m_h5_tk cookie
Request initialRequest = new Request.Builder()
.url(apiH5Tk)
.headers(generateHeaders())
.get()
.build();
client.newCall(initialRequest).execute().close(); // Cookies handled by cookieJar
// Extract m_h5_tk from cookie jar
String mh5tk = getCookieValue("m_h5_tk");
String token = mh5tk.split("_")[0];
// 2. Sign
String signInput = token + "&" + t + "&" + appKey + "&" + dataStr;
String sign = md5(signInput);
// 3. Post with sign
HttpUrl url = HttpUrl.parse(apiH5Tk).newBuilder()
.addQueryParameter("jsv", "2.7.2")
.addQueryParameter("appKey", appKey)
.addQueryParameter("t", t)
.addQueryParameter("sign", sign)
.addQueryParameter("v", "1.0")
.addQueryParameter("type", "originaljson")
.addQueryParameter("dataType", "json")
.addQueryParameter("api", "mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get")
.addQueryParameter("data", dataStr)
.build();
Request postRequest = new Request.Builder()
.url(url)
.headers(generateHeaders())
.get() // Note: Python code used POST but query params seem to be in URL? Let's check Python code. Line 138: client.post(..., params=params). params in httpx POST usually go to query string? No, httpx params are query, data/json is body. But here Python used `post` with `params`.
// Ah, Python code: client.post(self.api_h5_tk, params=params, headers=self.headers, cookies=session.cookies)
// Wait, if it is a POST without body?? Usually mtop requires GET or POST. Let's stick to what Python did.
// Re-reading Python: `params` argument in `client.post` adds to URL query string.
.post(RequestBody.create(new byte[0], null)) // Empty body POST
.build();
client.newCall(postRequest).execute().close();
}
private Map<String, String> getLoginParams(QrLoginSession session) throws IOException {
HttpUrl url = HttpUrl.parse("https://passport.goofish.com/mini_login.htm").newBuilder()
.addQueryParameter("lang", "zh_cn")
.addQueryParameter("appName", "xianyu")
.addQueryParameter("appEntrance", "web")
.addQueryParameter("styleType", "vertical")
.addQueryParameter("bizParams", "")
.addQueryParameter("notLoadSsoView", "false")
.addQueryParameter("notKeepLogin", "false")
.addQueryParameter("isMobile", "false")
.addQueryParameter("qrCodeFirst", "false")
.addQueryParameter("stie", "77")
.addQueryParameter("rnd", String.valueOf(Math.random()))
.build();
Request request = new Request.Builder()
.url(url)
.headers(generateHeaders())
.get()
.build();
try (Response response = client.newCall(request).execute()) {
String html = response.body().string();
Pattern pattern = Pattern.compile("window\\.viewData\\s*=\s*(\\{.*?\\});");
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
String jsonStr = matcher.group(1);
Map<String, Object> viewData = objectMapper.readValue(jsonStr, Map.class);
Map<String, Object> loginFormData = (Map<String, Object>) viewData.get("loginFormData");
Map<String, String> params = new HashMap<>();
if (loginFormData != null) {
for (Map.Entry<String, Object> entry : loginFormData.entrySet()) {
params.put(entry.getKey(), String.valueOf(entry.getValue()));
}
params.put("umidTag", "SERVER");
session.getParams().putAll(params);
return params;
}
}
}
throw new RuntimeException("Could not find login params in mini_login.htm");
}
private void pollQrCodeStatus(QrLoginSession session) throws IOException {
String apiScanStatus = "https://passport.goofish.com/newlogin/qrcode/query.do";
FormBody.Builder formBuilder = new FormBody.Builder();
for (Map.Entry<String, String> entry : session.getParams().entrySet()) {
formBuilder.add(entry.getKey(), entry.getValue());
}
Request request = new Request.Builder()
.url(apiScanStatus)
.headers(generateHeaders())
// In Python: client.post(api, data=session.params). `data` means FORM body.
.post(formBuilder.build())
.build();
try (Response response = client.newCall(request).execute()) {
String body = response.body().string();
Map<String, Object> result = objectMapper.readValue(body, Map.class);
// Capture cookies from response
List<Cookie> cookies = Cookie.parseAll(request.url(), response.headers());
for (Cookie c : cookies) {
session.getCookies().put(c.name(), c.value());
if ("unb".equals(c.name())) {
session.setUnb(c.value());
}
}
Map<String, Object> content = (Map<String, Object>) result.get("content");
Map<String, Object> data = (Map<String, Object>) content.get("data");
String qrCodeStatus = (String) data.get("qrCodeStatus");
if ("CONFIRMED".equals(qrCodeStatus)) {
Boolean iframeRedirect = (Boolean) data.get("iframeRedirect");
if (iframeRedirect != null && iframeRedirect) {
session.setStatus("verification_required");
session.setVerificationUrl((String) data.get("iframeRedirectUrl"));
log.warn("【QR Login】Risk control triggered: {}", session.getSessionId());
} else {
session.setStatus("success");
log.info("【QR Login】Success! UNB: {}", session.getUnb());
}
} else if ("NEW".equals(qrCodeStatus)) {
// waiting
} else if ("EXPIRED".equals(qrCodeStatus)) {
session.setStatus("expired");
} else if ("SCANED".equals(qrCodeStatus)) {
session.setStatus("scanned");
} else {
session.setStatus("cancelled");
}
}
}
private String generateQrImageBase64(String content) throws Exception {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, 200, 200);
ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", pngOutputStream);
byte[] pngData = pngOutputStream.toByteArray();
return Base64.getEncoder().encodeToString(pngData);
}
private Headers generateHeaders() {
return new Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.add("Accept", "application/json, text/plain, */*")
.add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.add("Referer", "https://passport.goofish.com/")
.add("Origin", "https://passport.goofish.com")
.build();
}
private String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : messageDigest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private String getCookieValue(String name) {
// InMemoryCookieJar implementation detail - retrieving for specific host
// Since we know the host
List<Cookie> cookies = client.cookieJar().loadForRequest(HttpUrl.parse("https://h5api.m.goofish.com"));
for (Cookie c : cookies) {
if (c.name().equals(name)) return c.value();
}
return "";
}
// Simple custom InMemoryCookieJar
private static class InMemoryCookieJar implements CookieJar {
private final List<Cookie> cookies = new ArrayList<>();
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
this.cookies.addAll(cookies);
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> validCookies = new ArrayList<>();
for (Cookie cookie : cookies) {
validCookies.add(cookie);
}
return validCookies;
}
}
}