模拟登录第三方接口

This commit is contained in:
2026-06-03 17:38:21 +08:00
parent 6ce698eecc
commit 7b3879d6b6
13 changed files with 423 additions and 0 deletions

View File

@@ -16,8 +16,11 @@ public final class LbThirdIntegrationConstants {
public static final String DEFAULT_USER_UPDATE_PATH = "/app/admin/user/update";
public static final String DEFAULT_GOODS_API_PATH = "/api/order/goods";
public static final String DEFAULT_BUY_API_PATH = "/api/order/buy";
public static final String DEFAULT_FANS_API_PATH = "/api/share/select";
public static final String DEFAULT_LOGIN_API_PATH = "/api/user/login";
public static final int DEFAULT_ORDER_PAGE_LIMIT = 90;
public static final int DEFAULT_USER_PAGE_LIMIT = 90;
public static final int DEFAULT_GOODS_PAGE_LIMIT = 20;
public static final int DEFAULT_FANS_PAGE_LIMIT = 10;
}

View File

@@ -1,5 +1,6 @@
package com.rj.controller;
import com.rj.dto.LbFanSimulateLoginRequest;
import com.rj.entity.LbFanManagement;
import com.rj.service.ILbFanManagementService;
import io.swagger.v3.oas.annotations.Operation;
@@ -49,6 +50,20 @@ public class LbFanManagementController {
return toResponse(result);
}
@PostMapping("/simulate-login")
@Operation(
summary = "批量模拟第三方登录",
description =
"按 tenantId 从 lb_third_integration_config 读取 login_api_path 等配置,"
+ "对每个手机号调用第三方登录接口并解析返回 JSON"
+ "未传 password 时默认密码为 123456")
public ResponseEntity<Map<String, Object>> simulateLogin(
@Parameter(description = "租户 id 与手机号列表", required = true)
@RequestBody LbFanSimulateLoginRequest request) {
Map<String, Object> result = lbFanManagementService.simulateLogin(request);
return toResponse(result);
}
@GetMapping("/list")
@Operation(summary = "分页查询粉丝")
public ResponseEntity<Map<String, Object>> list(

View File

@@ -0,0 +1,23 @@
package com.rj.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 粉丝管理:按手机号批量模拟第三方登录。
*/
@Data
@Schema(description = "粉丝模拟登录请求")
public class LbFanSimulateLoginRequest {
@Schema(description = "租户 id关联 lb_third_integration_config.tenant_id", requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantId;
@Schema(description = "手机号列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> mobiles;
@Schema(description = "登录密码;未传时默认为 123456")
private String password;
}

View File

@@ -0,0 +1,11 @@
package com.rj.dto.hxr;
/**
* 调用 hxrd {@code /api/user/login} 所需的运行时配置(由 {@code lb_third_integration_config} 解析而来)。
*/
public record HxrUserLoginApiContext(
String loginApiUrl,
String origin,
String referer,
String appStr) {
}

View File

@@ -68,6 +68,14 @@ public class LbThirdIntegrationConfig implements Serializable {
@Schema(description = "抢购 API 路径")
private String buyApiPath;
@TableField("fans_api_path")
@Schema(description = "粉丝列表 API 路径")
private String fansApiPath;
@TableField("login_api_path")
@Schema(description = "后台登录 API 路径")
private String loginApiPath;
@TableField("order_page_limit")
@Schema(description = "订单分页 limit")
private Integer orderPageLimit;
@@ -80,6 +88,10 @@ public class LbThirdIntegrationConfig implements Serializable {
@Schema(description = "货品分页 limit")
private Integer goodsPageLimit;
@TableField("fans_page_limit")
@Schema(description = "粉丝分页 limit")
private Integer fansPageLimit;
@TableField("order_referer")
@Schema(description = "订单 Referer")
private String orderReferer;

View File

@@ -0,0 +1,157 @@
package com.rj.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.rj.dto.hxr.HxrUserLoginApiContext;
import com.rj.util.HxrGoodsSignUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 调用 hxrd {@code POST /api/user/login} 模拟登录并解析响应 JSON。
*/
@Slf4j
@Service
public class HxrAdminUserLoginService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0";
private static final ObjectMapper JSON = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
/**
* 对单个手机号发起登录请求。
*
* @param password 非空密码;为空时由调用方传入与手机号相同的默认值
*/
public LoginApiResult login(String mobile, String password, HxrUserLoginApiContext ctx) throws Exception {
if (ctx == null) {
return LoginApiResult.failure(-1, "登录 API 配置为空", null, -1);
}
String loginApiUrl = ctx.loginApiUrl();
if (!StringUtils.hasText(loginApiUrl)) {
return LoginApiResult.failure(-1, "未配置 loginApiUrl", null, -1);
}
if (!StringUtils.hasText(mobile)) {
return LoginApiResult.failure(-1, "手机号不能为空", null, -1);
}
if (!StringUtils.hasText(password)) {
return LoginApiResult.failure(-1, "密码不能为空", null, -1);
}
long timestamp = System.currentTimeMillis() / 1000;
String noncestr = randomNoncestr();
Map<String, Object> body = new LinkedHashMap<>();
body.put("mobile", mobile.trim());
body.put("password", password.trim());
String bodyJson = JSON.writeValueAsString(body);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(loginApiUrl.trim()))
.timeout(Duration.ofSeconds(120))
.header("Accept", "application/json,*/*")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Content-Type", "application/json")
.header("Origin", ctx.origin())
.header("Referer", ctx.referer())
.header("User-Agent", USER_AGENT);
String appStr = ctx.appStr();
if (StringUtils.hasText(appStr)) {
Map<String, Object> signParams = new LinkedHashMap<>();
signParams.put("mobile", mobile.trim());
signParams.put("password", password.trim());
signParams.put("timestamp", timestamp);
signParams.put("noncestr", noncestr);
String sign = HxrGoodsSignUtil.computeSign(signParams, appStr.trim());
builder.header("S", sign)
.header("T", String.valueOf(timestamp))
.header("N", noncestr);
}
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = builder
.POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int httpStatus = response.statusCode();
String bodyText = response.body();
if (httpStatus < 200 || httpStatus >= 300) {
log.warn("hxr /api/user/login HTTP {} mobile={} bodyPrefix={}",
httpStatus, mobile, abbreviate(bodyText, 400));
return LoginApiResult.failure(-1, "HTTP " + httpStatus, bodyText, httpStatus);
}
if (bodyText == null || bodyText.isBlank()) {
log.warn("hxr /api/user/login empty body mobile={}", mobile);
return LoginApiResult.failure(-1, "响应为空", null, httpStatus);
}
JsonNode root = JSON.readTree(bodyText);
int code = root.path("code").asInt(-1);
String msg = root.path("msg").asText("");
Object parsed = JSON.convertValue(root, Map.class);
boolean ok = code == 0;
if (!ok) {
log.warn("hxr /api/user/login api code={} msg={} mobile={} bodyPrefix={}",
code, msg, mobile, abbreviate(bodyText, 200));
}
return new LoginApiResult(ok, code, msg, parsed, httpStatus);
}
public record LoginApiResult(
boolean success,
int apiCode,
String apiMsg,
Object parsed,
int httpStatus) {
public static LoginApiResult failure(int code, String msg, String rawBody, int httpStatus) {
Map<String, Object> parsed = new LinkedHashMap<>();
if (rawBody != null && !rawBody.isBlank()) {
parsed.put("rawBody", abbreviate(rawBody, 2000));
}
return new LoginApiResult(false, code, msg, parsed, httpStatus);
}
}
private static String randomNoncestr() {
String base36 = Long.toUnsignedString(Math.abs(RANDOM.nextLong()), 36);
if (base36.length() >= 5) {
return base36.substring(base36.length() - 5);
}
StringBuilder sb = new StringBuilder(base36);
while (sb.length() < 5) {
sb.append(Integer.toString(RANDOM.nextInt(36), 36));
}
return sb.toString();
}
private static String abbreviate(String s, int maxLen) {
if (s == null) {
return "";
}
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...";
}
}

View File

@@ -1,6 +1,7 @@
package com.rj.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.rj.dto.LbFanSimulateLoginRequest;
import com.rj.entity.LbFanManagement;
import java.util.Map;
@@ -24,4 +25,9 @@ public interface ILbFanManagementService extends IService<LbFanManagement> {
Integer shareNumMax,
String createdAtStart,
String createdAtEnd);
/**
* 按手机号列表模拟第三方登录:根据 tenantId 读取 lb_third_integration_config.login_api_path 调用接口并解析 JSON。
*/
Map<String, Object> simulateLogin(LbFanSimulateLoginRequest request);
}

View File

@@ -5,6 +5,7 @@ import com.rj.dto.LbThirdIntegrationCredentialUpdateRequest;
import com.rj.dto.hxr.HxrAdminOrderApiContext;
import com.rj.dto.hxr.HxrAdminUserApiContext;
import com.rj.dto.hxr.HxrGoodsApiContext;
import com.rj.dto.hxr.HxrUserLoginApiContext;
import com.rj.entity.LbThirdIntegrationConfig;
import java.util.Map;
@@ -54,4 +55,9 @@ public interface ILbThirdIntegrationConfigService extends IService<LbThirdIntegr
* 按租户 id 解析后台订单 API 运行时配置(含 cookie/phpsid、URL、Referer
*/
Optional<HxrAdminOrderApiContext> resolveAdminOrderApiContext(String tenantId);
/**
* 按租户 id 解析用户登录 API 运行时配置login_api_path、Origin、Referer、appStr
*/
Optional<HxrUserLoginApiContext> resolveUserLoginApiContext(String tenantId);
}

View File

@@ -3,18 +3,27 @@ package com.rj.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rj.dto.LbFanSimulateLoginRequest;
import com.rj.dto.hxr.HxrUserLoginApiContext;
import com.rj.entity.LbFanManagement;
import com.rj.mapper.LbFanManagementMapper;
import com.rj.service.HxrAdminUserLoginService;
import com.rj.service.ILbFanManagementService;
import com.rj.service.ILbThirdIntegrationConfigService;
import com.rj.tenant.TenantContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 粉丝管理服务实现
@@ -25,6 +34,14 @@ public class LbFanManagementServiceImpl
extends ServiceImpl<LbFanManagementMapper, LbFanManagement>
implements ILbFanManagementService {
@Autowired
private ILbThirdIntegrationConfigService lbThirdIntegrationConfigService;
@Autowired
private HxrAdminUserLoginService hxrAdminUserLoginService;
private static final String DEFAULT_SIMULATE_LOGIN_PASSWORD = "123456";
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -208,6 +225,97 @@ public class LbFanManagementServiceImpl
}
}
@Override
public Map<String, Object> simulateLogin(LbFanSimulateLoginRequest request) {
Map<String, Object> result = new HashMap<>();
try {
if (request == null) {
result.put("success", false);
result.put("message", "请求体不能为空");
return result;
}
if (request.getTenantId() == null || request.getTenantId().trim().isEmpty()) {
result.put("success", false);
result.put("message", "tenantId不能为空");
return result;
}
if (request.getMobiles() == null || request.getMobiles().isEmpty()) {
result.put("success", false);
result.put("message", "手机号列表不能为空");
return result;
}
String tenantId = request.getTenantId().trim();
Optional<HxrUserLoginApiContext> ctxOpt =
lbThirdIntegrationConfigService.resolveUserLoginApiContext(tenantId);
if (ctxOpt.isEmpty()) {
result.put("success", false);
result.put("message",
"未找到该租户的第三方集成配置或配置未启用、URL 不完整(请检查 lb_third_integration_config");
return result;
}
HxrUserLoginApiContext ctx = ctxOpt.get();
List<Map<String, Object>> items = new ArrayList<>();
int successCount = 0;
int failCount = 0;
String requestPassword = request.getPassword() != null ? request.getPassword().trim() : "";
for (String rawMobile : request.getMobiles()) {
Map<String, Object> item = new LinkedHashMap<>();
if (rawMobile == null || rawMobile.trim().isEmpty()) {
item.put("mobile", rawMobile);
item.put("loginSuccess", false);
item.put("apiCode", -1);
item.put("apiMsg", "手机号为空");
items.add(item);
failCount++;
continue;
}
String mobile = rawMobile.trim();
item.put("mobile", mobile);
String password = !requestPassword.isEmpty()
? requestPassword
: DEFAULT_SIMULATE_LOGIN_PASSWORD;
try {
HxrAdminUserLoginService.LoginApiResult loginResult =
hxrAdminUserLoginService.login(mobile, password, ctx);
item.put("httpStatus", loginResult.httpStatus());
item.put("apiCode", loginResult.apiCode());
item.put("apiMsg", loginResult.apiMsg());
item.put("loginSuccess", loginResult.success());
item.put("parsed", loginResult.parsed());
if (loginResult.success()) {
successCount++;
} else {
failCount++;
}
} catch (Exception e) {
log.warn("粉丝模拟登录异常 mobile={} tenantId={}", mobile, tenantId, e);
item.put("loginSuccess", false);
item.put("apiCode", -1);
item.put("apiMsg", "请求异常:" + e.getMessage());
failCount++;
}
items.add(item);
}
result.put("success", true);
result.put("message", "模拟登录完成");
result.put("data", items);
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("loginApiUrl", ctx.loginApiUrl());
return result;
} catch (Exception e) {
log.error("粉丝模拟登录异常", e);
result.put("success", false);
result.put("message", "模拟登录异常:" + e.getMessage());
return result;
}
}
/**
* 生成唯一IDid + '_' + tenant_id
*/

View File

@@ -9,6 +9,7 @@ import com.rj.dto.LbThirdIntegrationCredentialUpdateRequest;
import com.rj.dto.hxr.HxrAdminOrderApiContext;
import com.rj.dto.hxr.HxrAdminUserApiContext;
import com.rj.dto.hxr.HxrGoodsApiContext;
import com.rj.dto.hxr.HxrUserLoginApiContext;
import com.rj.entity.LbThirdIntegrationConfig;
import com.rj.mapper.LbThirdIntegrationConfigMapper;
import com.rj.service.ILbThirdIntegrationConfigService;
@@ -371,6 +372,31 @@ public class LbThirdIntegrationConfigServiceImpl
}
}
@Override
public Optional<HxrUserLoginApiContext> resolveUserLoginApiContext(String tenantId) {
if (!StringUtils.hasText(tenantId)) {
return Optional.empty();
}
LbThirdIntegrationConfig config = getByTenantIdOrNull(tenantId.trim());
if (config == null) {
return Optional.empty();
}
if (config.getEnabled() == null || config.getEnabled() != 1) {
return Optional.empty();
}
try {
String appStr = config.getGoodsApiAppStr();
return Optional.of(new HxrUserLoginApiContext(
LbThirdIntegrationConfigUtil.resolveLoginApiUrl(config),
LbThirdIntegrationConfigUtil.resolveGoodsApiOrigin(config),
LbThirdIntegrationConfigUtil.resolveGoodsApiReferer(config),
StringUtils.hasText(appStr) ? appStr.trim() : null));
} catch (IllegalStateException e) {
return Optional.empty();
}
}
@Override
public Optional<HxrAdminOrderApiContext> resolveAdminOrderApiContext(String tenantId) {
if (!StringUtils.hasText(tenantId)) {
@@ -523,6 +549,12 @@ public class LbThirdIntegrationConfigServiceImpl
if (!StringUtils.hasText(entity.getBuyApiPath())) {
entity.setBuyApiPath(LbThirdIntegrationConstants.DEFAULT_BUY_API_PATH);
}
if (!StringUtils.hasText(entity.getFansApiPath())) {
entity.setFansApiPath(LbThirdIntegrationConstants.DEFAULT_FANS_API_PATH);
}
if (!StringUtils.hasText(entity.getLoginApiPath())) {
entity.setLoginApiPath(LbThirdIntegrationConstants.DEFAULT_LOGIN_API_PATH);
}
if (entity.getOrderPageLimit() == null) {
entity.setOrderPageLimit(LbThirdIntegrationConstants.DEFAULT_ORDER_PAGE_LIMIT);
}
@@ -532,6 +564,9 @@ public class LbThirdIntegrationConfigServiceImpl
if (entity.getGoodsPageLimit() == null) {
entity.setGoodsPageLimit(LbThirdIntegrationConstants.DEFAULT_GOODS_PAGE_LIMIT);
}
if (entity.getFansPageLimit() == null) {
entity.setFansPageLimit(LbThirdIntegrationConstants.DEFAULT_FANS_PAGE_LIMIT);
}
if (!StringUtils.hasText(entity.getAuthType())) {
entity.setAuthType(LbThirdIntegrationConstants.AUTH_COOKIE_PHPSID);
}

View File

@@ -60,6 +60,40 @@ public final class LbThirdIntegrationConfigUtil {
return limit;
}
public static String resolveFansApiBaseUrl(LbThirdIntegrationConfig config) {
String base = trimTrailingSlash(requireText(config.getAdminBaseUrl(), "adminBaseUrl"));
String path = config.getFansApiPath();
if (!StringUtils.hasText(path)) {
path = LbThirdIntegrationConstants.DEFAULT_FANS_API_PATH;
}
path = path.trim();
if (!path.startsWith("/")) {
path = "/" + path;
}
return base + path;
}
public static String resolveLoginApiUrl(LbThirdIntegrationConfig config) {
String base = trimTrailingSlash(requireText(config.getAdminBaseUrl(), "adminBaseUrl"));
String path = config.getLoginApiPath();
if (!StringUtils.hasText(path)) {
path = LbThirdIntegrationConstants.DEFAULT_LOGIN_API_PATH;
}
path = path.trim();
if (!path.startsWith("/")) {
path = "/" + path;
}
return base + path;
}
public static int resolveFansPageLimit(LbThirdIntegrationConfig config) {
Integer limit = config.getFansPageLimit();
if (limit == null || limit < 1) {
return LbThirdIntegrationConstants.DEFAULT_FANS_PAGE_LIMIT;
}
return limit;
}
public static String resolveUserSelectBaseUrl(LbThirdIntegrationConfig config) {
String base = trimTrailingSlash(requireText(config.getAdminBaseUrl(), "adminBaseUrl"));
String path = config.getUserSelectPath();

View File

@@ -16,10 +16,13 @@ CREATE TABLE `lb_third_integration_config` (
`user_update_path` VARCHAR(128) NOT NULL DEFAULT '/app/admin/user/update' COMMENT '用户更新 API 路径',
`goods_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/order/goods' COMMENT '货品列表 API 路径',
`buy_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/order/buy' COMMENT '抢购 API 路径',
`fans_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/share/select' COMMENT '粉丝列表 API 路径',
`login_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/user/login' COMMENT '后台登录 API 路径',
`order_page_limit` INT NOT NULL DEFAULT 90 COMMENT '订单分页 limit',
`user_page_limit` INT NOT NULL DEFAULT 90 COMMENT '用户分页 limit',
`goods_page_limit` INT NOT NULL DEFAULT 20 COMMENT '货品分页 limit',
`fans_page_limit` INT NOT NULL DEFAULT 10 COMMENT '粉丝分页 limit',
`order_referer` VARCHAR(512) DEFAULT NULL COMMENT '订单 Referer空则 {admin_base_url}/app/admin/order/index',
`user_referer` VARCHAR(512) DEFAULT NULL COMMENT '用户 Referer空则 {admin_base_url}/app/admin/user/index',
`goods_api_origin` VARCHAR(256) DEFAULT NULL COMMENT '货品 Origin空则 web_base_url',

View File

@@ -0,0 +1,10 @@
-- 升级脚本lb_third_integration_config 增加粉丝/登录 API 配置列
SET NAMES utf8mb4;
ALTER TABLE `lb_third_integration_config`
ADD COLUMN `fans_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/share/select'
COMMENT '粉丝列表 API 路径' AFTER `buy_api_path`,
ADD COLUMN `login_api_path` VARCHAR(128) NOT NULL DEFAULT '/api/user/login'
COMMENT '后台登录 API 路径' AFTER `fans_api_path`,
ADD COLUMN `fans_page_limit` INT NOT NULL DEFAULT 10
COMMENT '粉丝分页 limit' AFTER `goods_page_limit`;