This commit is contained in:
wangli 2026-01-30 21:48:57 +08:00
parent 7b2991bc84
commit 9eb9223bd8
10 changed files with 289 additions and 43 deletions

View File

@ -0,0 +1,15 @@
package top.biwin.xinayu.common.dto.request;
import lombok.Data;
/**
* TODO
*
* @author wangli
* @since 2026-01-30 21:28
*/
@Data
public class AccountUpdateRequest {
private Boolean enabled;
private String remark;
}

View File

@ -0,0 +1,26 @@
package top.biwin.xinayu.common.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
/**
* TODO
*
* @author wangli
* @since 2026-01-30 21:10
*/
@EqualsAndHashCode(callSuper = true)
@Data
@SuperBuilder
public class DefaultReplyResponse extends BaseResponse {
private Boolean enabled;
@JsonProperty("reply_content")
private String replyContent;
@JsonProperty("reply_once")
private Boolean replyOnce;
@JsonProperty("reply_image_url")
private String replyImageUrl;
}

View File

@ -1,7 +1,10 @@
package top.biwin.xinayu.common.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* TODO
@ -10,6 +13,9 @@ import lombok.Data;
* @since 2026-01-22 21:52
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class GoofishAccountResponse {
private String id;
private String cookie;

View File

@ -0,0 +1,45 @@
package top.biwin.xianyu.core.entity;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "default_replies")
public class DefaultReplyEntity {
@Id
@Column(name = "goofish_id")
@JsonProperty("goofish_id")
private String goofishId;
private Boolean enabled = false;
@Column(name = "reply_content", columnDefinition = "TEXT")
@JsonProperty("reply_content")
private String replyContent;
@Column(name = "reply_image_url")
@JsonProperty("reply_image_url")
private String replyImageUrl;
@Column(name = "reply_once")
@JsonProperty("reply_once")
private Boolean replyOnce = false;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,9 @@
package top.biwin.xianyu.core.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import top.biwin.xianyu.core.entity.DefaultReplyEntity;
@Repository
public interface DefaultReplyRepository extends JpaRepository<DefaultReplyEntity, String> {
}

View File

@ -86,10 +86,13 @@ public class GoofishPwdLoginService {
boolean resolveResult = attemptSolveSlider(loginFrame, account, new AtomicInteger(3));
log.debug("【{}】滑块结果: {}", account, resolveResult ? "成功" : "失败");
if(resolveResult) {
// 这里可能已经登录成功等待选择是否持久化登录
loginFrame.waitForTimeout(10000);
log.debug("【{}】验证是否登录成功...", account);
Frame lastCheckFrame = findLoginFrame(page, account);
if(Objects.nonNull(lastCheckFrame)) {
log.debug("【{}】登录失败,仍存在登录页面...", account);
log.error("【{}】登录失败,仍存在登录页面...", account);
return;
}
} else {
log.error("【{}】账号密码登录失败", account);

View File

@ -3,6 +3,7 @@ package top.biwin.xianyu.goofish.util;
import cn.hutool.core.collection.CollUtil;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Frame;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Mouse;
import com.microsoft.playwright.options.BoundingBox;
import lombok.extern.slf4j.Slf4j;
@ -12,6 +13,9 @@ import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
import com.microsoft.playwright.CDPSession;
import com.google.gson.JsonObject;
/**
* 滑块验证码处理工具类
* 模拟人类操作行为解决滑块验证
@ -58,7 +62,6 @@ public final class SliderUtils {
// 定位滑块按钮
sliderButton = frame.querySelector("#nc_1_n1z");
if (sliderButton == null) sliderButton = frame.querySelector(".nc_iconfont");
if (sliderButton != null && sliderButton.isVisible()) {
log.info("【{}-处理滑块】检测到滑块验证Frame: {}", goofishId, frame.url());
BoundingBox box = sliderButton.boundingBox();
@ -79,15 +82,10 @@ public final class SliderUtils {
// 等待验证结果
Thread.sleep(800 + ThreadLocalRandom.current().nextInt(400));
// // 检查滑块是否消失成功标志
// if (!sliderButton.isVisible()) {
// log.info("【{}-处理滑块】滑块验证成功!(按钮已消失)", goofishId);
// return true;
// }
ElementHandle elementHandle = frame.querySelector("#nc_1_refresh1");
if (Objects.nonNull(elementHandle)) {
ElementHandle sliderBox = frame.querySelector("#nc_1_refresh1");
if (Objects.nonNull(sliderBox) && sliderBox.isVisible()) {
log.info("【{}-处理滑块】滑块验证失败,点击滑块重试!", goofishId);
elementHandle.click();
sliderBox.click();
if (maxRetryCount.decrementAndGet() > 0) {
return attemptSolveSlider(frame, goofishId, maxRetryCount);
} else {
@ -96,6 +94,8 @@ public final class SliderUtils {
}
}
return success;
} else {
log.debug("【{}-处理滑块】未找到滑块按钮,可能是已经登录成功!", goofishId);
}
} catch (Exception e) {
log.warn("【{}-处理滑块】解决滑块时出错: {}", goofishId, e.getMessage(), e);
@ -119,46 +119,98 @@ public final class SliderUtils {
* @return 是否执行成功
*/
private static boolean performSmoothSlide(Frame frame, BoundingBox buttonBox, double distance, String goofishId) {
CDPSession cdpSession = null;
try {
ThreadLocalRandom random = ThreadLocalRandom.current();
cdpSession = frame.page().context().newCDPSession(frame.page());
// 计算滑块起始位置微小随机偏移
double startX = buttonBox.x + buttonBox.width / 2 + (random.nextDouble() - 0.5) * 4;
double startY = buttonBox.y + buttonBox.height / 2 + (random.nextDouble() - 0.5) * 3;
// 1. 计算人类特征起点加入随机生理震颤偏移
double startX = buttonBox.x + buttonBox.width / 2 + random.nextDouble(-5.0, 5.0);
double startY = buttonBox.y + buttonBox.height / 2 + random.nextDouble(-3.0, 3.0);
// 步骤1移动鼠标到滑块位置
frame.page().mouse().move(startX, startY);
// 2. 鼠标移动到起点
JsonObject moveParams = new JsonObject();
moveParams.addProperty("type", "mouseMoved");
moveParams.addProperty("x", startX);
moveParams.addProperty("y", startY);
cdpSession.send("Input.dispatchMouseEvent", moveParams);
// 步骤2短暂停顿人类反应时间100-250ms
Thread.sleep(100 + random.nextInt(150));
// 视觉确认时间
Thread.sleep(80 + random.nextInt(120));
// 步骤3按下鼠标
frame.page().mouse().down();
// 3. 点击鼠标按下
JsonObject downParams = new JsonObject();
downParams.addProperty("type", "mousePressed");
downParams.addProperty("button", "left");
downParams.addProperty("clickCount", 1);
downParams.addProperty("x", startX);
downParams.addProperty("y", startY);
cdpSession.send("Input.dispatchMouseEvent", downParams);
// 步骤4按下后极短延迟30-60ms
Thread.sleep(30 + random.nextInt(30));
// 肌肉准备发力延迟
Thread.sleep(40 + random.nextInt(40));
// 步骤5生成丝滑轨迹
// 4. 执行轨迹渲染
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generateFastTrajectory(distance);
BrowserTrajectoryUtils.generateSmoothTrajectory(distance);
log.debug("【{}-处理滑块】生成轨迹点数: {}", goofishId, trajectory.size());
log.info("【{}-处理滑块】CDP 发送高仿真轨迹,总计点数: {}", goofishId, trajectory.size());
// 步骤6一气呵成快速滑动不加任何sleep
for (BrowserTrajectoryUtils.TrajectoryPoint point : trajectory) {
frame.page().mouse().move(startX + point.x, startY + point.y);
// 不加sleep让Playwright自己处理速度
int totalPoints = trajectory.size();
for (int i = 0; i < totalPoints; i++) {
BrowserTrajectoryUtils.TrajectoryPoint point = trajectory.get(i);
// 坐标映射基础位置 + 轨迹偏移 + 微小生理抖动
double targetX = startX + point.x;
double targetY = startY + point.y + random.nextDouble(-0.8, 0.8);
JsonObject dragParams = new JsonObject();
dragParams.addProperty("type", "mouseMoved");
dragParams.addProperty("button", "left");
dragParams.addProperty("x", targetX);
dragParams.addProperty("y", targetY);
cdpSession.send("Input.dispatchMouseEvent", dragParams);
// 仿真节奏菲茨定律应用前期0-40%极速冲刺后期70%精准瞄准降速
double progress = (double) i / totalPoints;
if (progress < 0.3) {
// 极速段
if (i % 2 == 0) Thread.sleep(1 + random.nextInt(2));
} else if (progress < 0.7) {
// 匀速段
Thread.sleep(2 + random.nextInt(4));
} else {
// 减速修正段人眼在接近目标时会频繁修正延迟拉长
Thread.sleep(5 + random.nextInt(12));
}
}
// 步骤7释放鼠标
frame.page().mouse().up();
// 瞄准终点后的生理停顿
Thread.sleep(30 + random.nextInt(50));
log.info("【{}-处理滑块】滑动执行完成,等待验证结果...", goofishId);
// 5. 释放鼠标在终点附近的最后一次修正位置释放
BrowserTrajectoryUtils.TrajectoryPoint lastPoint = trajectory.get(totalPoints - 1);
double finalX = startX + lastPoint.x;
double finalY = startY + lastPoint.y;
JsonObject upParams = new JsonObject();
upParams.addProperty("type", "mouseReleased");
upParams.addProperty("button", "left");
upParams.addProperty("clickCount", 1);
upParams.addProperty("x", finalX);
upParams.addProperty("y", finalY);
cdpSession.send("Input.dispatchMouseEvent", upParams);
log.info("【{}-处理滑块】CDP 高仿交互已全量下发Master 等待佳音吧! (๑•̀ㅂ•́)و✧", goofishId);
return true;
} catch (Exception e) {
log.error("【{}-处理滑块】执行滑动时出错: {}", goofishId, e.getMessage(), e);
log.error("【{}-处理滑块】CDP 执行滑动失败(可能是 JsonObject 序列化或协议异常): {}", goofishId, e.getMessage(), e);
return false;
} finally {
if (cdpSession != null) {
cdpSession.detach();
}
}
}
}

View File

@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -15,6 +16,7 @@ import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.service.GoofishApiService;
import top.biwin.xianyu.goofish.service.GoofishPwdLoginService;
import top.biwin.xianyu.goofish.service.QrLoginService;
import top.biwin.xinayu.common.dto.request.AccountUpdateRequest;
import top.biwin.xinayu.common.dto.request.GoofishAddCookieRequest;
import top.biwin.xinayu.common.dto.request.GoofishPwdLoginRequest;
import top.biwin.xinayu.common.dto.response.BaseResponse;
@ -153,4 +155,51 @@ public class GoofishAccountController {
}
goofishAccountRepository.delete(entity);
}
@PutMapping("/cookies/{id}/remark")
public ResponseEntity<GoofishAccountResponse> updateAccountRemark(@PathVariable("id") String goofishId, @RequestBody AccountUpdateRequest request) {
GoofishAccountEntity account = goofishAccountRepository.findById(goofishId).orElseThrow(() -> new IllegalStateException("账号不存在"));
if (!CurrentUserUtil.isSuperAdmin()) {
if (!Objects.equals(account.getUserId(), CurrentUserUtil.getCurrentUserId())) {
log.warn("该闲鱼账号不归属当前登录用户!");
}
}
account.setRemark(request.getRemark());
goofishAccountRepository.save(account);
return ResponseEntity.ok(GoofishAccountResponse.builder()
.id(account.getId())
.cookie(account.getCookie())
.enabled(account.getEnabled())
.autoConfirm(account.getAutoConfirm())
.remark(account.getRemark())
.pauseDuration(account.getPauseDuration())
.username(account.getUsername())
.loginPassword(account.getPassword())
.showBrowser(account.getShowBrowser() == 1)
.build());
}
@PutMapping("/cookies/{id}/status")
public ResponseEntity<GoofishAccountResponse> updateAccountStatus(@PathVariable("id") String goofishId, @RequestBody AccountUpdateRequest request) {
GoofishAccountEntity account = goofishAccountRepository.findById(goofishId).orElseThrow(() -> new IllegalStateException("账号不存在"));
if (!CurrentUserUtil.isSuperAdmin()) {
if (!Objects.equals(account.getUserId(), CurrentUserUtil.getCurrentUserId())) {
log.warn("该闲鱼账号不归属当前登录用户!");
}
}
account.setEnabled(request.getEnabled());
goofishAccountRepository.save(account);
return ResponseEntity.ok(GoofishAccountResponse.builder()
.id(account.getId())
.cookie(account.getCookie())
.enabled(account.getEnabled())
.autoConfirm(account.getAutoConfirm())
.remark(account.getRemark())
.pauseDuration(account.getPauseDuration())
.username(account.getUsername())
.loginPassword(account.getPassword())
.showBrowser(account.getShowBrowser() == 1)
.build());
}
}

View File

@ -4,6 +4,7 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
@ -11,16 +12,20 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xianyu.core.entity.AiReplySettingEntity;
import top.biwin.xianyu.core.entity.DefaultReplyEntity;
import top.biwin.xianyu.core.entity.GoofishAccountEntity;
import top.biwin.xianyu.core.entity.KeywordEntity;
import top.biwin.xianyu.core.repository.AiReplySettingRepository;
import top.biwin.xianyu.core.repository.DefaultReplyRepository;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.core.repository.KeywordRepository;
import top.biwin.xinayu.common.dto.request.AiSettingRequest;
import top.biwin.xinayu.common.dto.response.DefaultReplyResponse;
import top.biwin.xinayu.common.dto.response.KeywordResponse;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
@ -39,6 +44,8 @@ public class KeywordController {
private AiReplySettingRepository aiReplySettingRepository;
@Autowired
private KeywordRepository keywordRepository;
@Autowired
private DefaultReplyRepository defaultReplyRepository;
@GetMapping("/ai-reply-settings")
@ -52,19 +59,19 @@ public class KeywordController {
return ResponseEntity.ok(settings.stream().collect(Collectors.toMap(AiReplySettingEntity::getGoofishId, s -> s)));
}
@GetMapping("/ai-reply-settings/{accountId}")
public ResponseEntity<AiReplySettingEntity> getAiSetting(@PathVariable String accountId) {
return ResponseEntity.ok(aiReplySettingRepository.findById(accountId).orElse(null));
@GetMapping("/ai-reply-settings/{goofishId}")
public ResponseEntity<AiReplySettingEntity> getAiSetting(@PathVariable String goofishId) {
return ResponseEntity.ok(aiReplySettingRepository.findById(goofishId).orElse(null));
}
@PutMapping("/ai-reply-settings/{accountId}")
public ResponseEntity<AiReplySettingEntity> upsertAiSetting(@PathVariable String accountId, @RequestBody AiSettingRequest request) {
@PutMapping("/ai-reply-settings/{goofishId}")
public ResponseEntity<AiReplySettingEntity> upsertAiSetting(@PathVariable String goofishId, @RequestBody AiSettingRequest request) {
// Upsert 逻辑存在则更新不存在则创建
AiReplySettingEntity entity = aiReplySettingRepository.findById(accountId)
AiReplySettingEntity entity = aiReplySettingRepository.findById(goofishId)
.orElseGet(() -> {
// 不存在时创建新实体
AiReplySettingEntity newEntity = new AiReplySettingEntity();
newEntity.setGoofishId(accountId);
newEntity.setGoofishId(goofishId);
return newEntity;
});
// 忽略 null 防止前端未传的字段覆盖已有值或默认值
@ -72,11 +79,11 @@ public class KeywordController {
return ResponseEntity.ok(aiReplySettingRepository.save(entity));
}
@GetMapping("/keywords-with-item-id/{gid}")
public ResponseEntity<List<KeywordResponse>> getKeywordsWithItemId(@PathVariable Long gid) {
@GetMapping("/keywords-with-item-id/{goofishId}")
public ResponseEntity<List<KeywordResponse>> getKeywordsWithItemId(@PathVariable Long goofishId) {
// 获取关键词列表
List<KeywordEntity> keywordEntities = keywordRepository.findByGoofishId(gid);
List<KeywordEntity> keywordEntities = keywordRepository.findByGoofishId(goofishId);
// 转换为前端需要的格式 Python 实现一致
return ResponseEntity.ok(keywordEntities.stream()
@ -89,4 +96,38 @@ public class KeywordController {
k.getImageUrl()))
.collect(Collectors.toList()));
}
@GetMapping("/default-reply/{goofishId}")
public ResponseEntity<DefaultReplyResponse> getDefaultReply(@PathVariable String goofishId) {
DefaultReplyEntity defaultReply = defaultReplyRepository.findById(goofishId).orElse(null);
DefaultReplyResponse response = DefaultReplyResponse.builder()
.success(true)
.enabled(false)
.replyContent("")
.replyOnce(false)
.replyImageUrl("")
.build();
if (Objects.isNull(defaultReply)) {
return ResponseEntity.ok(response);
}
response.setEnabled(defaultReply.getEnabled());
response.setReplyContent(StringUtils.hasText(defaultReply.getReplyContent()) ? defaultReply.getReplyContent() : "");
response.setReplyOnce(defaultReply.getReplyOnce());
response.setReplyImageUrl(StringUtils.hasText(defaultReply.getReplyImageUrl()) ? defaultReply.getReplyImageUrl() : "");
return ResponseEntity.ok(response);
}
@PutMapping("/default-reply/{goofishId}")
public ResponseEntity<DefaultReplyResponse> updateDefaultReply(@PathVariable String goofishId, @RequestBody DefaultReplyEntity request) {
request.setGoofishId(goofishId);
DefaultReplyEntity saved = defaultReplyRepository.save(request);
return ResponseEntity.ok(DefaultReplyResponse.builder()
.success(true)
.enabled(saved.getEnabled())
.replyContent(StringUtils.hasText(saved.getReplyContent()) ? saved.getReplyContent() : "")
.replyOnce(saved.getReplyOnce())
.replyImageUrl(StringUtils.hasText(saved.getReplyImageUrl()) ? saved.getReplyImageUrl() : "")
.build());
}
}

View File

@ -1,5 +1,5 @@
app:
ddl-auto: create # valid values: none, validate, update, create, create-drop
ddl-auto: update # valid values: none, validate, update, create, create-drop
# 应用验证码配置
verification:
code-length: 6 # 验证码长度6位数字