feat: 店铺密码检测任务(后台发起→客户端领取→紫鸟真实登录判定→回传结果)

- 新增 biz_shop_credential_check 表(V99)与 ShopCredentialCheck 全链路
- Controller /api/admin/shop-credential-checks(create/poll/report/latest,X-Internal-Token 鉴权)
- ShopManageService 列表附带 latestCheck(每店铺最近一次检测结果)
- 客户端通过 poll 领取(60s 轮询、原子置 RUNNING),报告 SUCCESS/FAILED/OTP_REQUIRED/NO_NEED_LOGIN/ERROR
This commit is contained in:
2026-08-31 17:02:49 +08:00
parent 26f4bb3f25
commit 7f4737c800
12 changed files with 667 additions and 1 deletions
@@ -0,0 +1,82 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopCredentialCheckCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopCredentialCheckReportRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckClaimVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckVo;
import com.nanri.aiimage.modules.shopkey.service.ShopCredentialCheckService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 店铺密码检测:后台发起任务,在线客户端轮询领取并真实执行,结果回传后台展示。
* 所有端点均要求 X-Internal-Token(与 /credential 一致),仅供内部自动化调用。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/shop-credential-checks")
@Tag(name = "店铺密码检测", description = "后台发起、客户端执行、回传展示")
public class ShopCredentialCheckController {
@Value("${aiimage.security.internal-token:}")
private String internalToken;
private final ShopCredentialCheckService shopCredentialCheckService;
@PostMapping
@Operation(summary = "发起店铺密码检测", description = "同一店铺存在未完成检测时复用已有任务")
public ApiResponse<ShopCredentialCheckVo> create(
@Valid @RequestBody ShopCredentialCheckCreateRequest request,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
requireInternalToken(token);
return ApiResponse.success("检测任务已创建", shopCredentialCheckService.create(request.getShopName()));
}
@GetMapping("/poll")
@Operation(summary = "客户端轮询领取待执行检测任务", description = "无任务返回 data=null")
public ApiResponse<ShopCredentialCheckClaimVo> poll(
@RequestParam(value = "clientHost", required = false) String clientHost,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
requireInternalToken(token);
return ApiResponse.success(shopCredentialCheckService.claimForClient(clientHost));
}
@PostMapping("/{id}/report")
@Operation(summary = "客户端回传检测结果")
public ApiResponse<Void> report(
@Parameter(description = "检测任务 ID", required = true) @PathVariable Long id,
@Valid @RequestBody(required = false) ShopCredentialCheckReportRequest request,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
requireInternalToken(token);
shopCredentialCheckService.report(id, request);
return ApiResponse.success("检测结果已记录", null);
}
@GetMapping("/latest")
@Operation(summary = "查询店铺最近一次检测结果", description = "店铺无检测记录时返回 data=null")
public ApiResponse<ShopCredentialCheckVo> latest(
@RequestParam("shopId") Long shopId,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
requireInternalToken(token);
return ApiResponse.success(shopCredentialCheckService.latestByShopId(shopId));
}
private void requireInternalToken(String token) {
if (internalToken == null || internalToken.isBlank() || token == null || !internalToken.equals(token)) {
throw new com.nanri.aiimage.common.exception.BusinessException("无权访问");
}
}
}
@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopCredentialCheckEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ShopCredentialCheckMapper extends BaseMapper<ShopCredentialCheckEntity> {
}
@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "发起店铺密码检测请求")
public class ShopCredentialCheckCreateRequest {
@NotBlank(message = "店铺名称不能为空")
@Schema(description = "店铺名称,按 biz_shop_manage.shop_name 定位", example = "美国站-主营")
private String shopName;
}
@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "客户端回传密码检测结果")
public class ShopCredentialCheckReportRequest {
@Schema(description = "检测结果:SUCCESS=密码正确;FAILED=密码错误;NO_NEED_LOGIN=店铺已登录态(无法直接判定密码);ERROR=打开店铺/执行异常", example = "SUCCESS")
private String status;
@Schema(description = "结果详情,用于后台展示与排查", example = "账号或密码错误,请重试")
private String detail;
@Schema(description = "校验失败时的登录接口返回体摘要", example = "{\"error\":\"Incorrect password\"}")
private String raw;
@Schema(description = "客户端主机标识", example = "PC-2024001")
private String clientHost;
}
@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_shop_credential_check")
public class ShopCredentialCheckEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("shop_id")
private Long shopId;
@TableField("shop_name")
private String shopName;
private String status;
private String detail;
@TableField("client_host")
private String clientHost;
@TableField("try_requested_at")
private LocalDateTime tryRequestedAt;
@TableField("check_started_at")
private LocalDateTime checkStartedAt;
@TableField("check_finished_at")
private LocalDateTime checkFinishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,26 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 客户端轮询领取到的待执行密码检测任务(不包含任何敏感信息)。
*/
@Data
@Schema(description = "客户端领取的密码检测任务")
public class ShopCredentialCheckClaimVo {
@Schema(description = "检测任务 ID", example = "31")
private Long id;
@Schema(description = "店铺名称")
private String shopName;
@Schema(description = "紫鸟账号(znUsername 为空时客户端用默认)")
private String znUsername;
@Schema(description = "发起时间")
private LocalDateTime tryRequestedAt;
}
@@ -0,0 +1,38 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "店铺密码检测任务视图")
public class ShopCredentialCheckVo {
@Schema(description = "检测任务 ID", example = "31")
private Long id;
@Schema(description = "店铺 ID")
private Long shopId;
@Schema(description = "店铺名称")
private String shopName;
@Schema(description = "状态:PENDING/RUNNING/SUCCESS/FAILED/NO_NEED_LOGIN/ERROR")
private String status;
@Schema(description = "结果详情")
private String detail;
@Schema(description = "执行客户端标识")
private String clientHost;
@Schema(description = "发起时间")
private LocalDateTime tryRequestedAt;
@Schema(description = "执行开始时间")
private LocalDateTime checkStartedAt;
@Schema(description = "执行完成时间")
private LocalDateTime checkFinishedAt;
}
@@ -16,6 +16,8 @@ public class ShopManageItemVo {
private String account;
private String password;
private String passwordMasked;
/** 最近一次密码检测结果视图;从未检测过为 null。 */
private ShopCredentialCheckVo latestCheck;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,230 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopCredentialCheckReportRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopCredentialCheckEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckClaimVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 店铺密码检测任务:后台发起 → 在线客户端轮询领取 → 真实打开紫鸟店铺
* 并尝试登录亚马逊 → 回传结果 → 后台店铺管理页展示。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ShopCredentialCheckService {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_NO_NEED_LOGIN = "NO_NEED_LOGIN";
public static final String STATUS_ERROR = "ERROR";
/** 客户端领取任务时最久保留的 PENDING 老任务(超过则标记过期)。 */
private static final int PENDING_ACCEPT_MINUTES = 60;
/** RUNNING 执行超时(客户端崩溃/断网),超过则回收为 PENDING 供其他客户端重试。 */
private static final int RUNNING_STALE_MINUTES = 30;
private final ShopCredentialCheckMapper shopCredentialCheckMapper;
private final ShopManageMapper shopManageMapper;
@Transactional
public ShopCredentialCheckVo create(String shopName) {
ShopManageEntity shop = requireShopByName(shopName);
// 同一店铺已有未完成任务(PENDING/RUNNING)时复用,避免重复弹出多个浏览器窗口
ShopCredentialCheckEntity active = shopCredentialCheckMapper.selectOne(new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getShopId, shop.getId())
.in(ShopCredentialCheckEntity::getStatus, STATUS_PENDING, STATUS_RUNNING)
.orderByDesc(ShopCredentialCheckEntity::getId)
.last("limit 1"));
if (active != null) {
return toVo(active);
}
ShopCredentialCheckEntity entity = new ShopCredentialCheckEntity();
entity.setShopId(shop.getId());
entity.setShopName(shop.getShopName());
entity.setStatus(STATUS_PENDING);
entity.setTryRequestedAt(LocalDateTime.now());
shopCredentialCheckMapper.insert(entity);
log.info("[shop-credential-check] created id={} shopId={} shopName={}", entity.getId(), shop.getId(), shop.getShopName());
return toVo(entity);
}
/**
* 客户端轮询领取:返回一条 PENDING 任务(跨店铺按 id 升序),
* 并原子置为 RUNNING;无任务时返回 null。
*/
@Transactional
public ShopCredentialCheckClaimVo claimForClient(String clientHost) {
recycleStaleRunning();
expireAbandonedPending();
ShopCredentialCheckEntity pending = shopCredentialCheckMapper.selectOne(new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getStatus, STATUS_PENDING)
.gt(ShopCredentialCheckEntity::getTryRequestedAt, LocalDateTime.now().minusMinutes(PENDING_ACCEPT_MINUTES))
.orderByAsc(ShopCredentialCheckEntity::getId)
.last("limit 1"));
if (pending == null) {
return null;
}
int updated = shopCredentialCheckMapper.update(null, new LambdaUpdateWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getId, pending.getId())
.eq(ShopCredentialCheckEntity::getStatus, STATUS_PENDING)
.set(ShopCredentialCheckEntity::getStatus, STATUS_RUNNING)
.set(ShopCredentialCheckEntity::getCheckStartedAt, LocalDateTime.now())
.set(ShopCredentialCheckEntity::getClientHost, clientHost));
if (updated == 0) {
// 被其他客户端抢先领取
return null;
}
log.info("[shop-credential-check] claimed id={} shopName={} clientHost={}", pending.getId(), pending.getShopName(), clientHost);
ShopCredentialCheckClaimVo vo = new ShopCredentialCheckClaimVo();
vo.setId(pending.getId());
vo.setShopName(pending.getShopName());
vo.setTryRequestedAt(pending.getTryRequestedAt());
try {
vo.setZnUsername(findZnUsernameByShopName(pending.getShopName()));
} catch (Exception ex) {
log.warn("[shop-credential-check] resolve znUsername failed shopName={} msg={}", pending.getShopName(), ex.getMessage());
}
return vo;
}
@Transactional
public void report(Long id, ShopCredentialCheckReportRequest request) {
ShopCredentialCheckEntity entity = getById(id);
if (!STATUS_RUNNING.equals(entity.getStatus())) {
log.warn("[shop-credential-check] ignore stale report id={} currentStatus={}", id, entity.getStatus());
return;
}
String status = request == null ? null : request.getStatus();
if (!List.of(STATUS_SUCCESS, STATUS_FAILED, STATUS_NO_NEED_LOGIN, STATUS_ERROR).contains(status)) {
throw new BusinessException("不支持的检测结果状态: " + status);
}
entity.setStatus(status);
entity.setDetail(request.getDetail());
entity.setClientHost(firstNonBlank(request.getClientHost(), entity.getClientHost()));
entity.setCheckFinishedAt(LocalDateTime.now());
shopCredentialCheckMapper.updateById(entity);
log.info("[shop-credential-check] reported id={} shopName={} status={} detail={}",
id, entity.getShopName(), status, request.getDetail());
}
public ShopCredentialCheckVo latestByShopId(Long shopId) {
if (shopId == null || shopId <= 0) {
return null;
}
ShopCredentialCheckEntity entity = shopCredentialCheckMapper.selectOne(new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getShopId, shopId)
.orderByDesc(ShopCredentialCheckEntity::getId)
.last("limit 1"));
return entity == null ? null : toVo(entity);
}
private ShopCredentialCheckEntity getById(Long id) {
ShopCredentialCheckEntity entity = shopCredentialCheckMapper.selectById(id);
if (entity == null) {
throw new BusinessException("密码检测任务不存在");
}
return entity;
}
private ShopManageEntity requireShopByName(String shopName) {
String normalized = shopName == null ? "" : shopName.trim();
if (normalized.isEmpty()) {
throw new BusinessException("店铺名称不能为空");
}
ShopManageEntity entity = shopManageMapper.selectOne(new LambdaQueryWrapper<ShopManageEntity>()
.eq(ShopManageEntity::getShopName, normalized)
.last("limit 1"));
if (entity == null) {
throw new BusinessException("后台店铺管理中未找到店铺:" + normalized + ",请先添加店铺信息");
}
return entity;
}
private String findZnUsernameByShopName(String shopName) {
String normalized = shopName == null ? "" : shopName.trim();
if (normalized.isEmpty()) {
return null;
}
ShopManageEntity entity = shopManageMapper.selectOne(new LambdaQueryWrapper<ShopManageEntity>()
.select(ShopManageEntity::getZnUsername)
.eq(ShopManageEntity::getShopName, normalized)
.last("limit 1"));
return entity == null ? null : entity.getZnUsername();
}
/**
* 客户端执行超时(崩溃/断网)的 RUNNING 回收为 PENDING,供其他在线客户端重试。
*/
private void recycleStaleRunning() {
List<ShopCredentialCheckEntity> stale = shopCredentialCheckMapper.selectList(new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getStatus, STATUS_RUNNING)
.lt(ShopCredentialCheckEntity::getCheckStartedAt, LocalDateTime.now().minusMinutes(RUNNING_STALE_MINUTES)));
for (ShopCredentialCheckEntity entity : stale) {
int updated = shopCredentialCheckMapper.update(null, new LambdaUpdateWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getId, entity.getId())
.eq(ShopCredentialCheckEntity::getStatus, STATUS_RUNNING)
.set(ShopCredentialCheckEntity::getStatus, STATUS_PENDING)
.set(ShopCredentialCheckEntity::getCheckStartedAt, null));
if (updated > 0) {
log.warn("[shop-credential-check] recycled stale RUNNING id={} shopName={} clientHost={}",
entity.getId(), entity.getShopName(), entity.getClientHost());
}
}
}
/**
* 后台发起后长时间无人领取的 PENDING 标记 ERROR;客户端领取入口只捡 60 分钟内新发的,
* 这里只清理历史残留,防止 PENDING 无限堆积。
*/
private void expireAbandonedPending() {
List<ShopCredentialCheckEntity> abandoned = shopCredentialCheckMapper.selectList(new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getStatus, STATUS_PENDING)
.le(ShopCredentialCheckEntity::getTryRequestedAt, LocalDateTime.now().minusMinutes(PENDING_ACCEPT_MINUTES))
.last("limit 50"));
for (ShopCredentialCheckEntity entity : abandoned) {
int updated = shopCredentialCheckMapper.update(null, new LambdaUpdateWrapper<ShopCredentialCheckEntity>()
.eq(ShopCredentialCheckEntity::getId, entity.getId())
.eq(ShopCredentialCheckEntity::getStatus, STATUS_PENDING)
.set(ShopCredentialCheckEntity::getStatus, STATUS_ERROR)
.set(ShopCredentialCheckEntity::getDetail, "超过 " + PENDING_ACCEPT_MINUTES + " 分钟无在线客户端领取,已自动过期")
.set(ShopCredentialCheckEntity::getCheckFinishedAt, LocalDateTime.now()));
if (updated > 0) {
log.warn("[shop-credential-check] expired abandoned PENDING id={} shopName={}", entity.getId(), entity.getShopName());
}
}
}
private ShopCredentialCheckVo toVo(ShopCredentialCheckEntity entity) {
ShopCredentialCheckVo vo = new ShopCredentialCheckVo();
vo.setId(entity.getId());
vo.setShopId(entity.getShopId());
vo.setShopName(entity.getShopName());
vo.setStatus(entity.getStatus());
vo.setDetail(entity.getDetail());
vo.setClientHost(entity.getClientHost());
vo.setTryRequestedAt(entity.getTryRequestedAt());
vo.setCheckStartedAt(entity.getCheckStartedAt());
vo.setCheckFinishedAt(entity.getCheckFinishedAt());
return vo;
}
private String firstNonBlank(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}
@@ -3,11 +3,14 @@ package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopCredentialCheckEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
@@ -15,6 +18,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -26,6 +30,7 @@ public class ShopManageService {
private final ShopManageMapper shopManageMapper;
private final ShopManageGroupService shopManageGroupService;
private final ShopCredentialCryptoService shopCredentialCryptoService;
private final ShopCredentialCheckMapper shopCredentialCheckMapper;
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
@@ -69,8 +74,14 @@ public class ShopManageService {
.distinct()
.toList());
Map<Long, ShopCredentialCheckVo> latestCheckByShopId = buildLatestCheckMap(rows);
List<ShopManageItemVo> items = rows.stream()
.map(entity -> toItemVo(entity, groupNameById.get(entity.getGroupId())))
.map(entity -> {
ShopManageItemVo vo = toItemVo(entity, groupNameById.get(entity.getGroupId()));
vo.setLatestCheck(latestCheckByShopId.get(entity.getId()));
return vo;
})
.toList();
ShopManagePageVo vo = new ShopManagePageVo();
vo.setItems(items);
@@ -188,6 +199,43 @@ public class ShopManageService {
return entity;
}
/**
* 一次查询本页所有店铺 id 的检测记录(id 倒序),取每个店铺 id 的第一条即最近一次。
*/
private Map<Long, ShopCredentialCheckVo> buildLatestCheckMap(List<ShopManageEntity> rows) {
List<Long> shopIds = rows.stream()
.map(ShopManageEntity::getId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (shopIds.isEmpty()) {
return Map.of();
}
List<ShopCredentialCheckEntity> checks = shopCredentialCheckMapper.selectList(
new LambdaQueryWrapper<ShopCredentialCheckEntity>()
.in(ShopCredentialCheckEntity::getShopId, shopIds)
.orderByDesc(ShopCredentialCheckEntity::getId));
Map<Long, ShopCredentialCheckVo> map = new LinkedHashMap<>();
for (ShopCredentialCheckEntity check : checks) {
map.putIfAbsent(check.getShopId(), toCheckVo(check));
}
return map;
}
private ShopCredentialCheckVo toCheckVo(ShopCredentialCheckEntity entity) {
ShopCredentialCheckVo vo = new ShopCredentialCheckVo();
vo.setId(entity.getId());
vo.setShopId(entity.getShopId());
vo.setShopName(entity.getShopName());
vo.setStatus(entity.getStatus());
vo.setDetail(entity.getDetail());
vo.setClientHost(entity.getClientHost());
vo.setTryRequestedAt(entity.getTryRequestedAt());
vo.setCheckStartedAt(entity.getCheckStartedAt());
vo.setCheckFinishedAt(entity.getCheckFinishedAt());
return vo;
}
private void validateGroupAccess(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
}
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `biz_shop_credential_check` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`shop_id` BIGINT NOT NULL,
`shop_name` VARCHAR(255) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'PENDING',
`detail` VARCHAR(1024) NULL,
`client_host` VARCHAR(255) NULL,
`try_requested_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`check_started_at` DATETIME NULL,
`check_finished_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_shop_credential_check_shop_status` (`shop_id`, `status`, `id`),
KEY `idx_shop_credential_check_status_id` (`status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,147 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopCredentialCheckReportRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopCredentialCheckEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopCredentialCheckClaimVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ShopCredentialCheckServiceTest {
@Mock
private ShopCredentialCheckMapper shopCredentialCheckMapper;
@Mock
private ShopManageMapper shopManageMapper;
@InjectMocks
private ShopCredentialCheckService service;
@BeforeEach
void initTableInfo() {
// MyBatis-Plus Lambda 缓存依赖 TableInfo,单测环境需手动初始化(对应实体)
initTable(ShopCredentialCheckEntity.class);
initTable(ShopManageEntity.class);
}
private void initTable(Class<?> entityClass) {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, entityClass);
}
@Test
void createReusesActiveTaskForSameShop() {
ShopManageEntity shop = new ShopManageEntity();
shop.setId(7L);
shop.setShopName("美国站-主营");
when(shopManageMapper.selectOne(any())).thenReturn(shop);
ShopCredentialCheckEntity active = new ShopCredentialCheckEntity();
active.setId(3L);
active.setShopId(7L);
active.setShopName("美国站-主营");
active.setStatus(ShopCredentialCheckService.STATUS_RUNNING);
when(shopCredentialCheckMapper.selectOne(any())).thenReturn(active);
var vo = service.create("美国站-主营");
assertEquals(3L, vo.getId());
assertEquals("RUNNING", vo.getStatus());
verify(shopCredentialCheckMapper, never()).insert(any(ShopCredentialCheckEntity.class));
}
@Test
void createRejectsUnknownShop() {
when(shopManageMapper.selectOne(any())).thenReturn(null);
BusinessException ex = assertThrows(BusinessException.class, () -> service.create("不存在店铺"));
assertEquals("后台店铺管理中未找到店铺:不存在店铺,请先添加店铺信息", ex.getMessage());
}
@Test
void claimSetsRunningAndReturnsZnUsername() {
ShopCredentialCheckEntity pending = new ShopCredentialCheckEntity();
pending.setId(9L);
pending.setShopName("店铺-测试");
when(shopCredentialCheckMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(java.util.List.of());
when(shopCredentialCheckMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(pending);
when(shopCredentialCheckMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1);
ShopManageEntity shop = new ShopManageEntity();
shop.setZnUsername("zn-user-1");
when(shopManageMapper.selectOne(any())).thenReturn(shop);
ShopCredentialCheckClaimVo vo = service.claimForClient("PC-01");
assertNotNull(vo);
assertEquals(9L, vo.getId());
assertEquals("店铺-测试", vo.getShopName());
assertEquals("zn-user-1", vo.getZnUsername());
verify(shopCredentialCheckMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void claimReturnsNullWhenNothingPending() {
when(shopCredentialCheckMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(java.util.List.of());
when(shopCredentialCheckMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
assertNull(service.claimForClient("PC-01"));
verify(shopCredentialCheckMapper, never()).update(any(), any(LambdaUpdateWrapper.class));
}
@Test
void reportIgnoresStaleStatus() {
ShopCredentialCheckEntity finished = new ShopCredentialCheckEntity();
finished.setId(5L);
finished.setStatus(ShopCredentialCheckService.STATUS_SUCCESS);
when(shopCredentialCheckMapper.selectById(5L)).thenReturn(finished);
ShopCredentialCheckReportRequest request = new ShopCredentialCheckReportRequest();
request.setStatus(ShopCredentialCheckService.STATUS_FAILED);
request.setDetail("密码错误");
service.report(5L, request);
verify(shopCredentialCheckMapper, never()).updateById(any(ShopCredentialCheckEntity.class));
}
@Test
void reportRecordsFailureDetail() {
ShopCredentialCheckEntity running = new ShopCredentialCheckEntity();
running.setId(6L);
running.setStatus(ShopCredentialCheckService.STATUS_RUNNING);
when(shopCredentialCheckMapper.selectById(6L)).thenReturn(running);
ShopCredentialCheckReportRequest request = new ShopCredentialCheckReportRequest();
request.setStatus(ShopCredentialCheckService.STATUS_FAILED);
request.setDetail("账号或密码错误");
request.setClientHost("PC-02");
service.report(6L, request);
verify(shopCredentialCheckMapper, times(1)).updateById(running);
assertEquals(ShopCredentialCheckService.STATUS_FAILED, running.getStatus());
assertEquals("账号或密码错误", running.getDetail());
assertEquals("PC-02", running.getClientHost());
assertNotNull(running.getCheckFinishedAt());
}
}