diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopCredentialCheckController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopCredentialCheckController.java new file mode 100644 index 00000000..d28362bc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopCredentialCheckController.java @@ -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 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 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 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 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("无权访问"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopCredentialCheckMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopCredentialCheckMapper.java new file mode 100644 index 00000000..79e31b50 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopCredentialCheckMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckCreateRequest.java new file mode 100644 index 00000000..0cf898c1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckCreateRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckReportRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckReportRequest.java new file mode 100644 index 00000000..5e90459e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopCredentialCheckReportRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopCredentialCheckEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopCredentialCheckEntity.java new file mode 100644 index 00000000..e351f4eb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopCredentialCheckEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckClaimVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckClaimVo.java new file mode 100644 index 00000000..201a5ebd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckClaimVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckVo.java new file mode 100644 index 00000000..6f08b55d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopCredentialCheckVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java index bb37a432..a8f2d5ba 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckService.java new file mode 100644 index 00000000..f07e839b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckService.java @@ -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() + .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() + .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() + .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() + .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() + .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() + .select(ShopManageEntity::getZnUsername) + .eq(ShopManageEntity::getShopName, normalized) + .last("limit 1")); + return entity == null ? null : entity.getZnUsername(); + } + + /** + * 客户端执行超时(崩溃/断网)的 RUNNING 回收为 PENDING,供其他在线客户端重试。 + */ + private void recycleStaleRunning() { + List stale = shopCredentialCheckMapper.selectList(new LambdaQueryWrapper() + .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() + .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 abandoned = shopCredentialCheckMapper.selectList(new LambdaQueryWrapper() + .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() + .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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java index e0d2dbfa..79240148 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java @@ -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 latestCheckByShopId = buildLatestCheckMap(rows); + List 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 buildLatestCheckMap(List rows) { + List shopIds = rows.stream() + .map(ShopManageEntity::getId) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (shopIds.isEmpty()) { + return Map.of(); + } + List checks = shopCredentialCheckMapper.selectList( + new LambdaQueryWrapper() + .in(ShopCredentialCheckEntity::getShopId, shopIds) + .orderByDesc(ShopCredentialCheckEntity::getId)); + Map 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); } diff --git a/backend-java/src/main/resources/db/V99__shop_credential_check.sql b/backend-java/src/main/resources/db/V99__shop_credential_check.sql new file mode 100644 index 00000000..4c1befa7 --- /dev/null +++ b/backend-java/src/main/resources/db/V99__shop_credential_check.sql @@ -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; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckServiceTest.java new file mode 100644 index 00000000..88f2b79b --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopCredentialCheckServiceTest.java @@ -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()); + } +}