This commit is contained in:
wangli 2026-01-30 19:30:20 +08:00
parent d15443d0e3
commit 7b2991bc84
8 changed files with 140 additions and 33 deletions

View File

@ -0,0 +1,18 @@
package top.biwin.xinayu.common.dto.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* TODO
*
* @author wangli
* @since 2026-01-30 17:07
*/
@Data
public class ChangePasswordRequest {
@JsonProperty("current_password")
private String currentPassword;
@JsonProperty("new_password")
private String newPassword;
}

View File

@ -38,6 +38,8 @@ import java.util.concurrent.ConcurrentHashMap;
public class BrowserService {
@Value("${browser.persistence-data}")
private String persistenceData;
@Value("${browser.use-installed-browser}")
private Boolean useInstalledBrowser;
@Value("${browser.location}")
private String browserLocation;
@Value("${browser.ua}")
@ -68,12 +70,21 @@ public class BrowserService {
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(args);
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()) {
if (Objects.equals(Boolean.TRUE, useInstalledBrowser)) {
Path chromePath = Paths.get(browserLocation);
if (!chromePath.toFile().exists()) {
throw new IllegalArgumentException("系统启动失败,配置的浏览器软件路径不存在: " + browserLocation);
}
launchOptions.setExecutablePath(chromePath);
} else {
log.debug("尝试判断是否是开发机,直接强制使用安装的正式浏览器...");
String osName = System.getProperty("os.name").toLowerCase();
String osArch = System.getProperty("os.arch").toLowerCase();
if (osName.contains("mac") && (osArch.contains("aarch64") || osArch.contains("x86_64"))) {
Path chromePath = Paths.get("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
if (!chromePath.toFile().exists()) {
throw new IllegalArgumentException("系统启动失败,请先主动安装 Chrome 浏览器!");
}
launchOptions.setExecutablePath(chromePath);
}
}
@ -284,7 +295,7 @@ public class BrowserService {
public void closePersistentContext(String goofishId) {
BrowserContext browserContext = persistentContexts.get(goofishId);
if(Objects.isNull(browserContext)) {
if (Objects.isNull(browserContext)) {
return;
}
browserContext.close();

View File

@ -15,6 +15,7 @@ import top.biwin.xianyu.goofish.util.CookieUtils;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import static top.biwin.xianyu.goofish.BrowserConstant.STEALTH_SCRIPT;
import static top.biwin.xianyu.goofish.util.SliderUtils.attemptSolveSlider;
@ -50,7 +51,9 @@ public class GoofishPwdLoginService {
log.debug("【{}】正在导航至登录页... url: http://www.goofish.com/im", account);
page.navigate("https://www.goofish.com/im");
log.debug("【{}】等待页面加载,查找登录框... url: http://www.goofish.com/im", account);
// 确保页面加载完成
page.getByText("登录后可以更懂你,推荐你喜欢的商品!");
Frame loginFrame = findLoginFrame(page, account);
if (Objects.nonNull(loginFrame)) {
// 开始登录
@ -80,8 +83,18 @@ public class GoofishPwdLoginService {
// 这里点击后可能并不能成功会出现滑块
if (Objects.nonNull(findLoginFrame(page, account))) {
// 内部应该要有重试处理滑块的逻辑
boolean resolveResult = attemptSolveSlider(loginFrame, account);
boolean resolveResult = attemptSolveSlider(loginFrame, account, new AtomicInteger(3));
log.debug("【{}】滑块结果: {}", account, resolveResult ? "成功" : "失败");
if(resolveResult) {
log.debug("【{}】验证是否登录成功...", account);
Frame lastCheckFrame = findLoginFrame(page, account);
if(Objects.nonNull(lastCheckFrame)) {
log.debug("【{}】登录失败,仍存在登录页面...", account);
}
} else {
log.error("【{}】账号密码登录失败", account);
return;
}
}
} else {

View File

@ -3,11 +3,14 @@ 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.Mouse;
import com.microsoft.playwright.options.BoundingBox;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 滑块验证码处理工具类
@ -29,7 +32,7 @@ public final class SliderUtils {
* @param goofishId 闲鱼账号ID用于日志
* @return 是否成功解决滑块
*/
public static boolean attemptSolveSlider(Frame frame, String goofishId) {
public static boolean attemptSolveSlider(Frame frame, String goofishId, AtomicInteger maxRetryCount) {
try {
String[] sliderSelectors = {"#nc_1_n1z", ".nc-container", ".nc_scale", ".nc-wrapper"};
ElementHandle sliderButton = null;
@ -46,7 +49,7 @@ public final class SliderUtils {
// 如果当前Frame没有滑块递归检查子Frame
if (!containerFound && CollUtil.isNotEmpty(frame.childFrames())) {
for (Frame childFrame : frame.childFrames()) {
return attemptSolveSlider(childFrame, goofishId);
return attemptSolveSlider(childFrame, goofishId, maxRetryCount);
}
}
@ -57,7 +60,7 @@ public final class SliderUtils {
if (sliderButton == null) sliderButton = frame.querySelector(".nc_iconfont");
if (sliderButton != null && sliderButton.isVisible()) {
log.info("Login Task】检测到滑块验证Frame: {}", frame.url());
log.info("{}-处理滑块】检测到滑块验证Frame: {}", goofishId, frame.url());
BoundingBox box = sliderButton.boundingBox();
if (box == null) return false;
@ -68,24 +71,34 @@ public final class SliderUtils {
BoundingBox trackBox = track.boundingBox();
double distance = trackBox.width - box.width;
log.info("Login Task】准备解决滑块: 距离={}px", distance);
log.info("{}-处理滑块】准备解决滑块: 距离={}px", goofishId, distance);
// 执行人类模拟滑动丝滑版
boolean success = performSmoothSlide(frame, box, distance);
boolean success = performSmoothSlide(frame, box, distance, goofishId);
// 等待验证结果
Thread.sleep(800 + ThreadLocalRandom.current().nextInt(400));
// 检查滑块是否消失成功标志
if (!sliderButton.isVisible()) {
log.info("【Login Task】滑块验证成功按钮已消失");
return true;
// // 检查滑块是否消失成功标志
// if (!sliderButton.isVisible()) {
// log.info("【{}-处理滑块】滑块验证成功!(按钮已消失)", goofishId);
// return true;
// }
ElementHandle elementHandle = frame.querySelector("#nc_1_refresh1");
if (Objects.nonNull(elementHandle)) {
log.info("【{}-处理滑块】滑块验证失败,点击滑块重试!", goofishId);
elementHandle.click();
if (maxRetryCount.decrementAndGet() > 0) {
return attemptSolveSlider(frame, goofishId, maxRetryCount);
} else {
log.error("【{}-处理滑块】重试次数过多,放弃解决!", goofishId);
return false;
}
}
return success;
}
} catch (Exception e) {
log.warn("【Login Task】解决滑块时出错: {}", e.getMessage());
log.warn("{}-处理滑块】解决滑块时出错: {}", goofishId, e.getMessage(), e);
}
return false;
}
@ -105,7 +118,7 @@ public final class SliderUtils {
* @param distance 需要滑动的距离
* @return 是否执行成功
*/
private static boolean performSmoothSlide(Frame frame, BoundingBox buttonBox, double distance) {
private static boolean performSmoothSlide(Frame frame, BoundingBox buttonBox, double distance, String goofishId) {
try {
ThreadLocalRandom random = ThreadLocalRandom.current();
@ -129,7 +142,7 @@ public final class SliderUtils {
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generateFastTrajectory(distance);
log.debug("Login Task】生成轨迹点数: {}", trajectory.size());
log.debug("{}-处理滑块】生成轨迹点数: {}", goofishId, trajectory.size());
// 步骤6一气呵成快速滑动不加任何sleep
for (BrowserTrajectoryUtils.TrajectoryPoint point : trajectory) {
@ -140,11 +153,11 @@ public final class SliderUtils {
// 步骤7释放鼠标
frame.page().mouse().up();
log.info("Login Task】滑动执行完成,等待验证结果...");
log.info("{}-处理滑块】滑动执行完成,等待验证结果...", goofishId);
return true;
} catch (Exception e) {
log.error("Login Task】执行滑动时出错: {}", e.getMessage());
log.error("{}-处理滑块】执行滑动时出错: {}", goofishId, e.getMessage(), e);
return false;
}
}

View File

@ -2,9 +2,11 @@ package top.biwin.xinayu.server.controller;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xianyu.core.entity.AdminUserEntity;
import top.biwin.xianyu.core.repository.AdminUserRepository;
import top.biwin.xinayu.common.dto.request.ChangePasswordRequest;
import top.biwin.xinayu.common.dto.request.LoginCaptchaRequest;
import top.biwin.xinayu.common.dto.request.LoginRequest;
import top.biwin.xinayu.common.dto.request.RefreshRequest;
@ -31,8 +34,11 @@ import top.biwin.xinayu.server.util.CurrentUserUtil;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import static java.awt.SystemColor.info;
/**
* 认证控制器
* 提供登录刷新令牌等认证相关的 REST API
@ -46,6 +52,7 @@ import java.util.Optional;
* @author wangli
* @since 2026-01-21
*/
@Slf4j
@RestController
@RequestMapping
public class AuthController {
@ -65,6 +72,8 @@ public class AuthController {
@Autowired
private CaptchaService captchaService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户登录接口
@ -285,21 +294,50 @@ public class AuthController {
* 对应 Python: /api/check-default-password
*/
@GetMapping("/api/check-default-password")
public ResponseEntity<CheckDefaultPwdResponse> checkDefaultPassword(HttpServletRequest httpRequest) {
public ResponseEntity<CheckDefaultPwdResponse> checkDefaultPassword() {
CheckDefaultPwdResponse build = CheckDefaultPwdResponse.builder()
.usingDefault(false)
.success(true)
.build();
CheckDefaultPwdResponse build = CheckDefaultPwdResponse.builder()
.usingDefault(false)
.success(true)
.build();
if(!CurrentUserUtil.isSuperAdmin()) {
if (!CurrentUserUtil.isSuperAdmin()) {
return ResponseEntity.ok(build);
}
AdminUserEntity adminUserEntity = authService.verifyUserPassword(ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD);
build.setUsingDefault( adminUserEntity != null);
build.setUsingDefault(adminUserEntity != null);
return ResponseEntity.ok(build);
}
@PostMapping("/change-password")
public ResponseEntity<BaseResponse> changeUserPassword(@RequestBody ChangePasswordRequest request) {
AdminUserEntity currentUser = CurrentUserUtil.getCurrentUser();
if (Objects.isNull(currentUser)) {
return ResponseEntity.ok(new BaseResponse("用户未登录", false));
}
AdminUserEntity adminUserEntity = authService.verifyUserPassword(currentUser.getUsername(), request.getCurrentPassword());
if ( Objects.isNull(adminUserEntity)) {
return ResponseEntity.ok(new BaseResponse("当前密码错误", false));
}
if(passwordEncoder.matches(JwtUtil.generatePasswordHash(request.getNewPassword()), adminUserEntity.getPasswordHash())) {
return ResponseEntity.ok(new BaseResponse("新密码与旧密码相同", false));
}
boolean success = authService.updateUserPassword(currentUser.getUsername(), request.getNewPassword());
BaseResponse response = new BaseResponse();
if (success) {
log.info("【{}#{}】用户密码修改成功", adminUserEntity.getUsername(), adminUserEntity.getId());
response.setMessage("密码修改成功");
response.setSuccess(true);
} else {
response.setSuccess(false);
response.setMessage("密码修改失败");
}
return ResponseEntity.ok(response);
}
}

View File

@ -176,6 +176,10 @@ public class JwtUtil {
return accessTokenExpiration / 1000;
}
public static String generatePasswordHash(String password) {
return new BCryptPasswordEncoder(12).encode(password);
}
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
String hash = encoder.encode("123456");

View File

@ -230,4 +230,12 @@ public class AuthService {
}
return user;
}
public boolean updateUserPassword(String username, String newPassword) {
AdminUserEntity adminUserEntity = adminUserRepository.findByUsername(username)
.orElseThrow(() -> new IllegalStateException("用户不存在"));
adminUserEntity.setPasswordHash(JwtUtil.generatePasswordHash(newPassword));
adminUserRepository.save(adminUserEntity);
return true;
}
}

View File

@ -1,5 +1,5 @@
app:
ddl-auto: update # valid values: none, validate, update, create, create-drop
ddl-auto: create # valid values: none, validate, update, create, create-drop
# 应用验证码配置
verification:
code-length: 6 # 验证码长度6位数字
@ -14,7 +14,7 @@ spring:
datasource:
driver-class-name: org.sqlite.JDBC
url: jdbc:sqlite:./db/xianyu_data.db
url: jdbc:sqlite:db/xianyu_data.db
username:
password:
@ -69,9 +69,11 @@ goofish:
assistant:
static:
uploads:
images : static/uploads/images/
images: static/uploads/images/
browser:
# 使用已安装的浏览器,否则系统会尝试下载 playwright 绑定版本的浏览器,如果设置为 false则系统首次启动会比较久
use-installed-browser: true
# chrome 浏览器软件安装位置
location: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
# 浏览器登录状态数据存储目录