Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8cd851b2f | |||
| 14a41e64f1 | |||
| 7f4737c800 |
+27
-24
@@ -3,36 +3,39 @@ package com.nanri.aiimage.config;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@ConfigurationProperties(prefix = "aiimage.appearance-patent")
|
@ConfigurationProperties(prefix = "aiimage.appearance-patent")
|
||||||
public class AppearancePatentProperties {
|
public class AppearancePatentProperties {
|
||||||
private String cozeBaseUrl = "https://api.coze.cn";
|
|
||||||
private String cozeWorkflowPath = "/v1/workflow/run";
|
/**
|
||||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
* LLM API(OpenAI 兼容 /v1/chat/completions)地址
|
||||||
private String cozeWorkflowId = "7632683471312355338";
|
*/
|
||||||
private String cozeToken = "";
|
private String llmHost = "https://ai.t8star.org";
|
||||||
private List<CozeCredential> cozeCredentials = new ArrayList<>();
|
/**
|
||||||
private int cozeCredentialStripeSize = 5;
|
* 商标关键词提取模型
|
||||||
private int cozeBatchSize = 10;
|
*/
|
||||||
private int cozeConnectTimeoutMillis = 10000;
|
private String titleModel = "deepseek-v4-flash";
|
||||||
private int cozeReadTimeoutMillis = 60000;
|
/**
|
||||||
private int cozePollIntervalMillis = 30000;
|
* 外观检测模型(视觉)
|
||||||
private int cozePollTimeoutMillis = 600000;
|
*/
|
||||||
|
private String appearanceModel = "gemini-3.7-flash";
|
||||||
|
private int llmMaxTokens = 64000;
|
||||||
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
private int llmBatchSize = 10;
|
||||||
|
/**
|
||||||
|
* 批内行级并发数,默认等于批量大小
|
||||||
|
*/
|
||||||
|
private int llmRowConcurrency = 10;
|
||||||
|
/**
|
||||||
|
* 每行每个 LLM 请求的重试次数(含首次)
|
||||||
|
*/
|
||||||
|
private int llmRetryTimes = 3;
|
||||||
private int staleTimeoutMinutes = 30;
|
private int staleTimeoutMinutes = 30;
|
||||||
private String staleFinalizeCron = "0 */2 * * * *";
|
private String staleFinalizeCron = "0 */2 * * * *";
|
||||||
/**
|
/**
|
||||||
* 末尾不足一批的数据等待该时长后强制提交 Coze。
|
* 末尾不足一批的数据等待该时长后强制提交检测。
|
||||||
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
|
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
|
||||||
*/
|
*/
|
||||||
private int cozeFlushPendingMinutes = 1;
|
private int flushPendingMinutes = 1;
|
||||||
|
|
||||||
@Data
|
|
||||||
public static class CozeCredential {
|
|
||||||
private String name;
|
|
||||||
private String workflowId;
|
|
||||||
private String token;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,38 @@ public class SimilarAsinProperties {
|
|||||||
*/
|
*/
|
||||||
private long cozeSubmitLockRetryDelayMillis = 500L;
|
private long cozeSubmitLockRetryDelayMillis = 500L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 货源查询直连 LLM 模式开关(默认 true:新任务与存量 PENDING 批次都走直连 LLM,
|
||||||
|
* 不再经过 Coze)。false 时回退到原 Coze 工作流链路(轮询/重试状态机保留)。
|
||||||
|
*/
|
||||||
|
private boolean directLlmEnabled = true;
|
||||||
|
|
||||||
|
/** 直连 LLM 的 base url(OpenAI 兼容 /v1/chat/completions)。 */
|
||||||
|
private String llmHost = "https://ai.t8star.org";
|
||||||
|
|
||||||
|
/** 直连 LLM 的 Bearer token;前端未传 api_key 时兜底使用。 */
|
||||||
|
private String llmApiKey = "";
|
||||||
|
|
||||||
|
/** 类目匹配(一级/二级)使用的小模型。 */
|
||||||
|
private String llmCategoryModel = "gemini-3.5-flash-lite";
|
||||||
|
|
||||||
|
/** 合规检查(is_conform/reason/category)使用的小模型。 */
|
||||||
|
private String llmConformModel = "gemini-3.5-flash-lite";
|
||||||
|
|
||||||
|
/** 图片相似度对比(主图 vs 拼接图)使用的模型。 */
|
||||||
|
private String llmImageCompareModel = "gemini-3.7-flash";
|
||||||
|
|
||||||
|
private int llmMaxTokens = 64000;
|
||||||
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
private int llmRetryTimes = 3;
|
||||||
|
|
||||||
|
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
|
||||||
|
private int llmRowConcurrency = 5;
|
||||||
|
|
||||||
|
/** 拼接图/主图下载超时(秒),慢源图片较多时放大该值。 */
|
||||||
|
private int llmImageDownloadTimeoutSeconds = 10;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class CozeCredential {
|
public static class CozeCredential {
|
||||||
private String name;
|
private String name;
|
||||||
|
|||||||
+470
-804
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -35,7 +35,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping("/api/appearance-patent")
|
@RequestMapping("/api/appearance-patent")
|
||||||
@Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 Coze、补齐子行、生成最终 xlsx 并上传 OSS。")
|
@Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 LLM 检测、补齐子行、生成最终 xlsx 并上传 OSS。")
|
||||||
public class AppearancePatentController {
|
public class AppearancePatentController {
|
||||||
|
|
||||||
private final AppearancePatentTaskService service;
|
private final AppearancePatentTaskService service;
|
||||||
@@ -110,7 +110,7 @@ public class AppearancePatentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/{taskId}/result")
|
@PostMapping("/tasks/{taskId}/result")
|
||||||
@Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。")
|
@Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 LLM 检测。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。")
|
||||||
public ApiResponse<Void> result(
|
public ApiResponse<Void> result(
|
||||||
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
|
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
|
||||||
@PathVariable Long taskId,
|
@PathVariable Long taskId,
|
||||||
|
|||||||
+3
-3
@@ -24,17 +24,17 @@ public class AppearancePatentParseRequest {
|
|||||||
|
|
||||||
@JsonProperty("ai_prompt")
|
@JsonProperty("ai_prompt")
|
||||||
@JsonAlias({"aiPrompt", "prompt"})
|
@JsonAlias({"aiPrompt", "prompt"})
|
||||||
@Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
|
@Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 LLM 时作为附加要求传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
|
||||||
private String aiPrompt;
|
private String aiPrompt;
|
||||||
|
|
||||||
@JsonProperty("api_key")
|
@JsonProperty("api_key")
|
||||||
@JsonAlias({"apiKey"})
|
@JsonAlias({"apiKey"})
|
||||||
@Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥。")
|
@Schema(description = "调用 LLM API 的任务级密钥。")
|
||||||
@NotBlank(message = "密钥不能为空")
|
@NotBlank(message = "密钥不能为空")
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
@JsonProperty("patent_token")
|
@JsonProperty("patent_token")
|
||||||
@JsonAlias({"patentToken"})
|
@JsonAlias({"patentToken"})
|
||||||
@Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌。非必填。")
|
@Schema(description = "专利汇令牌。非必填。")
|
||||||
private String patentToken;
|
private String patentToken;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -14,10 +14,10 @@ public class AppearancePatentParsedPayloadDto {
|
|||||||
@Schema(description = "AI 提示词")
|
@Schema(description = "AI 提示词")
|
||||||
private String aiPrompt;
|
private String aiPrompt;
|
||||||
|
|
||||||
@Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥")
|
@Schema(description = "调用 LLM API 的任务级密钥")
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
@Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌")
|
@Schema(description = "专利汇令牌")
|
||||||
private String patentToken;
|
private String patentToken;
|
||||||
|
|
||||||
@Schema(description = "本次解析的源文件列表")
|
@Schema(description = "本次解析的源文件列表")
|
||||||
|
|||||||
+7
-7
@@ -29,7 +29,7 @@ public class AppearancePatentResultRowDto {
|
|||||||
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
||||||
private String country;
|
private String country;
|
||||||
|
|
||||||
@Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001")
|
@Schema(description = "商品 SKU。Java 调用 LLM 时会作为标题识别输入。", example = "SKU-001")
|
||||||
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
||||||
private String sku;
|
private String sku;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public class AppearancePatentResultRowDto {
|
|||||||
@JsonAlias({"Price", "价格"})
|
@JsonAlias({"Price", "价格"})
|
||||||
private String price;
|
private String price;
|
||||||
|
|
||||||
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
@Schema(description = "商品主图或待检测图片 URL。Java 调用 LLM 时会作为外观检测图片输入。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||||
@JsonAlias({
|
@JsonAlias({
|
||||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||||
"mainImage", "main_image", "mainImageUrl", "main_image_url",
|
"mainImage", "main_image", "mainImageUrl", "main_image_url",
|
||||||
@@ -51,24 +51,24 @@ public class AppearancePatentResultRowDto {
|
|||||||
})
|
})
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
@Schema(description = "商品标题。Java 调用 LLM 时会作为标题识别输入;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
||||||
@JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"})
|
@JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"})
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
|
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;LLM 检测失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
|
||||||
private String error;
|
private String error;
|
||||||
|
|
||||||
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
|
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
|
||||||
private Boolean done;
|
private Boolean done;
|
||||||
|
|
||||||
@JsonAlias({"row_status", "rowStatus", "Status"})
|
@JsonAlias({"row_status", "rowStatus", "Status"})
|
||||||
@Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
@Schema(description = "行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
@Schema(description = "Java 调用 LLM 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/LLM 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||||
private String titleRisk;
|
private String titleRisk;
|
||||||
|
|
||||||
@Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
@Schema(description = "Java 调用 LLM 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||||
private String appearanceRisk;
|
private String appearanceRisk;
|
||||||
|
|
||||||
@JsonAlias({"patent ", "patent"})
|
@JsonAlias({"patent ", "patent"})
|
||||||
|
|||||||
+40
@@ -126,11 +126,51 @@ public class AppearancePatentTaskCacheService {
|
|||||||
try {
|
try {
|
||||||
stringRedisTemplate.delete(pendingRowsKey(taskId));
|
stringRedisTemplate.delete(pendingRowsKey(taskId));
|
||||||
stringRedisTemplate.delete(heartbeatKey(taskId));
|
stringRedisTemplate.delete(heartbeatKey(taskId));
|
||||||
|
stringRedisTemplate.delete(processedRowsKey(taskId));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[appearance-patent-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage());
|
log.warn("[appearance-patent-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行级检测去重:Redis SETNX,防止 submitResult 与 stale 恢复双入口并发处理同一批。
|
||||||
|
* 返回 true 表示首次标记(应执行检测),false 表示已处理过(跳过)。
|
||||||
|
*/
|
||||||
|
public boolean markRowProcessed(Long taskId, String rowKey) {
|
||||||
|
if (taskId == null || taskId <= 0 || rowKey == null || rowKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(
|
||||||
|
processedRowKey(taskId, rowKey), "1", Duration.ofHours(TTL_HOURS));
|
||||||
|
return Boolean.TRUE.equals(first);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[appearance-patent-cache] mark row processed degraded taskId={} rowKey={} msg={}", taskId, rowKey, ex.getMessage());
|
||||||
|
// Redis 降级时放行,由持久化层的 hasResolvedCozeFields 判据兜底去重。
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRowProcessed(Long taskId, String rowKey) {
|
||||||
|
if (taskId == null || taskId <= 0 || rowKey == null || rowKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(processedRowKey(taskId, rowKey)));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[appearance-patent-cache] check row processed degraded taskId={} rowKey={} msg={}", taskId, rowKey, ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String processedRowsKey(Long taskId) {
|
||||||
|
return "appearance-patent:task:processed-rows:" + taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String processedRowKey(Long taskId, String rowKey) {
|
||||||
|
return processedRowsKey(taskId) + ":" + rowKey;
|
||||||
|
}
|
||||||
|
|
||||||
private String pendingRowsKey(Long taskId) {
|
private String pendingRowsKey(Long taskId) {
|
||||||
return "appearance-patent:task:pending-rows:" + taskId;
|
return "appearance-patent:task:pending-rows:" + taskId;
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-1459
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -11,14 +11,19 @@ import java.util.List;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface InvalidAsinDataMapper extends BaseMapper<InvalidAsinDataEntity> {
|
public interface InvalidAsinDataMapper extends BaseMapper<InvalidAsinDataEntity> {
|
||||||
|
|
||||||
/** 批量 INSERT IGNORE:命中唯一键 (data_value, brand) 的重复行静默跳过,幂等。 */
|
/**
|
||||||
|
* 批量 INSERT IGNORE:命中唯一键 (data_value, brand) 的重复行静默跳过,幂等。
|
||||||
|
* created_at/updated_at 不显式写入:依赖列的 DEFAULT CURRENT_TIMESTAMP。
|
||||||
|
* 显式传 null 会绕过默认值,在宽松 SQL 模式下落为 '0000-00-00 00:00:00',
|
||||||
|
* 随后在 NO_ZERO_DATE 模式(生产已启用)下读取即报 Zero date value prohibited。
|
||||||
|
*/
|
||||||
@Insert("""
|
@Insert("""
|
||||||
<script>
|
<script>
|
||||||
INSERT IGNORE INTO biz_invalid_asin_data
|
INSERT IGNORE INTO biz_invalid_asin_data
|
||||||
(data_value, brand, record_source, created_at, updated_at)
|
(data_value, brand, record_source)
|
||||||
VALUES
|
VALUES
|
||||||
<foreach collection="rows" item="row" separator=",">
|
<foreach collection="rows" item="row" separator=",">
|
||||||
(#{row.dataValue}, #{row.brand}, #{row.recordSource}, #{row.createdAt}, #{row.updatedAt})
|
(#{row.dataValue}, #{row.brand}, #{row.recordSource})
|
||||||
</foreach>
|
</foreach>
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
|
|||||||
+82
@@ -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("无权访问");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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> {
|
||||||
|
}
|
||||||
+14
@@ -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;
|
||||||
|
}
|
||||||
+21
@@ -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;
|
||||||
|
}
|
||||||
+33
@@ -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;
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
}
|
||||||
+38
@@ -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;
|
||||||
|
}
|
||||||
+2
@@ -16,6 +16,8 @@ public class ShopManageItemVo {
|
|||||||
private String account;
|
private String account;
|
||||||
private String password;
|
private String password;
|
||||||
private String passwordMasked;
|
private String passwordMasked;
|
||||||
|
/** 最近一次密码检测结果视图;从未检测过为 null。 */
|
||||||
|
private ShopCredentialCheckVo latestCheck;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+230
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-1
@@ -3,11 +3,14 @@ package com.nanri.aiimage.modules.shopkey.service;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
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.mapper.ShopManageMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
|
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.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.ShopManageEntity;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
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.ShopManageCredentialVo;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
|
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
|
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
|
||||||
@@ -15,6 +18,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -26,6 +30,7 @@ public class ShopManageService {
|
|||||||
private final ShopManageMapper shopManageMapper;
|
private final ShopManageMapper shopManageMapper;
|
||||||
private final ShopManageGroupService shopManageGroupService;
|
private final ShopManageGroupService shopManageGroupService;
|
||||||
private final ShopCredentialCryptoService shopCredentialCryptoService;
|
private final ShopCredentialCryptoService shopCredentialCryptoService;
|
||||||
|
private final ShopCredentialCheckMapper shopCredentialCheckMapper;
|
||||||
|
|
||||||
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long operatorId, boolean superAdmin) {
|
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long operatorId, boolean superAdmin) {
|
||||||
long safePage = Math.max(page, 1);
|
long safePage = Math.max(page, 1);
|
||||||
@@ -69,8 +74,14 @@ public class ShopManageService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList());
|
.toList());
|
||||||
|
|
||||||
|
Map<Long, ShopCredentialCheckVo> latestCheckByShopId = buildLatestCheckMap(rows);
|
||||||
|
|
||||||
List<ShopManageItemVo> items = rows.stream()
|
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();
|
.toList();
|
||||||
ShopManagePageVo vo = new ShopManagePageVo();
|
ShopManagePageVo vo = new ShopManagePageVo();
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
@@ -188,6 +199,43 @@ public class ShopManageService {
|
|||||||
return entity;
|
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) {
|
private void validateGroupAccess(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
|
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
|
||||||
}
|
}
|
||||||
|
|||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.HttpClientPool;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 货源查询直连 LLM 客户端:调用 OpenAI 兼容 /v1/chat/completions,
|
||||||
|
* 替代原 Coze 工作流(similarity_asin -> similarity_image -> LLM_chat)链路,
|
||||||
|
* 减少一次外部平台中转。链路对齐点见 SimilarAsinLlmService。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class SimilarAsinLlmClient {
|
||||||
|
|
||||||
|
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
private final SimilarAsinProperties properties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ExternalCallMetricsRecorder externalCallMetrics;
|
||||||
|
|
||||||
|
private volatile RestClient sharedRestClient;
|
||||||
|
|
||||||
|
public SimilarAsinLlmClient(SimilarAsinProperties properties,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
ExternalCallMetricsRecorder externalCallMetrics) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.externalCallMetrics = externalCallMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本对话,json_object 输出。 */
|
||||||
|
public String invokeChat(String model, String system, String userText, String apiKey) {
|
||||||
|
return invokeChat(model, system, userText, List.of(), apiKey, "json_object");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多模态对话(图片 URL 列表),json_object 输出。 */
|
||||||
|
public String invokeChatWithImages(String model, String system, String userText, List<String> images, String apiKey) {
|
||||||
|
return invokeChat(model, system, userText, images, apiKey, "json_object");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String invokeChat(String model,
|
||||||
|
String system,
|
||||||
|
String userText,
|
||||||
|
List<String> images,
|
||||||
|
String apiKey,
|
||||||
|
String responseFormat) {
|
||||||
|
String resolvedKey = resolveApiKey(apiKey);
|
||||||
|
int attempts = Math.max(1, properties.getLlmRetryTimes());
|
||||||
|
Exception lastFailure = null;
|
||||||
|
for (int attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
lastFailure = ex;
|
||||||
|
if (attempt >= attempts) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
log.warn("[similar-asin][llm] retryable failure attempt={} model={} err={}",
|
||||||
|
attempt, model, failureMessage(ex));
|
||||||
|
sleepBeforeRetry(attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastFailure == null
|
||||||
|
? new IllegalStateException("LLM call failed")
|
||||||
|
: lastFailure instanceof RuntimeException runtimeFailure
|
||||||
|
? runtimeFailure
|
||||||
|
: new IllegalStateException(lastFailure.getMessage(), lastFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前端 api_key 为空时兜底走服务端配置(工作流里 api_key 为必填项)。 */
|
||||||
|
public String resolveApiKey(String apiKey) {
|
||||||
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
|
return apiKey.trim();
|
||||||
|
}
|
||||||
|
return normalize(properties.getLlmApiKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasApiKey(String apiKey) {
|
||||||
|
return !resolveApiKey(apiKey).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invokeChatOnce(String model,
|
||||||
|
String system,
|
||||||
|
String userText,
|
||||||
|
List<String> images,
|
||||||
|
String apiKey,
|
||||||
|
String responseFormat) {
|
||||||
|
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
||||||
|
log.debug("[similar-asin][llm] request model={} url={} body={}",
|
||||||
|
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
||||||
|
writeJson(maskChatBody(body)));
|
||||||
|
RestClient.RequestBodySpec request = restClient().post()
|
||||||
|
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
|
||||||
|
.headers(headers -> {
|
||||||
|
headers.setBearerAuth(stripBearer(apiKey));
|
||||||
|
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||||
|
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||||
|
});
|
||||||
|
request.body(body);
|
||||||
|
String responseText = request.exchange((clientRequest, clientResponse) -> {
|
||||||
|
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||||
|
String responseBody = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||||
|
if (!clientResponse.getStatusCode().is2xxSuccessful()) {
|
||||||
|
throw new IllegalStateException("LLM http " + clientResponse.getStatusCode().value()
|
||||||
|
+ ": " + abbreviate(responseBody, 500));
|
||||||
|
}
|
||||||
|
log.debug("[similar-asin][llm] response model={} status={} body={}",
|
||||||
|
model, clientResponse.getStatusCode(), abbreviate(responseBody, 2000));
|
||||||
|
return responseBody;
|
||||||
|
});
|
||||||
|
JsonNode root = parseJsonOrThrow(responseText);
|
||||||
|
JsonNode errorNode = root.path("error");
|
||||||
|
if (!errorNode.isMissingNode() && !errorNode.isNull()) {
|
||||||
|
String message = text(errorNode.path("message"));
|
||||||
|
throw new IllegalStateException(firstNonBlank(message, "LLM error"));
|
||||||
|
}
|
||||||
|
JsonNode contentNode = root.path("choices").path(0).path("message").path("content");
|
||||||
|
String content = text(contentNode);
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
throw new IllegalStateException("LLM empty response");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildChatBody(String model,
|
||||||
|
String system,
|
||||||
|
String userText,
|
||||||
|
List<String> images,
|
||||||
|
String responseFormat) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("model", model);
|
||||||
|
body.put("stream", false);
|
||||||
|
body.put("max_tokens", Math.max(1, properties.getLlmMaxTokens()));
|
||||||
|
body.put("temperature", 0);
|
||||||
|
Map<String, Object> responseFormatObj = new LinkedHashMap<>();
|
||||||
|
responseFormatObj.put("type", responseFormat);
|
||||||
|
body.put("response_format", responseFormatObj);
|
||||||
|
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>(2);
|
||||||
|
Map<String, Object> systemMessage = new LinkedHashMap<>();
|
||||||
|
systemMessage.put("role", "system");
|
||||||
|
systemMessage.put("content", system);
|
||||||
|
messages.add(systemMessage);
|
||||||
|
|
||||||
|
Map<String, Object> userMessage = new LinkedHashMap<>();
|
||||||
|
userMessage.put("role", "user");
|
||||||
|
if (images == null || images.isEmpty()) {
|
||||||
|
userMessage.put("content", userText);
|
||||||
|
} else {
|
||||||
|
List<Object> content = new ArrayList<>();
|
||||||
|
Map<String, Object> textPart = new LinkedHashMap<>();
|
||||||
|
textPart.put("type", "text");
|
||||||
|
textPart.put("text", userText);
|
||||||
|
content.add(textPart);
|
||||||
|
for (String url : images) {
|
||||||
|
if (normalize(url).isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> imagePart = new LinkedHashMap<>();
|
||||||
|
imagePart.put("type", "image_url");
|
||||||
|
Map<String, Object> imageUrl = new LinkedHashMap<>();
|
||||||
|
imageUrl.put("url", url);
|
||||||
|
imagePart.put("image_url", imageUrl);
|
||||||
|
content.add(imagePart);
|
||||||
|
}
|
||||||
|
userMessage.put("content", content);
|
||||||
|
}
|
||||||
|
messages.add(userMessage);
|
||||||
|
body.put("messages", messages);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> maskChatBody(Map<String, Object> body) {
|
||||||
|
Map<String, Object> masked = new LinkedHashMap<>(body);
|
||||||
|
Object messagesObj = masked.get("messages");
|
||||||
|
if (messagesObj instanceof List<?> messages) {
|
||||||
|
List<Object> maskedMessages = new ArrayList<>(messages.size());
|
||||||
|
for (Object messageObj : messages) {
|
||||||
|
if (messageObj instanceof Map<?, ?> message) {
|
||||||
|
Map<String, Object> maskedMessage = new LinkedHashMap<>((Map<String, Object>) message);
|
||||||
|
Object contentObj = maskedMessage.get("content");
|
||||||
|
if (contentObj instanceof String contentText && contentText.length() > 80) {
|
||||||
|
maskedMessage.put("content", contentText.substring(0, 40) + "...[len=" + contentText.length() + "]");
|
||||||
|
}
|
||||||
|
maskedMessages.add(maskedMessage);
|
||||||
|
} else {
|
||||||
|
maskedMessages.add(messageObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
masked.put("messages", maskedMessages);
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 输出的 JSON 解包:剥离 ```json 包裹、多重字符串转义后返回 JsonNode;
|
||||||
|
* 失败时抛 IllegalStateException。
|
||||||
|
*/
|
||||||
|
public JsonNode parseJsonContent(String content) {
|
||||||
|
Object current = content;
|
||||||
|
while (current instanceof String value) {
|
||||||
|
String cleaned = value.trim();
|
||||||
|
if (cleaned.startsWith("```json")) {
|
||||||
|
cleaned = cleaned.substring(7).replaceAll("```$", "").trim();
|
||||||
|
}
|
||||||
|
Object parsed = parseLenient(cleaned);
|
||||||
|
if (parsed == null) {
|
||||||
|
Object fixed = parseLenient(cleaned.replace("\n", "\\n").replace("\r", "\\r"));
|
||||||
|
if (fixed == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = fixed;
|
||||||
|
} else {
|
||||||
|
if (parsed.equals(value)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current instanceof Map<?, ?> map) {
|
||||||
|
try {
|
||||||
|
return objectMapper.valueToTree(map);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String raw = content == null ? "" : content.trim();
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(raw);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("LLM 输出不是合法 JSON: " + abbreviate(raw, 300), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object parseLenient(String value) {
|
||||||
|
String normalized = normalize(value);
|
||||||
|
if (!(normalized.startsWith("{") || normalized.startsWith("["))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(normalized, Object.class);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode parseJsonOrThrow(String value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(value);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("LLM response is not valid JSON", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RestClient restClient() {
|
||||||
|
RestClient client = sharedRestClient;
|
||||||
|
if (client != null) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (sharedRestClient == null) {
|
||||||
|
RestClient.Builder builder = RestClient.builder()
|
||||||
|
.requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis()));
|
||||||
|
if (externalCallMetrics != null) {
|
||||||
|
builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
|
||||||
|
}
|
||||||
|
sharedRestClient = builder.build();
|
||||||
|
}
|
||||||
|
return sharedRestClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepBeforeRetry(int attemptIndex) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(Math.max(1, attemptIndex) * 1500L);
|
||||||
|
} catch (InterruptedException interruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("LLM retry interrupted", interruptedException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Failed to serialize LLM payload", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String abbreviate(String value, int maxLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.length() <= maxLength) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String failureMessage(Exception ex) {
|
||||||
|
String message = ex == null ? null : ex.getMessage();
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return "LLM call failed";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripBearer(String token) {
|
||||||
|
String normalized = token == null ? "" : token.trim();
|
||||||
|
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinUrl(String baseUrl, String path) {
|
||||||
|
String base = baseUrl == null ? "" : baseUrl.trim();
|
||||||
|
String suffix = path == null ? "" : path.trim();
|
||||||
|
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||||
|
return base + suffix.substring(1);
|
||||||
|
}
|
||||||
|
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||||
|
return base + "/" + suffix;
|
||||||
|
}
|
||||||
|
return base + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String preferred, String fallback) {
|
||||||
|
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(JsonNode node) {
|
||||||
|
return node == null || node.isNull() ? null : node.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容对比:similarity 百分比文本统一规整为 "NN%" 形式。 */
|
||||||
|
public static String normalizePercent(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.isBlank() || "null".equalsIgnoreCase(normalized)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (normalized.endsWith("%")) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Double.parseDouble(normalized);
|
||||||
|
return normalized + "%";
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean similarityHitsStock(String similarity) {
|
||||||
|
return parsePercent(similarity) >= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double parsePercent(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.endsWith("%")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1).trim();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(normalized);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isStockAvailable(String value) {
|
||||||
|
String normalized = value == null ? "" : normalizeStatic(value);
|
||||||
|
return normalized.equals("有货") || normalized.contains("有货");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isConformYes(String value) {
|
||||||
|
return "符合".equals(normalizeStatic(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeStatic(String value) {
|
||||||
|
return value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
+669
@@ -0,0 +1,669 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 货源查询直连 LLM 编排服务:复刻 Coze 工作流 similarity_asin
|
||||||
|
* (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉 Coze 中转。
|
||||||
|
*
|
||||||
|
* 链路对齐点(按工作流节点):
|
||||||
|
* 1. 图片准备 batch(103226,无条件执行):alibaba 前 8 张拼图1、8~16 张拼图2
|
||||||
|
* (puzzle_image 插件,失败降级为空串),主图下载转存 MinIO supply_images
|
||||||
|
* (upload_file 插件,失败即行失败);
|
||||||
|
* 2. category_switch=false:不调 LLM,输出行仅 asin + 三图,主图空时 status=不存在
|
||||||
|
* (127486/1857081);
|
||||||
|
* 3. category_switch=true:一级匹配(141783)→ 二级匹配(1548215)→ 三级候选查询
|
||||||
|
* (1288150),任一匹配为"无"则违规候选为空;
|
||||||
|
* 4. 合规检查(1570640,总是执行):is_conform/reason/category;
|
||||||
|
* 5. 不符合或 alibaba 空或 img_switch=false:只回填合规结果,status=成功(139255/1398750);
|
||||||
|
* 6. 否则图片对比循环(181250,最多 2 个候选):主图(原始url) vs 拼图 → LLM
|
||||||
|
* (LLM_chat,重试 3 次)→ is_stock=有货 则停;候选全失败时保持 status=失败(初始 data 语义)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class SimilarAsinLlmService {
|
||||||
|
|
||||||
|
private static final String STATUS_SUCCESS = "成功";
|
||||||
|
private static final String STATUS_FAILED = "失败";
|
||||||
|
private static final String STATUS_NOT_EXISTS = "不存在";
|
||||||
|
private static final String NO_MATCH = "无";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一级/二级类目匹配系统提示词(工作流 109409/1544733 原文,ID 字段移除:
|
||||||
|
* LLM 会编造 slug ID,Java 按名称回查类目表取真实数字 ID)。
|
||||||
|
*/
|
||||||
|
private static final String SYSTEM_CATEGORY =
|
||||||
|
"# Role\n你是一个电商数据专家,擅长根据商品的标题(Title)和属性(Attributes)进行精准的类目归类。\n\n"
|
||||||
|
+ "# Task\n请分析用户提供的\"商品标题\"和\"商品属性\",从给定的\"备选类目列表\"中筛选出最匹配的一个类目。\n\n"
|
||||||
|
+ "# Rules\n1. **语义匹配**:不仅要考虑关键词匹配,还要考虑商品的实际用途、材质和适用人群。\n"
|
||||||
|
+ "2. **属性优先**:如果标题模糊,请重点参考属性中的关键信息(如:材质、功能、品牌)。\n"
|
||||||
|
+ "3. **唯一输出**:只输出 JSON 格式的结果,不要包含任何解释、开场白或修饰词。\n"
|
||||||
|
+ "4. **回退机制**:\n"
|
||||||
|
+ " - 如果能匹配到类目,输出:{\"name\": \"类目名称\"}\n"
|
||||||
|
+ " - 如果没有任何类目符合商品特征,输出:{\"name\": \"无\"}\n\n"
|
||||||
|
+ "# Constraint\n严禁伪造类目名称。输出必须严格遵循 JSON 语法。";
|
||||||
|
|
||||||
|
/** 合规检查系统提示词(工作流 1380531 原文)。 */
|
||||||
|
private static final String SYSTEM_CONFORM =
|
||||||
|
"# 角色与任务\n\n你是一个专业的亚马逊电商产品合规审核助手。请根据用户提供的产品文本信息(包括 ASIN、SKU、产品标题、产品属性等)以及\"用户指定的违规类目\",判断该产品是否符合上架要求。\n\n"
|
||||||
|
+ "# 判断逻辑(请严格按以下优先级顺序执行)\n\n"
|
||||||
|
+ "1. **优先匹配指定类目(特殊备注拦截逻辑)**:\n"
|
||||||
|
+ "首先根据产品标题、SKU及属性等文本信息推断产品真实类目,并与用户输入的\"违规类目\"(一级/二级/三级)进行比对。此拦截逻辑适用于**任何层级**(包括一级、二级类目):\n\n"
|
||||||
|
+ "* **泛化拦截(带特殊备注)**:如果某一层级的类目名称中带有包含性的特殊备注(如\"(包含所有...)\"、\"(所有...都不行)\"等),则**该带有备注的层级及其包含的所有底层产品**均视为违规。\n"
|
||||||
|
+ "*示例 1(一级拦截):一级类目\"食品(所有食品都不行)\",二级\"饮料\",三级\"可乐\"。只要产品属于\"食品\"大类,一律判定为\"不符合\"。*\n"
|
||||||
|
+ "*示例 2(二级拦截):一级类目\"食品\",二级\"饮料(包含所有饮料类产品)\",三级\"可乐\"。则所有\"饮料\"均判定为\"不符合\"(如雪碧、果汁都不行),但属于食品大类下的\"零食\"或\"糖果\"不受影响。*\n"
|
||||||
|
+ "* **精准拦截(无特殊备注)**:如果所有层级均无此类特殊备注,则**仅有最底层(如三级类目)明确指定的具体产品**才判定为违规。\n"
|
||||||
|
+ "*示例 3(精准拦截):一级类目\"食品\",二级\"饮料\",三级\"可乐\"。则只有\"可乐\"判定为\"不符合\",同属饮料的\"雪碧\"则判定为符合。*\n"
|
||||||
|
+ "如果命中以上规则,直接判定为\"不符合\",并将匹配到的带备注的类目层级名称或具体的三级违规类目作为理由。\n\n"
|
||||||
|
+ "2. **结合平台政策判定(重点关注高危及需资质产品)**:\n"
|
||||||
|
+ "如果产品不在上述用户指定的违规范围内,请基于亚马逊官方政策判断。若该产品属于以下三类之一,强制判定为\"不符合\":\n\n"
|
||||||
|
+ "* **医疗器械类产品**\n"
|
||||||
|
+ "* **需要特殊资质/认证材料类产品**(如FDA认证、儿童CPC认证等强管控产品)\n"
|
||||||
|
+ "* **高危类产品**(如易燃易爆、管制刀具、危险化学品等)\n"
|
||||||
|
+ "如果是,请在理由中写明触发的具体亚马逊限制类目或原因。\n\n"
|
||||||
|
+ "3. **最终合规判定**:\n"
|
||||||
|
+ "如果上述两步的检查均未发现违规(既不属于用户指定的违规范围,也不属于亚马逊限制的上述三类产品),则判定为\"符合\"。\n\n"
|
||||||
|
+ "# 输出限制\n\n"
|
||||||
|
+ "请**严格且仅以**下方的 JSON 格式输出结果。**绝对禁止**输出任何前言、后语、解释性文字或 Markdown 代码块标记(如 ```json 等),只需纯 JSON 文本形式输出。\n\n"
|
||||||
|
+ "# 预期的 JSON 输出格式\n\n"
|
||||||
|
+ "{\n"
|
||||||
|
+ "\"asin\": \"<提取并保持用户输入的ASIN不变,若用户未提供ASIN但提供了SKU,则填入SKU>\",\n"
|
||||||
|
+ "\"is_conform\": \"<填写'符合'或'不符合'>\",\n"
|
||||||
|
+ "\"reason\": \"<如果'不符合',请填入具体理由(如匹配到的用户带有备注的类目层级、或精准匹配的三级类目、或亚马逊违规原因);如果'符合',不需要理由,请直接填写 '无'>\",\n"
|
||||||
|
+ "\"category\": \"<根据文本信息推断出的产品所属类目,必须输出完整的三级类目层级结构,使用'->'连接,示例:一级类目->二级类目->三级类目(如:食品->饮料->可乐),必须为中文>\"\n"
|
||||||
|
+ "}";
|
||||||
|
|
||||||
|
/** 图片相似度对比系统提示词(工作流 199497 原文)。 */
|
||||||
|
private static final String SYSTEM_IMAGE_COMPARE =
|
||||||
|
"# Role\n你是一位专业的电商图像对比与同款库存状态判定专家。\n\n"
|
||||||
|
+ "# Task\n请根据提供的【图1】(主图)、【图2】(对比图/同款候选图)以及用户提供的【产品信息/类目/特殊过滤要求】,精准识别并对比两张图片中**商品销售主体**的视觉相似度,从而精确判定商品是否为同款有货。\n\n"
|
||||||
|
+ "# Core Rules(主体锚定与干扰过滤)\n在进行比对前,必须首先依据【产品信息/类目】明确**本次比对的唯一售卖主体**,并强制执行以下过滤规则:\n"
|
||||||
|
+ "1. **强制忽略展示道具与填充物**:\n"
|
||||||
|
+ " - 严禁将用于展示功能的非售卖物品计入比对(例如:收纳包内的手表/充电器/数据线、手机壳内的手机机身、鞋包内的填充物/鞋撑、穿戴在模特身上的其他非标衣物等)。\n"
|
||||||
|
+ " - 比对时仅聚焦于**商品外壳/本体本身**(如收纳包本身的内外壳材质、凹槽形状、拉链、缝线、包边)。\n"
|
||||||
|
+ "2. **强制忽略营销文案与后期水印**:\n"
|
||||||
|
+ " - 严禁将图片后期添加的促销文字、价格标签(如8.50、9.00)、卖点描述(如\"牛津布面料\")、尺寸标注、防盗水印等计入\"文字与Logo\"维度的差异。\n"
|
||||||
|
+ " - 仅比对**商品本体上自带出厂印刷/压印/刺绣的品牌Logo或固定图案**。\n"
|
||||||
|
+ "3. **强制排除拍摄环境与展示形态差异**:\n"
|
||||||
|
+ " - 排除背景(纯白底、木纹、布景)、光影明暗、拍摄角度的差异。\n"
|
||||||
|
+ " - 若商品为同款,但一张为\"开盖展示内部\",另一张为\"闭合展示外观\"或\"附带可拆卸挂扣\",应基于可见的主体结构特征进行同款一致性判定,不得因未展示部位直接判为完全不同。\n\n"
|
||||||
|
+ "# Guidelines(多维度同款对比标准)\n必须针对**商品销售主体本身**从以下八个核心维度进行独立比对:\n"
|
||||||
|
+ "1. **形状与轮廓**:商品主体的几何外形、长宽比例、边缘弧度、立体轮廓是否一致。\n"
|
||||||
|
+ "2. **款式与结构**:商品的版型剪裁、开合方式(如拉链走向、卡扣结构)、内部功能槽位划分是否一致。\n"
|
||||||
|
+ "3. **颜色与色调**:主体面料颜色、拉链布与拉链齿颜色、缝线颜色等核心配色是否一致(允许合理的光影深浅色差)。\n"
|
||||||
|
+ "4. **材质与纹理**:表面材质(如EVA硬壳、牛津布纹理、PU皮革纹、金属质感等)是否为同种材质。\n"
|
||||||
|
+ "5. **图案与印花**:商品主体本身固有的纹路(如表面的凹凸波浪纹理、装饰线条)是否一致。\n"
|
||||||
|
+ "6. **本体文字与Logo**:商品主体表面自带的品牌Logo、压纹是否一致(忽略海报文案)。\n"
|
||||||
|
+ "7. **细节与辅料**:拉链头款式、拉链走线边缘、挂绳/登山扣挂耳设计、包边工艺等微观细节是否吻合。\n"
|
||||||
|
+ "8. **整体版型与做工**:排除道具与背景干扰后,商品展现出的同款货源一致性。\n\n"
|
||||||
|
+ "# 判定与评分逻辑\n"
|
||||||
|
+ "1. **同款判定(有货,相似度 ≥90%)**:\n"
|
||||||
|
+ " - 当图1与图2中的**商品销售主体**在款式结构、主体材质、轮廓造型、核心细节(如独特的凹凸纹理、拉链配色)上完全一致,确认为同一款货源/产品时,整体相似度判定为 **90% - 100%**,状态输出为\"有货\"。\n"
|
||||||
|
+ " - 若主体完全一致,仅因光影、角度或是否挂着可拆卸配件等有微小差别,可在 90%~98% 之间评定。\n"
|
||||||
|
+ "2. **非同款判定(没有货,相似度 <90%)**:\n"
|
||||||
|
+ " - 只要商品主体在核心结构(如圆形变方形)、材质(如硬壳变软布)、关键版型或功能凹槽设计上存在实质性不同,即属于不同款,相似度必须判定为 **<90%**,状态输出为\"没有货\"。\n\n"
|
||||||
|
+ "# 字段输出规则\n"
|
||||||
|
+ "* `asin`:直接提取并保持用户输入的 ASIN 不变。\n"
|
||||||
|
+ "* `is_stock`:仅允许输出\"有货\"或\"没有货\"。\n"
|
||||||
|
+ "* `similarity`:输出基于商品主体计算出的整体百分比数值(例如:\"95%\"、\"92%\"、\"30%\")。\n"
|
||||||
|
+ "* `status`:仅允许输出\"成功\"。\n"
|
||||||
|
+ "* `is_conform`:仅允许输出\"符合\"。\n"
|
||||||
|
+ "* `category`:直接提取并保持用户输入的产品类目不变输出。\n\n"
|
||||||
|
+ "# 输出格式约束\n"
|
||||||
|
+ "你必须直接输出原始 JSON 文本,**严禁**使用 Markdown 代码块标记(如 ```json 和 ```),严禁输出任何额外解释说明。\n\n"
|
||||||
|
+ "{\n"
|
||||||
|
+ "\"asin\": \"保持输入的ASIN不变输出\",\n"
|
||||||
|
+ "\"is_stock\": \"有货 或 没有货\",\n"
|
||||||
|
+ "\"similarity\": \"95%\",\n"
|
||||||
|
+ "\"status\": \"成功\",\n"
|
||||||
|
+ "\"is_conform\": \"符合\",\n"
|
||||||
|
+ "\"category\": \"保持输入的产品类目不变输出\"\n"
|
||||||
|
+ "}";
|
||||||
|
|
||||||
|
private static final String USER_TEXT_CATEGORY = "产品标题:%s\n产品属性:%s\n类目:%s";
|
||||||
|
private static final String USER_TEXT_CONFORM =
|
||||||
|
"标题:%s\nSKU:%s\n违规类目产品:一级类目:%s ,二级类目:%s,三级类目:%s";
|
||||||
|
private static final String USER_TEXT_COMPARE = "ASIN:%s\n所属类目:%s\n产品信息:%s";
|
||||||
|
|
||||||
|
private final SimilarAsinLlmClient llmClient;
|
||||||
|
private final SimilarAsinProperties properties;
|
||||||
|
private final OssProperties ossProperties;
|
||||||
|
private final ProductCategoryService productCategoryService;
|
||||||
|
private final PuzzleImageMerger puzzleImageMerger;
|
||||||
|
private final OssStorageService ossStorageService;
|
||||||
|
|
||||||
|
private volatile ExecutorService rowExecutor;
|
||||||
|
private volatile HttpClient downloadHttpClient;
|
||||||
|
|
||||||
|
/** 测试注入点:替换图片下载 HttpClient,避免单测发起真实网络请求。 */
|
||||||
|
void setDownloadHttpClientForTest(HttpClient client) {
|
||||||
|
this.downloadHttpClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批内行级直连检测:每行独立跑完整链路,结果按入参顺序返回。
|
||||||
|
* 单行内部流程失败时整行标记失败(status=失败 + error/reason),不中断其他行。
|
||||||
|
*/
|
||||||
|
public List<SimilarAsinResultRowDto> inspectRows(List<SimilarAsinResultRowDto> rows,
|
||||||
|
String prompt,
|
||||||
|
String apiKey,
|
||||||
|
boolean imgSwitch,
|
||||||
|
boolean categorySwitch) {
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!llmClient.hasApiKey(apiKey)) {
|
||||||
|
log.warn("[similar-asin][llm] llm api key not configured, keep raw rows size={}", rows.size());
|
||||||
|
return rows.stream().map(this::copyRow).toList();
|
||||||
|
}
|
||||||
|
Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency()));
|
||||||
|
ExecutorService executor = rowExecutor();
|
||||||
|
List<CompletableFuture<SimilarAsinResultRowDto>> futures = new ArrayList<>(rows.size());
|
||||||
|
for (SimilarAsinResultRowDto row : rows) {
|
||||||
|
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
concurrency.acquire();
|
||||||
|
try {
|
||||||
|
return inspectRow(row, prompt, apiKey, imgSwitch, categorySwitch);
|
||||||
|
} finally {
|
||||||
|
concurrency.release();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException interruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("LLM row inspect interrupted", interruptedException);
|
||||||
|
}
|
||||||
|
}, executor));
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
|
||||||
|
for (int i = 0; i < futures.size(); i++) {
|
||||||
|
try {
|
||||||
|
merged.add(futures.get(i).join());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][llm] row failed index={} asin={} err={}",
|
||||||
|
i, rows.get(i).getAsin(), failureMessage(ex));
|
||||||
|
merged.add(markFailed(copyRow(rows.get(i)), failureMessage(ex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinResultRowDto inspectRow(SimilarAsinResultRowDto source,
|
||||||
|
String prompt,
|
||||||
|
String apiKey,
|
||||||
|
boolean imgSwitch,
|
||||||
|
boolean categorySwitch) {
|
||||||
|
SimilarAsinResultRowDto row = copyRow(source);
|
||||||
|
try {
|
||||||
|
inspectRowInternal(row, prompt, apiKey, imgSwitch, categorySwitch);
|
||||||
|
return row;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][llm] row failed asin={} title={} err={}",
|
||||||
|
row.getAsin(), abbreviate(row.getTitle(), 120), failureMessage(ex));
|
||||||
|
return markFailed(row, failureMessage(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void inspectRowInternal(SimilarAsinResultRowDto row,
|
||||||
|
String prompt,
|
||||||
|
String apiKey,
|
||||||
|
boolean imgSwitch,
|
||||||
|
boolean categorySwitch) throws Exception {
|
||||||
|
// --- 1. 图片准备(工作流 103226:无条件执行)---
|
||||||
|
prepareImages(row, apiKey);
|
||||||
|
|
||||||
|
if (!categorySwitch) {
|
||||||
|
// category_switch=false:只出图,不调用 LLM(工作流 127486/1857081)。
|
||||||
|
if (normalize(row.getMainUrl()).isBlank()) {
|
||||||
|
row.setStatus(STATUS_NOT_EXISTS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. 类目匹配:一级 → 二级 → 三级违规候选(141783/1548215/1288150)---
|
||||||
|
CategoryLevel first = matchCategoryLevel(row, null, apiKey);
|
||||||
|
List<CategoryLevel> thirdCandidates;
|
||||||
|
CategoryLevel second;
|
||||||
|
if (isNoMatch(first)) {
|
||||||
|
second = CategoryLevel.none();
|
||||||
|
thirdCandidates = List.of();
|
||||||
|
} else {
|
||||||
|
second = matchCategoryLevel(row, first, apiKey);
|
||||||
|
if (isNoMatch(second)) {
|
||||||
|
thirdCandidates = List.of();
|
||||||
|
} else {
|
||||||
|
thirdCandidates = childrenOf(second.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. 合规检查(1570640:总是执行)---
|
||||||
|
boolean conform = checkConform(row, first, second, thirdCandidates, prompt, apiKey);
|
||||||
|
|
||||||
|
// --- 4. 不符合 / 无 alibaba / img_switch=false:只回填合规结果(139255 true 分支 → 1398750)---
|
||||||
|
if (!conform || row.getAlibaba().isEmpty() || !imgSwitch) {
|
||||||
|
row.setStatus(STATUS_SUCCESS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. 图片对比循环(181250:主图 vs 拼图,候选最多 2 个)---
|
||||||
|
compareLoop(row, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片准备:拼图1(alibaba 前 8 张)、拼图2(8~16 张)拼接后上传 MinIO
|
||||||
|
* supply_images;主图下载转存 MinIO。拼图失败降级为空(对齐插件 dataOnErr
|
||||||
|
* 空串),主图转存失败抛异常(对齐插件 processType 1 终止)。
|
||||||
|
*/
|
||||||
|
private void prepareImages(SimilarAsinResultRowDto row, String apiKey) {
|
||||||
|
List<SimilarAsinResultRowDto.AlibabaItem> alibaba = row.getAlibaba();
|
||||||
|
List<SimilarAsinResultRowDto.AlibabaItem> firstHalf = new ArrayList<>();
|
||||||
|
List<SimilarAsinResultRowDto.AlibabaItem> secondHalf = new ArrayList<>();
|
||||||
|
for (int i = 0; i < alibaba.size(); i++) {
|
||||||
|
SimilarAsinResultRowDto.AlibabaItem item = alibaba.get(i);
|
||||||
|
if (item == null || normalize(item.getUrl()).isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i < 8) {
|
||||||
|
firstHalf.add(item);
|
||||||
|
} else if (i < 16) {
|
||||||
|
secondHalf.add(item);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String puzzle1 = uploadMergedPuzzle(firstHalf, apiKey);
|
||||||
|
if (puzzle1 != null) {
|
||||||
|
row.setPuzzleImg1(puzzle1);
|
||||||
|
}
|
||||||
|
String puzzle2 = uploadMergedPuzzle(secondHalf, apiKey);
|
||||||
|
if (puzzle2 != null) {
|
||||||
|
row.setPuzzleImg2(puzzle2);
|
||||||
|
}
|
||||||
|
|
||||||
|
String primaryUrl = row.getUrl();
|
||||||
|
if (normalize(primaryUrl).isBlank()) {
|
||||||
|
// 主图缺省:main_url 留空,后续 status=不存在(对齐 1857081)。
|
||||||
|
row.setMainUrl("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String stored = uploadExternalUrlToSupplyImages(primaryUrl);
|
||||||
|
if (stored == null) {
|
||||||
|
throw new IllegalStateException("主图转存失败: " + abbreviate(primaryUrl, 160));
|
||||||
|
}
|
||||||
|
row.setMainUrl(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String uploadMergedPuzzle(List<SimilarAsinResultRowDto.AlibabaItem> items, String apiKey) {
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] merged = puzzleImageMerger.merge(items, null);
|
||||||
|
if (merged == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return uploadBytesToSupplyImages(merged);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][llm] puzzle upload failed items={} err={}", items.size(), failureMessage(ex));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryLevel matchCategoryLevel(SimilarAsinResultRowDto row,
|
||||||
|
CategoryLevel parent,
|
||||||
|
String apiKey) {
|
||||||
|
List<CategoryLevel> candidates = parent == null
|
||||||
|
? childrenOf(null)
|
||||||
|
: childrenOf(parent.id());
|
||||||
|
String userText = String.format(USER_TEXT_CATEGORY,
|
||||||
|
firstNonBlank(row.getTitle(), row.getAsin()),
|
||||||
|
normalize(row.getSku()),
|
||||||
|
formatCandidates(candidates));
|
||||||
|
String content = llmClient.invokeChat(properties.getLlmCategoryModel(), SYSTEM_CATEGORY, userText, apiKey);
|
||||||
|
JsonNode root = llmClient.parseJsonContent(content);
|
||||||
|
String name = textOrEmpty(root, "name");
|
||||||
|
if (isNoMatch(new CategoryLevel("", name))) {
|
||||||
|
return CategoryLevel.none();
|
||||||
|
}
|
||||||
|
// LLM 只输出名称,ID 按名称在候选列表内回查(防 LLM 编造不存在的 ID)。
|
||||||
|
CategoryLevel matched = findByName(candidates, name);
|
||||||
|
if (matched == null) {
|
||||||
|
log.warn("[similar-asin][llm] category name not found in candidates name={} candidates={}",
|
||||||
|
name, candidates.size());
|
||||||
|
return CategoryLevel.none();
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称精确回查候选列表(含"无"),找不到返回 null。 */
|
||||||
|
private CategoryLevel findByName(List<CategoryLevel> candidates, String name) {
|
||||||
|
String normalized = normalize(name);
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (CategoryLevel candidate : candidates) {
|
||||||
|
if (normalize(candidate.name()).equals(normalized)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkConform(SimilarAsinResultRowDto row,
|
||||||
|
CategoryLevel first,
|
||||||
|
CategoryLevel second,
|
||||||
|
List<CategoryLevel> thirdCandidates,
|
||||||
|
String prompt,
|
||||||
|
String apiKey) {
|
||||||
|
String userText = String.format(USER_TEXT_CONFORM,
|
||||||
|
firstNonBlank(row.getTitle(), row.getAsin()),
|
||||||
|
normalize(row.getSku()),
|
||||||
|
first.name(),
|
||||||
|
second.name(),
|
||||||
|
formatCandidates(thirdCandidates));
|
||||||
|
String system = prompt == null || prompt.isBlank() ? SYSTEM_CONFORM : SYSTEM_CONFORM + "\n\n额外要求:" + prompt.trim();
|
||||||
|
String content = llmClient.invokeChat(properties.getLlmConformModel(), system, userText, apiKey);
|
||||||
|
JsonNode root = llmClient.parseJsonContent(content);
|
||||||
|
String isConform = textOrEmpty(root, "is_conform");
|
||||||
|
row.setIsConform(isConform);
|
||||||
|
row.setReason(firstNonBlank(textOrEmpty(root, "reason"), ""));
|
||||||
|
row.setCategory(firstNonBlank(textOrEmpty(root, "category"), ""));
|
||||||
|
return SimilarAsinLlmClient.isConformYes(isConform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片对比循环:候选为 puzzle1/puzzle2(非空),主图用原始 url(对齐工作流
|
||||||
|
* 191275 的 data.url)。is_stock=有货 则停;候选全部失败时保持 status=失败
|
||||||
|
* (对齐初始 data 语义 1976304)。
|
||||||
|
*/
|
||||||
|
private void compareLoop(SimilarAsinResultRowDto row, String apiKey) {
|
||||||
|
String mainUrl = row.getUrl();
|
||||||
|
if (normalize(mainUrl).isBlank()) {
|
||||||
|
// 主图缺省:无法对比,保持初始失败语义。
|
||||||
|
row.setStatus(STATUS_FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> candidates = new ArrayList<>(2);
|
||||||
|
if (!normalize(row.getPuzzleImg1()).isBlank()) {
|
||||||
|
candidates.add(row.getPuzzleImg1());
|
||||||
|
}
|
||||||
|
if (!normalize(row.getPuzzleImg2()).isBlank()) {
|
||||||
|
candidates.add(row.getPuzzleImg2());
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
row.setStatus(STATUS_FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.setStatus(STATUS_FAILED);
|
||||||
|
for (String candidate : candidates) {
|
||||||
|
try {
|
||||||
|
compareImage(row, mainUrl, candidate, apiKey);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][llm] compare failed asin={} candidate={} err={}",
|
||||||
|
row.getAsin(), abbreviate(candidate, 160), failureMessage(ex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SimilarAsinLlmClient.isStockAvailable(row.getIsStock())) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compareImage(SimilarAsinResultRowDto row, String mainUrl, String compareUrl, String apiKey) {
|
||||||
|
String userText = String.format(USER_TEXT_COMPARE,
|
||||||
|
normalize(row.getAsin()),
|
||||||
|
normalize(row.getCategory()),
|
||||||
|
firstNonBlank(row.getTitle(), row.getAsin()));
|
||||||
|
String content = llmClient.invokeChatWithImages(properties.getLlmImageCompareModel(),
|
||||||
|
SYSTEM_IMAGE_COMPARE, userText, List.of(mainUrl, compareUrl), apiKey);
|
||||||
|
JsonNode root = llmClient.parseJsonContent(content);
|
||||||
|
String asin = textOrEmpty(root, "asin");
|
||||||
|
String isStock = textOrEmpty(root, "is_stock");
|
||||||
|
String similarity = textOrEmpty(root, "similarity");
|
||||||
|
String status = textOrEmpty(root, "status");
|
||||||
|
String isConform = textOrEmpty(root, "is_conform");
|
||||||
|
String category = textOrEmpty(root, "category");
|
||||||
|
if (!asin.isBlank()) {
|
||||||
|
row.setAsin(asin);
|
||||||
|
}
|
||||||
|
row.setIsStock(isStock);
|
||||||
|
row.setSimilarity(SimilarAsinLlmClient.normalizePercent(similarity));
|
||||||
|
row.setStatus(firstNonBlank(status, STATUS_SUCCESS));
|
||||||
|
if (!isConform.isBlank()) {
|
||||||
|
row.setIsConform(isConform);
|
||||||
|
}
|
||||||
|
if (!category.isBlank()) {
|
||||||
|
row.setCategory(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CategoryLevel> childrenOf(String parentId) {
|
||||||
|
Long parsedId = null;
|
||||||
|
if (parentId != null && !parentId.isBlank()) {
|
||||||
|
try {
|
||||||
|
parsedId = Long.parseLong(parentId.trim());
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
log.warn("[similar-asin][llm] category parent id not a number id={}", parentId);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProductCategoryListVo vo = productCategoryService.children(parsedId, 1, 100);
|
||||||
|
List<CategoryLevel> result = new ArrayList<>();
|
||||||
|
if (vo == null || vo.getItems() == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (ProductCategoryItemVo item : vo.getItems()) {
|
||||||
|
if (item == null || item.getId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.add(new CategoryLevel(String.valueOf(item.getId()), firstNonBlank(item.getName(), "")));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatCandidates(List<CategoryLevel> candidates) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (CategoryLevel candidate : candidates) {
|
||||||
|
if (candidate.name().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
sb.append(candidate.name()).append("(ID:").append(candidate.id()).append(")");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNoMatch(CategoryLevel level) {
|
||||||
|
return level == null || level.name().isBlank() || NO_MATCH.equals(level.name().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传字节到 MinIO supply_images 前缀,返回公网 URL。 */
|
||||||
|
private String uploadBytesToSupplyImages(byte[] bytes) {
|
||||||
|
String objectKey = "supply_images/" + UUID.randomUUID() + ".jpg";
|
||||||
|
ossStorageService.uploadBytes(ossProperties.getBucket(), objectKey, bytes, "image/jpeg");
|
||||||
|
return ossStorageService.getPublicUrl(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载外部图片并转存到 MinIO supply_images,失败返回 null。 */
|
||||||
|
private String uploadExternalUrlToSupplyImages(String url) {
|
||||||
|
byte[] bytes = downloadBytes(url);
|
||||||
|
if (bytes == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String objectKey = "supply_images/" + UUID.randomUUID() + ".jpg";
|
||||||
|
ossStorageService.uploadBytes(ossProperties.getBucket(), objectKey, bytes, "image/jpeg");
|
||||||
|
return ossStorageService.getPublicUrl(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] downloadBytes(String url) {
|
||||||
|
String trimmed = url == null ? "" : url.trim();
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int timeoutSeconds = Math.max(1, properties.getLlmImageDownloadTimeoutSeconds());
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(trimmed))
|
||||||
|
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
.header("Referer", "https://www.coze.cn")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<byte[]> response = downloadHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() == 200 && response.body() != null && response.body().length > 0) {
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
log.warn("[similar-asin][llm] main download http {} url={}", response.statusCode(), abbreviate(trimmed, 160));
|
||||||
|
return null;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][llm] main download fail url={} err={}", abbreviate(trimmed, 160), failureMessage(ex));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient downloadHttpClient() {
|
||||||
|
HttpClient client = downloadHttpClient;
|
||||||
|
if (client != null) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (downloadHttpClient == null) {
|
||||||
|
downloadHttpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return downloadHttpClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutorService rowExecutor() {
|
||||||
|
ExecutorService executor = rowExecutor;
|
||||||
|
if (executor != null) {
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (rowExecutor == null) {
|
||||||
|
rowExecutor = Executors.newThreadPerTaskExecutor(
|
||||||
|
Thread.ofVirtual().name("similar-asin-llm-row-", 0).factory());
|
||||||
|
}
|
||||||
|
return rowExecutor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinResultRowDto copyRow(SimilarAsinResultRowDto source) {
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setSourceFileKey(source.getSourceFileKey());
|
||||||
|
row.setSourceFilename(source.getSourceFilename());
|
||||||
|
row.setRowToken(source.getRowToken());
|
||||||
|
row.setGroupKey(source.getGroupKey());
|
||||||
|
row.setId(source.getId());
|
||||||
|
row.setAsin(source.getAsin());
|
||||||
|
row.setCountry(source.getCountry());
|
||||||
|
row.setSku(source.getSku());
|
||||||
|
row.setPrice(source.getPrice());
|
||||||
|
row.setUrls(source.getUrls());
|
||||||
|
row.setAlibaba(source.getAlibaba());
|
||||||
|
row.setTitle(source.getTitle());
|
||||||
|
row.setUrl(source.getUrl());
|
||||||
|
row.setError(source.getError());
|
||||||
|
row.setDone(source.getDone());
|
||||||
|
row.setStatus(source.getStatus());
|
||||||
|
row.setIsConform(source.getIsConform());
|
||||||
|
row.setReason(source.getReason());
|
||||||
|
row.setCategory(source.getCategory());
|
||||||
|
row.setTitleRisk(source.getTitleRisk());
|
||||||
|
row.setAppearanceRisk(source.getAppearanceRisk());
|
||||||
|
row.setPatentRisk(source.getPatentRisk());
|
||||||
|
row.setConclusion(source.getConclusion());
|
||||||
|
row.setIsStock(source.getIsStock());
|
||||||
|
row.setSimilarity(source.getSimilarity());
|
||||||
|
row.setTitleReason(source.getTitleReason());
|
||||||
|
row.setAppearanceReason(source.getAppearanceReason());
|
||||||
|
row.setPatentReason(source.getPatentReason());
|
||||||
|
row.setMainUrl(source.getMainUrl());
|
||||||
|
row.setPuzzleImg1(source.getPuzzleImg1());
|
||||||
|
row.setPuzzleImg2(source.getPuzzleImg2());
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinResultRowDto markFailed(SimilarAsinResultRowDto row, String failureMessage) {
|
||||||
|
String message = failureMessage == null || failureMessage.isBlank() ? "货源查询失败" : failureMessage;
|
||||||
|
if (normalize(row.getError()).isBlank()) {
|
||||||
|
row.setError(message);
|
||||||
|
}
|
||||||
|
if (normalize(row.getReason()).isBlank()) {
|
||||||
|
row.setReason(message);
|
||||||
|
}
|
||||||
|
if (normalize(row.getStatus()).isBlank()) {
|
||||||
|
row.setStatus(STATUS_FAILED);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String textOrEmpty(JsonNode node, String field) {
|
||||||
|
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? "" : normalize(value.asText(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String failureMessage(Exception ex) {
|
||||||
|
String message = ex == null ? null : ex.getMessage();
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return ex == null ? "LLM call failed" : ex.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String abbreviate(String value, int maxLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.length() <= maxLength) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String preferred, String fallback) {
|
||||||
|
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CategoryLevel(String id, String name) {
|
||||||
|
static CategoryLevel none() {
|
||||||
|
return new CategoryLevel("", NO_MATCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -353,6 +353,7 @@ public class SimilarAsinTaskService {
|
|||||||
private final SimilarAsinFilterConditionMapper filterConditionMapper;
|
private final SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final SimilarAsinCozeClient cozeClient;
|
private final SimilarAsinCozeClient cozeClient;
|
||||||
|
private final SimilarAsinLlmService similarAsinLlmService;
|
||||||
private final SimilarAsinTaskCacheService taskCacheService;
|
private final SimilarAsinTaskCacheService taskCacheService;
|
||||||
private final SimilarAsinProperties properties;
|
private final SimilarAsinProperties properties;
|
||||||
private final TaskFileJobService taskFileJobService;
|
private final TaskFileJobService taskFileJobService;
|
||||||
@@ -2333,6 +2334,11 @@ public class SimilarAsinTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (lockHandle) {
|
try (lockHandle) {
|
||||||
|
if (properties.isDirectLlmEnabled()) {
|
||||||
|
// 直连模式:轮询器退化为"兜底调度器",把还挂着 PENDING 的任务
|
||||||
|
// 重新调度一次批量提交(submitLlmBatch 同步直连),新任务本就走直连。
|
||||||
|
schedulePendingLlmBatches();
|
||||||
|
}
|
||||||
List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
|
List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
|
||||||
if (states == null || states.isEmpty()) {
|
if (states == null || states.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -2353,6 +2359,50 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直连模式兜底调度:把已封口(提交完成)但仍有 PENDING 状态的任务重新调度一次
|
||||||
|
* 批量提交,新批次走 submitLlmBatch 同步直连,由提交路径落 DONE 缓冲/merge。
|
||||||
|
*/
|
||||||
|
private void schedulePendingLlmBatches() {
|
||||||
|
List<TaskScopeStateEntity> states = listOwnedPendingCozeStates();
|
||||||
|
if (states == null || states.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<Long> taskIds = new LinkedHashSet<>();
|
||||||
|
for (TaskScopeStateEntity state : states) {
|
||||||
|
if (state != null && state.getTaskId() != null) {
|
||||||
|
taskIds.add(state.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("[similar-asin] direct-llm poll fallback scheduling pending tasks count={}",
|
||||||
|
taskIds.size());
|
||||||
|
for (Long taskId : taskIds) {
|
||||||
|
cozeTaskExecutor.execute(() -> {
|
||||||
|
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L);
|
||||||
|
if (taskLockHandle == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (taskLockHandle) {
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
|
||||||
|
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
|
||||||
|
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
|
||||||
|
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, task.getId())
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.orderByAsc(TaskChunkEntity::getChunkIndex));
|
||||||
|
submitCozeBatches(task, result, job, chunks, loadAllRowsByBaseId(task));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void pollPendingCozeStatesForTask(Long taskId, List<Long> stateIds) {
|
private void pollPendingCozeStatesForTask(Long taskId, List<Long> stateIds) {
|
||||||
if (taskId == null || stateIds == null || stateIds.isEmpty()) {
|
if (taskId == null || stateIds == null || stateIds.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -2719,6 +2769,10 @@ public class SimilarAsinTaskService {
|
|||||||
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|
||||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||||
}
|
}
|
||||||
|
if (properties.isDirectLlmEnabled()) {
|
||||||
|
return submitLlmBatch(task, result, job, batchRows, batchScopeKey, batchScopeHash,
|
||||||
|
batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId);
|
||||||
|
}
|
||||||
SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
|
SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential();
|
||||||
try {
|
try {
|
||||||
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
|
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
|
||||||
@@ -2779,6 +2833,73 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直连 LLM 模式(directLlmEnabled=true)下的批提交:跳过 Coze 中转,
|
||||||
|
* 由 SimilarAsinLlmService 逐行跑完整链路(拼图/合规/对比),成功后按
|
||||||
|
* 原 Coze 同步 immediate DONE 结果路径集成:scope 去重 → 缓冲或立即 merge。
|
||||||
|
* 行级失败信息经空结果检测保留,与 Coze 同步提交失败行为对齐。
|
||||||
|
*/
|
||||||
|
private boolean submitLlmBatch(FileTaskEntity task,
|
||||||
|
FileResultEntity result,
|
||||||
|
TaskFileJobEntity job,
|
||||||
|
List<SimilarAsinResultRowDto> batchRows,
|
||||||
|
String batchScopeKey,
|
||||||
|
String batchScopeHash,
|
||||||
|
int batchIndex,
|
||||||
|
int batchTotal,
|
||||||
|
String prompt,
|
||||||
|
String apiKey,
|
||||||
|
boolean imgSwitch,
|
||||||
|
boolean categorySwitch,
|
||||||
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
||||||
|
List<SimilarAsinResultRowDto> llmRows;
|
||||||
|
try {
|
||||||
|
llmRows = similarAsinLlmService.inspectRows(batchRows, prompt, apiKey, imgSwitch, categorySwitch);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
String message = firstNonBlank(ex.getMessage(), "LLM submit failed");
|
||||||
|
log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
||||||
|
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message);
|
||||||
|
mergeCozeRowsIntoChunk(task,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cozeClient.markRowsFailed(batchRows, message),
|
||||||
|
allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (llmRows == null || llmRows.isEmpty()) {
|
||||||
|
String message = "LLM submit returned empty result rows";
|
||||||
|
log.warn("[similar-asin] llm submit empty taskId={} jobId={} rows={} batch={}/{}",
|
||||||
|
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal);
|
||||||
|
mergeCozeRowsIntoChunk(task,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cozeClient.markRowsFailed(batchRows, message),
|
||||||
|
allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String emptyResultMessage = emptyCozeResultMessage(llmRows, batchRows.size());
|
||||||
|
if (!emptyResultMessage.isBlank()) {
|
||||||
|
mergeCozeRowsIntoChunk(task,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cozeClient.markRowsFailed(batchRows, emptyResultMessage),
|
||||||
|
allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 落一条 DONE state 承载缓冲 pointer(对齐 Coze 同步 immediate 路径);
|
||||||
|
// 缓冲失败/关闭时回退立即 merge,结果不丢失。
|
||||||
|
if (isCozeResultBufferEnabled()) {
|
||||||
|
TaskScopeStateEntity doneState = persistImmediateCozeDoneState(task, result, job, batchRows,
|
||||||
|
batchScopeKey, batchScopeHash, batchIndex, batchTotal, "llm-direct");
|
||||||
|
if (doneState != null) {
|
||||||
|
bufferCozeRowsOrMerge(doneState, readCozeBatchContext(doneState), llmRows, task, allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergeCozeRowsIntoChunk(task, null, null, llmRows, allRowsByBaseId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void savePendingCozeBatchState(FileTaskEntity task,
|
private void savePendingCozeBatchState(FileTaskEntity task,
|
||||||
FileResultEntity result,
|
FileResultEntity result,
|
||||||
TaskFileJobEntity job,
|
TaskFileJobEntity job,
|
||||||
@@ -2918,6 +3039,13 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 直连模式:不再轮询 Coze,直接把存量批次重跑一遍直连 LLM(submitLlmBatch 内部
|
||||||
|
// 同步落 DONE 缓冲/merge 并触发 finalize),把历史遗留 PENDING 状态清掉。
|
||||||
|
if (properties.isDirectLlmEnabled()) {
|
||||||
|
if (submitLlmBatchForPendingState(state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
|
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3031,6 +3159,48 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直连模式下清存量 PENDING 状态:把该 state 的批次载荷重跑一遍直连 LLM,
|
||||||
|
* 结果落 DONE 缓冲/merge,由 maybeFinalizeCozeJob 触发收尾,最后把 state 置为终态。
|
||||||
|
* 重跑失败时先试 split/retry 路径(复用 Coze 分类器与重试语义),仍失败则标记失败。
|
||||||
|
* 返回 true 表示本批次已被本轮处理完(调用方直接 return,不再走 Coze 轮询)。
|
||||||
|
*/
|
||||||
|
private boolean submitLlmBatchForPendingState(TaskScopeStateEntity state) {
|
||||||
|
if (state == null || state.getId() == null || state.getTaskId() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = taskForPoll(state.getTaskId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<SimilarAsinResultRowDto> batchRows = readCozeBatchRows(state);
|
||||||
|
if (batchRows == null || batchRows.isEmpty()) {
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 批次载荷缺失");
|
||||||
|
maybeFinalizeCozeJob(state.getTaskId(), readCozeBatchContext(state));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
CozeBatchContext context = readCozeBatchContext(state);
|
||||||
|
String prompt = readAiPrompt(task);
|
||||||
|
String apiKey = readApiKey(task);
|
||||||
|
boolean imgSwitch = readImgSwitch(task);
|
||||||
|
boolean categorySwitch = readCategorySwitch(task);
|
||||||
|
boolean submitted = submitLlmBatch(task, null, taskFileJobService.findById(
|
||||||
|
context == null ? null : context.jobId()),
|
||||||
|
batchRows, state.getScopeKey(), state.getScopeHash(),
|
||||||
|
context == null ? 1 : context.batchIndex(),
|
||||||
|
context == null ? 1 : context.batchTotal(),
|
||||||
|
prompt, apiKey, imgSwitch, categorySwitch,
|
||||||
|
allRowsByBaseIdForPoll(task));
|
||||||
|
if (submitted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 直连重跑未真正提交(提交异常已被 submitLlmBatch 内部消化为失败 merge):
|
||||||
|
// 直接把 state 置为终态并触发收尾,避免 PENDING 永远挂着。
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_FAILED, "直连模式重跑批次失败");
|
||||||
|
maybeFinalizeCozeJob(state.getTaskId(), context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void finalizeTimedOutCozeStatesForTask(Long taskId) {
|
private void finalizeTimedOutCozeStatesForTask(Long taskId) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+310
@@ -0,0 +1,310 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.imageio.IIOImage;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageWriteParam;
|
||||||
|
import javax.imageio.ImageWriter;
|
||||||
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.FontMetrics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情页图片拼接:复刻 Coze 插件 image_pinjie 的行为。
|
||||||
|
* 横版(Orientation=2):alibaba 图片按 4 列网格铺到 2560px 宽画布,
|
||||||
|
* 每格下方叠加白色价格条(红色粗体、两位小数),整图 JPEG(95) 输出。
|
||||||
|
* 图片下载失败时保留原图(URL 回退),与 Coze 插件语义对齐。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class PuzzleImageMerger {
|
||||||
|
|
||||||
|
private static final int CANVAS_WIDTH = 2560;
|
||||||
|
private static final int COLS = 4;
|
||||||
|
private static final float JPEG_QUALITY = 0.95f;
|
||||||
|
private static final int FALLBACK_FONT_SIZE = 40;
|
||||||
|
|
||||||
|
private final com.nanri.aiimage.config.SimilarAsinProperties properties;
|
||||||
|
private volatile HttpClient sharedHttpClient;
|
||||||
|
|
||||||
|
public PuzzleImageMerger(com.nanri.aiimage.config.SimilarAsinProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接一张横版网格图。
|
||||||
|
*
|
||||||
|
* @param items 带 price 的图片项(alibaba 列表);为空时返回 null
|
||||||
|
* @param sourceRow 原始行(用于缺 url 时兜底 row.getUrl())
|
||||||
|
* @return 拼接后的 JPEG 字节,全部图片下载失败时返回 null
|
||||||
|
*/
|
||||||
|
public byte[] merge(List<SimilarAsinResultRowDto.AlibabaItem> items, SimilarAsinResultRowDto sourceRow) {
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
List<BigDecimal> prices = new ArrayList<>();
|
||||||
|
for (SimilarAsinResultRowDto.AlibabaItem item : items) {
|
||||||
|
String url = item == null ? "" : item.getUrl();
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
urls.add(url);
|
||||||
|
Object rawPrice = item == null ? null : item.getRawPrice();
|
||||||
|
prices.add(parsePrice(rawPrice));
|
||||||
|
}
|
||||||
|
if (urls.isEmpty() && sourceRow != null) {
|
||||||
|
List<String> fallbackUrls = sourceRow.getUrls();
|
||||||
|
if (fallbackUrls != null && !fallbackUrls.isEmpty()) {
|
||||||
|
urls.addAll(fallbackUrls);
|
||||||
|
for (int i = 0; i < fallbackUrls.size(); i++) {
|
||||||
|
prices.add(parsePrice(sourceRow.getPrice()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return merge(urls, prices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] merge(List<String> urls, List<BigDecimal> prices) {
|
||||||
|
if (urls == null || urls.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<byte[]> rawImages = new ArrayList<>(urls.size());
|
||||||
|
List<BigDecimal> rawPrices = new ArrayList<>(urls.size());
|
||||||
|
List<String> failedUrls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < urls.size(); i++) {
|
||||||
|
String url = urls.get(i);
|
||||||
|
byte[] raw = downloadImage(url);
|
||||||
|
if (raw == null) {
|
||||||
|
failedUrls.add(url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rawImages.add(raw);
|
||||||
|
rawPrices.add(i < prices.size() ? prices.get(i) : null);
|
||||||
|
}
|
||||||
|
if (rawImages.isEmpty()) {
|
||||||
|
if (!failedUrls.isEmpty()) {
|
||||||
|
log.warn("[similar-asin][puzzle] all images download failed urls={}", failedUrls.size());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage[] decoded = new BufferedImage[rawImages.size()];
|
||||||
|
boolean anyFailed = false;
|
||||||
|
for (int i = 0; i < rawImages.size(); i++) {
|
||||||
|
BufferedImage image = decode(rawImages.get(i));
|
||||||
|
if (image == null) {
|
||||||
|
anyFailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
decoded[i] = image;
|
||||||
|
}
|
||||||
|
if (countNonNull(decoded) == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasPrice = rawPrices.stream().anyMatch(price -> price != null);
|
||||||
|
int cellW = CANVAS_WIDTH / COLS;
|
||||||
|
BufferedImage firstImage = firstNonNull(decoded);
|
||||||
|
double aspectRatio = firstImage == null ? 16.0 / 9.0 : (double) firstImage.getHeight() / firstImage.getWidth();
|
||||||
|
int imageRegionHeight = (int) Math.floor(cellW * aspectRatio);
|
||||||
|
int textRegionHeight = hasPrice ? (int) Math.floor(cellW * 0.22) : 0;
|
||||||
|
int cellH = imageRegionHeight + textRegionHeight;
|
||||||
|
int rows = Math.max(1, (int) Math.ceil((double) countNonNull(decoded) / COLS));
|
||||||
|
int canvasHeight = rows * cellH;
|
||||||
|
|
||||||
|
BufferedImage canvas = new BufferedImage(CANVAS_WIDTH, canvasHeight, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = canvas.createGraphics();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.setColor(Color.WHITE);
|
||||||
|
g.fillRect(0, 0, CANVAS_WIDTH, canvasHeight);
|
||||||
|
|
||||||
|
int drawnIndex = 0;
|
||||||
|
for (int i = 0; i < decoded.length; i++) {
|
||||||
|
BufferedImage image = decoded[i];
|
||||||
|
if (image == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int row = drawnIndex / COLS;
|
||||||
|
int col = drawnIndex % COLS;
|
||||||
|
int left = col * cellW;
|
||||||
|
int top = row * cellH;
|
||||||
|
g.drawImage(image, left, top, cellW, imageRegionHeight, null);
|
||||||
|
BigDecimal price = rawPrices.get(i);
|
||||||
|
if (hasPrice && price != null) {
|
||||||
|
drawPrice(g, price, left, top + imageRegionHeight, cellW, textRegionHeight);
|
||||||
|
}
|
||||||
|
drawnIndex++;
|
||||||
|
}
|
||||||
|
g.dispose();
|
||||||
|
|
||||||
|
if (anyFailed) {
|
||||||
|
log.warn("[similar-asin][puzzle] some images failed, used {}/{}", countNonNull(decoded), decoded.length);
|
||||||
|
}
|
||||||
|
return encodeJpeg(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawPrice(Graphics2D g, BigDecimal price, int left, int top, int cellW, int textRegionHeight) {
|
||||||
|
int fontSize = Math.max(FALLBACK_FONT_SIZE, (int) Math.floor(cellW * 0.15));
|
||||||
|
Font font = new Font(Font.SANS_SERIF, Font.BOLD, fontSize);
|
||||||
|
g.setFont(font);
|
||||||
|
g.setColor(Color.WHITE);
|
||||||
|
g.fillRect(left, top, cellW, textRegionHeight);
|
||||||
|
String priceText = price.setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString();
|
||||||
|
FontMetrics metrics = g.getFontMetrics(font);
|
||||||
|
int textWidth = metrics.stringWidth(priceText);
|
||||||
|
int x = left + (cellW - textWidth) / 2;
|
||||||
|
int baseline = top + (textRegionHeight - metrics.getHeight()) / 2 + metrics.getAscent();
|
||||||
|
g.setColor(new Color(0xFF4500));
|
||||||
|
g.setStroke(new BasicStroke(1.0f));
|
||||||
|
g.drawString(priceText, x, baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parsePrice(Object raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw instanceof Number number) {
|
||||||
|
return new BigDecimal(number.toString());
|
||||||
|
}
|
||||||
|
String text = String.valueOf(raw).trim();
|
||||||
|
if (text.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(text);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] downloadImage(String url) {
|
||||||
|
String trimmed = url == null ? "" : url.trim();
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int timeoutSeconds = Math.max(1, properties.getLlmImageDownloadTimeoutSeconds());
|
||||||
|
int attempts = 2;
|
||||||
|
for (int attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(trimmed))
|
||||||
|
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
.header("Referer", "https://www.coze.cn")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<byte[]> response = httpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() == 200 && response.body() != null && response.body().length > 0) {
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
log.warn("[similar-asin][puzzle] download http {} url={} attempt={}/{}",
|
||||||
|
response.statusCode(), abbreviate(trimmed, 160), attempt, attempts);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin][puzzle] download fail url={} attempt={}/{} err={}",
|
||||||
|
abbreviate(trimmed, 160), attempt, attempts, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient httpClient() {
|
||||||
|
HttpClient client = sharedHttpClient;
|
||||||
|
if (client != null) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (sharedHttpClient == null) {
|
||||||
|
sharedHttpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return sharedHttpClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage decode(byte[] raw) {
|
||||||
|
try (InputStream stream = new ByteArrayInputStream(raw)) {
|
||||||
|
return ImageIO.read(stream);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] encodeJpeg(BufferedImage image) {
|
||||||
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
|
||||||
|
if (!writers.hasNext()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ImageWriter writer = writers.next();
|
||||||
|
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||||
|
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||||
|
param.setCompressionQuality(JPEG_QUALITY);
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||||
|
writer.setOutput(ios);
|
||||||
|
writer.write(null, new IIOImage(image, null, null), param);
|
||||||
|
writer.dispose();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
log.warn("[similar-asin][puzzle] encode jpeg failed err={}", ex.getMessage());
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (writer != null) {
|
||||||
|
writer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countNonNull(Object[] array) {
|
||||||
|
int count = 0;
|
||||||
|
for (Object value : array) {
|
||||||
|
if (value != null) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage firstNonNull(BufferedImage[] array) {
|
||||||
|
for (BufferedImage image : array) {
|
||||||
|
if (image != null) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String abbreviate(String value, int maxLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.length() <= maxLength) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -195,6 +195,7 @@ public class ZiniaoShopIndexService {
|
|||||||
int skippedApiKeyCount = 0;
|
int skippedApiKeyCount = 0;
|
||||||
int whitelistSkippedApiKeyCount = 0;
|
int whitelistSkippedApiKeyCount = 0;
|
||||||
boolean completeCoverage = true;
|
boolean completeCoverage = true;
|
||||||
|
List<String> whitelistBlockedApiKeys = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
List<ZiniaoApiKeyProvider.ApiKeyAccount> allApiKeyAccounts = ziniaoApiKeyProvider.listApiKeyAccounts();
|
List<ZiniaoApiKeyProvider.ApiKeyAccount> allApiKeyAccounts = ziniaoApiKeyProvider.listApiKeyAccounts();
|
||||||
List<ZiniaoApiKeyProvider.ApiKeyAccount> apiKeyAccounts = selectApiKeyBatchByOffset(
|
List<ZiniaoApiKeyProvider.ApiKeyAccount> apiKeyAccounts = selectApiKeyBatchByOffset(
|
||||||
@@ -217,6 +218,7 @@ public class ZiniaoShopIndexService {
|
|||||||
completeCoverage = false;
|
completeCoverage = false;
|
||||||
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
||||||
whitelistSkippedApiKeyCount++;
|
whitelistSkippedApiKeyCount++;
|
||||||
|
whitelistBlockedApiKeys.add(apiKey);
|
||||||
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
||||||
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} msg={}", companyName, ex.getMessage());
|
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} msg={}", companyName, ex.getMessage());
|
||||||
continue;
|
continue;
|
||||||
@@ -233,6 +235,7 @@ public class ZiniaoShopIndexService {
|
|||||||
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
||||||
skippedApiKeyCount++;
|
skippedApiKeyCount++;
|
||||||
whitelistSkippedApiKeyCount++;
|
whitelistSkippedApiKeyCount++;
|
||||||
|
whitelistBlockedApiKeys.add(apiKey);
|
||||||
completeCoverage = false;
|
completeCoverage = false;
|
||||||
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
||||||
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} msg={}",
|
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} msg={}",
|
||||||
@@ -252,6 +255,7 @@ public class ZiniaoShopIndexService {
|
|||||||
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
||||||
skippedApiKeyCount++;
|
skippedApiKeyCount++;
|
||||||
whitelistSkippedApiKeyCount++;
|
whitelistSkippedApiKeyCount++;
|
||||||
|
whitelistBlockedApiKeys.add(apiKey);
|
||||||
completeCoverage = false;
|
completeCoverage = false;
|
||||||
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
|
||||||
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} userId={} msg={}",
|
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} userId={} msg={}",
|
||||||
@@ -380,6 +384,7 @@ public class ZiniaoShopIndexService {
|
|||||||
log.info("[ziniao-index] skip stale marking for partial refresh completedApiKeys={}/{} skippedApiKeys={} nextOffset={}",
|
log.info("[ziniao-index] skip stale marking for partial refresh completedApiKeys={}/{} skippedApiKeys={} nextOffset={}",
|
||||||
completedApiKeyCount, allApiKeyAccounts.size(), skippedApiKeyCount, nextOffset);
|
completedApiKeyCount, allApiKeyAccounts.size(), skippedApiKeyCount, nextOffset);
|
||||||
}
|
}
|
||||||
|
syncWhitelistBlockedMarks(whitelistBlockedApiKeys, now);
|
||||||
|
|
||||||
cursor.setStatus("SUCCESS");
|
cursor.setStatus("SUCCESS");
|
||||||
List<String> refreshMessages = new ArrayList<>();
|
List<String> refreshMessages = new ArrayList<>();
|
||||||
@@ -583,6 +588,75 @@ public class ZiniaoShopIndexService {
|
|||||||
return tb >= ta ? b : a;
|
return tb >= ta ? b : a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本轮因 IP 白名单被跳过的 apiKey,其名下的索引行保持 ACTIVE 且 lastRefreshedAt 不再更新,
|
||||||
|
* 20 分钟后会落入"已过保鲜期"。这里给这些行写入 refreshBlockedReason / lastRefreshBlockedAt,
|
||||||
|
* 让查询侧 {@link #shouldBypassFreshnessBecauseWhitelistFailure} 得以豁免保鲜期判断。
|
||||||
|
* 仅写入本轮被阻断的键对应行;成功键的行一旦重新 put() 即为无阻断字段的新对象。
|
||||||
|
*/
|
||||||
|
private void syncWhitelistBlockedMarks(List<String> whitelistBlockedApiKeys, long now) {
|
||||||
|
if (whitelistBlockedApiKeys == null || whitelistBlockedApiKeys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> blockedApiKeyHashes = new HashSet<>();
|
||||||
|
for (String apiKey : whitelistBlockedApiKeys) {
|
||||||
|
blockedApiKeyHashes.add(buildApiKeyHash(apiKey));
|
||||||
|
}
|
||||||
|
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreService.listAliveEntitiesByType(
|
||||||
|
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY,
|
||||||
|
SHOP_INDEX_LIST_LIMIT
|
||||||
|
);
|
||||||
|
if (entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ZiniaoMemoryStoreEntity> updates = new ArrayList<>();
|
||||||
|
int changedCount = 0;
|
||||||
|
for (ZiniaoMemoryStoreEntity entity : entities) {
|
||||||
|
if (entity == null || entity.getId() == null || entity.getPayloadJson() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ZiniaoShopIndexEntryDto existingEntry;
|
||||||
|
try {
|
||||||
|
existingEntry = objectMapper.readValue(entity.getPayloadJson(), ZiniaoShopIndexEntryDto.class);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[ziniao-index] skip corrupt shop_index row while marking whitelist-blocked cacheKey={}", entity.getCacheKey());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingEntry == null || !STATUS_ACTIVE.equals(existingEntry.getStatus())
|
||||||
|
|| existingEntry.getApiKeyHash() == null
|
||||||
|
|| !blockedApiKeyHashes.contains(existingEntry.getApiKeyHash())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long lastRefreshed = existingEntry.getLastRefreshedAt() == null ? 0L : existingEntry.getLastRefreshedAt();
|
||||||
|
boolean changed = !REFRESH_BLOCKED_REASON_IP_WHITELIST.equals(existingEntry.getRefreshBlockedReason())
|
||||||
|
|| existingEntry.getLastRefreshBlockedAt() == null
|
||||||
|
|| existingEntry.getLastRefreshBlockedAt() < lastRefreshed;
|
||||||
|
if (!changed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingEntry.setRefreshBlockedReason(REFRESH_BLOCKED_REASON_IP_WHITELIST);
|
||||||
|
existingEntry.setLastRefreshBlockedAt(Math.max(now, lastRefreshed));
|
||||||
|
String newPayload;
|
||||||
|
try {
|
||||||
|
newPayload = objectMapper.writeValueAsString(existingEntry);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("写入紫鸟记忆存储失败", ex);
|
||||||
|
}
|
||||||
|
entity.setPayloadJson(newPayload);
|
||||||
|
entity.setExpiresAt(LocalDateTime.now().plus(resolveEntryTtl()));
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
updates.add(entity);
|
||||||
|
changedCount++;
|
||||||
|
}
|
||||||
|
if (!updates.isEmpty()) {
|
||||||
|
ziniaoMemoryStoreService.updateStaleMarks(updates);
|
||||||
|
}
|
||||||
|
if (changedCount > 0) {
|
||||||
|
log.info("[ziniao-index] marked whitelist-blocked shop_index rows count={} blockedApiKeys={}",
|
||||||
|
changedCount, whitelistBlockedApiKeys.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void markMissingEntriesAsStale(Set<String> activeCacheKeys, long now) {
|
private void markMissingEntriesAsStale(Set<String> activeCacheKeys, long now) {
|
||||||
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreService.listAliveEntitiesByType(
|
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreService.listAliveEntitiesByType(
|
||||||
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY,
|
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY,
|
||||||
|
|||||||
@@ -225,16 +225,16 @@ aiimage:
|
|||||||
connect-timeout-millis: ${AIIMAGE_BRAND_CHECK_CONNECT_TIMEOUT_MILLIS:10000}
|
connect-timeout-millis: ${AIIMAGE_BRAND_CHECK_CONNECT_TIMEOUT_MILLIS:10000}
|
||||||
read-timeout-millis: ${AIIMAGE_BRAND_CHECK_READ_TIMEOUT_MILLIS:60000}
|
read-timeout-millis: ${AIIMAGE_BRAND_CHECK_READ_TIMEOUT_MILLIS:60000}
|
||||||
appearance-patent:
|
appearance-patent:
|
||||||
coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn}
|
llm-host: ${AIIMAGE_APPEARANCE_PATENT_LLM_HOST:https://ai.t8star.org}
|
||||||
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
title-model: ${AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL:deepseek-v4-flash}
|
||||||
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
|
appearance-model: ${AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL:gemini-3.7-flash}
|
||||||
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:}
|
llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000}
|
||||||
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10}
|
llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000}
|
||||||
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
llm-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_READ_TIMEOUT_MILLIS:180000}
|
||||||
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
|
llm-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10}
|
||||||
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
|
llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10}
|
||||||
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
|
llm-retry-times: ${AIIMAGE_APPEARANCE_PATENT_LLM_RETRY_TIMES:3}
|
||||||
coze-flush-pending-minutes: ${AIIMAGE_APPEARANCE_PATENT_COZE_FLUSH_PENDING_MINUTES:1}
|
flush-pending-minutes: ${AIIMAGE_APPEARANCE_PATENT_FLUSH_PENDING_MINUTES:${AIIMAGE_APPEARANCE_PATENT_COZE_FLUSH_PENDING_MINUTES:1}}
|
||||||
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30}
|
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30}
|
||||||
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
|
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||||
similar-asin:
|
similar-asin:
|
||||||
@@ -266,6 +266,18 @@ aiimage:
|
|||||||
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
|
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
|
||||||
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
|
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
|
||||||
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
|
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
|
||||||
|
direct-llm-enabled: ${AIIMAGE_SIMILAR_ASIN_DIRECT_LLM_ENABLED:true}
|
||||||
|
llm-host: ${AIIMAGE_SIMILAR_ASIN_LLM_HOST:https://ai.t8star.org}
|
||||||
|
llm-api-key: ${AIIMAGE_SIMILAR_ASIN_LLM_API_KEY:}
|
||||||
|
llm-category-model: ${AIIMAGE_SIMILAR_ASIN_LLM_CATEGORY_MODEL:gemini-3.5-flash-lite}
|
||||||
|
llm-conform-model: ${AIIMAGE_SIMILAR_ASIN_LLM_CONFORM_MODEL:gemini-3.5-flash-lite}
|
||||||
|
llm-image-compare-model: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_COMPARE_MODEL:gemini-3.7-flash}
|
||||||
|
llm-max-tokens: ${AIIMAGE_SIMILAR_ASIN_LLM_MAX_TOKENS:64000}
|
||||||
|
llm-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_CONNECT_TIMEOUT_MILLIS:10000}
|
||||||
|
llm-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_READ_TIMEOUT_MILLIS:180000}
|
||||||
|
llm-retry-times: ${AIIMAGE_SIMILAR_ASIN_LLM_RETRY_TIMES:3}
|
||||||
|
llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5}
|
||||||
|
llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10}
|
||||||
collect-data:
|
collect-data:
|
||||||
stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES:30}
|
stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES:30}
|
||||||
stale-check-cron: ${AIIMAGE_COLLECT_DATA_STALE_CHECK_CRON:*/30 * * * * *}
|
stale-check-cron: ${AIIMAGE_COLLECT_DATA_STALE_CHECK_CRON:*/30 * * * * *}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- V98: 后台管理菜单「无效ASIN数据」重命名为「品牌数据库」
|
||||||
|
-- 只更新 admin 菜单的 name 显示名,column_key / route_path 保持不变,
|
||||||
|
-- 所有权限校验、前端分组路由、面板加载逻辑均按 route_path 匹配,不受影响。
|
||||||
|
-- 对应前端静态文案见 backend/static/admin.js 与 backend/web_source/admin.html。
|
||||||
|
UPDATE `columns` SET `name` = '品牌数据库'
|
||||||
|
WHERE `menu_type` = 'admin' AND `column_key` = 'admin_invalid_asin_data';
|
||||||
@@ -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;
|
||||||
+7
-7
@@ -26,21 +26,21 @@ class AppearancePatentCozeClientTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
|
void markRowsFailedLeavesUserFacingResultFilledWithReviewMessage() {
|
||||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
row.setId("1");
|
row.setId("1");
|
||||||
|
|
||||||
List<AppearancePatentResultRowDto> failedRows =
|
List<AppearancePatentResultRowDto> failedRows =
|
||||||
client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
|
client.markRowsFailed(List.of(row), "LLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
|
|
||||||
assertThat(failedRows).hasSize(1);
|
assertThat(failedRows).hasSize(1);
|
||||||
AppearancePatentResultRowDto failed = failedRows.get(0);
|
AppearancePatentResultRowDto failed = failedRows.get(0);
|
||||||
assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
|
assertThat(failed.getError()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
assertThat(failed.getStatus()).isEqualTo("FAILED");
|
assertThat(failed.getStatus()).isEqualTo("FAILED");
|
||||||
assertThat(failed.getTitleRisk()).isNull();
|
assertThat(failed.getTitleRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
assertThat(failed.getAppearanceRisk()).isNull();
|
assertThat(failed.getAppearanceRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
assertThat(failed.getPatentRisk()).isNull();
|
assertThat(failed.getPatentRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
assertThat(failed.getConclusion()).isNull();
|
assertThat(failed.getConclusion()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
package com.nanri.aiimage.modules.appearancepatent.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||||
|
import com.nanri.aiimage.config.BrandCheckProperties;
|
||||||
|
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class AppearancePatentLlmClientHttpTest {
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
|
||||||
|
private final List<String> capturedBodies = new ArrayList<>();
|
||||||
|
|
||||||
|
private AppearancePatentCozeClient client;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws IOException {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/v1/chat/completions", this::handleChat);
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
AppearancePatentProperties properties = new AppearancePatentProperties();
|
||||||
|
properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort());
|
||||||
|
properties.setLlmRetryTimes(3);
|
||||||
|
client = new AppearancePatentCozeClient(
|
||||||
|
properties,
|
||||||
|
objectMapper,
|
||||||
|
null,
|
||||||
|
new BrandCheckClient(new BrandCheckProperties(), null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleChat(HttpExchange exchange) throws IOException {
|
||||||
|
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
capturedBodies.add(body);
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> request = objectMapper.readValue(body, Map.class);
|
||||||
|
String model = String.valueOf(request.get("model"));
|
||||||
|
callCounts.computeIfAbsent(model, ignored -> new AtomicInteger()).incrementAndGet();
|
||||||
|
|
||||||
|
String content;
|
||||||
|
if (model.contains("deepseek")) {
|
||||||
|
// 商标提取:按标题内容返回品牌词或"无"
|
||||||
|
String messages = String.valueOf(request.get("messages"));
|
||||||
|
if (messages.contains("Apple")) {
|
||||||
|
content = "Apple,Apple";
|
||||||
|
} else {
|
||||||
|
content = "无";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 外观检测:返回 JSON(带 ```json 包裹与换行,模拟脏输出)
|
||||||
|
if (messagesBodyContains(exchange, "原创个性杯")) {
|
||||||
|
content = "```json\n{\"appearance_status\": \"侵权\", \"appearance_reason\": \"【视觉拆解】:特殊造型\\n【判定依据】:高度相似知名设计\"}\n```";
|
||||||
|
} else {
|
||||||
|
content = "{\"appearance_status\": \"无侵权\", \"appearance_reason\": \"【视觉拆解】:普通直筒杯。\\n【对比评估】:行业通用基础形状。\\n【判定依据】:无侵权。\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String response = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"model", model,
|
||||||
|
"choices", List.of(Map.of("message", Map.of("content", content)))
|
||||||
|
));
|
||||||
|
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
byte[] bytes = ("{\"error\":{\"message\":\"" + ex.getMessage() + "\"}}").getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(500, bytes.length);
|
||||||
|
try (OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean messagesBodyContains(HttpExchange exchange, String text) {
|
||||||
|
return capturedBodies.stream().anyMatch(b -> b.contains(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppearancePatentResultRowDto row(String id, String asin, String title, String sku, String url) {
|
||||||
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
|
row.setId(id);
|
||||||
|
row.setAsin(asin);
|
||||||
|
row.setTitle(title);
|
||||||
|
row.setSku(sku);
|
||||||
|
row.setUrl(url);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inspectRowRunsBothModelsAndParsesJsonAppearance() {
|
||||||
|
AppearancePatentResultRowDto row = row("1", "B001", "Apple Magic Case", "AC-1", "https://img.example.com/1.jpg");
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(1);
|
||||||
|
AppearancePatentResultRowDto result = rows.get(0);
|
||||||
|
assertThat(result.getAsin()).isEqualTo("B001");
|
||||||
|
assertThat(result.getAppearanceRisk()).isEqualTo("无侵权");
|
||||||
|
assertThat(result.getAppearanceReason()).contains("视觉拆解");
|
||||||
|
assertThat(result.getTitleReason()).contains("Apple");
|
||||||
|
assertThat(callCounts.get("deepseek-v4-flash")).hasValue(1);
|
||||||
|
assertThat(callCounts.get("gemini-3.7-flash")).hasValue(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appearanceJsonWithCodeFenceAndEscapedNewlineIsUnwrapped() {
|
||||||
|
AppearancePatentResultRowDto row = row("2", "B002", "原创个性杯", "CUP-9", "https://img.example.com/2.jpg");
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
|
||||||
|
|
||||||
|
AppearancePatentResultRowDto result = rows.get(0);
|
||||||
|
assertThat(result.getAppearanceRisk()).isEqualTo("侵权");
|
||||||
|
assertThat(result.getAppearanceReason()).contains("高度相似知名设计");
|
||||||
|
assertThat(result.getConclusion()).isEqualTo("侵权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleNoneSkipsBrandCheckAndMarksNoInfringement() {
|
||||||
|
AppearancePatentResultRowDto row = row("3", "B003", "普通收纳盒", "", "https://img.example.com/3.jpg");
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
|
||||||
|
|
||||||
|
AppearancePatentResultRowDto result = rows.get(0);
|
||||||
|
assertThat(result.getTitleRisk()).isEqualTo("无侵权");
|
||||||
|
assertThat(result.getAppearanceRisk()).isEqualTo("无侵权");
|
||||||
|
assertThat(result.getConclusion()).isEqualTo("无侵权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingUrlFallsBackToAppearanceAnomalyWithoutLlmCall() {
|
||||||
|
AppearancePatentResultRowDto row = row("4", "B004", "测试商品", "", "");
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
|
||||||
|
|
||||||
|
AppearancePatentResultRowDto result = rows.get(0);
|
||||||
|
assertThat(result.getAppearanceRisk()).isEqualTo("外观识别异常");
|
||||||
|
assertThat(callCounts.get("deepseek-v4-flash")).isNull();
|
||||||
|
assertThat(callCounts.get("gemini-3.7-flash")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingApiKeyKeepsRowsUntouched() {
|
||||||
|
AppearancePatentResultRowDto row = row("5", "B005", "测试", "", "https://img.example.com/5.jpg");
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "");
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(1);
|
||||||
|
assertThat(rows.get(0).getAppearanceRisk()).isNull();
|
||||||
|
assertThat(callCounts).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appearanceRequestCarriesImageUrlAndJsonResponseFormat() {
|
||||||
|
row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg");
|
||||||
|
client.inspectRows(List.of(row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg")), null, "test-key");
|
||||||
|
|
||||||
|
String appearanceBody = capturedBodies.stream()
|
||||||
|
.filter(b -> b.contains("gemini-3.7-flash"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(appearanceBody).contains("https://img.example.com/6.jpg");
|
||||||
|
assertThat(appearanceBody).contains("\"type\":\"image_url\"");
|
||||||
|
assertThat(appearanceBody).contains("\"type\":\"json_object\"");
|
||||||
|
assertThat(appearanceBody).contains("产品描述:普通数据线");
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.service;
|
|||||||
|
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
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.mapper.ShopManageMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -25,6 +26,8 @@ class ShopManageServiceTest {
|
|||||||
private ShopManageGroupService shopManageGroupService;
|
private ShopManageGroupService shopManageGroupService;
|
||||||
@Mock
|
@Mock
|
||||||
private ShopCredentialCryptoService shopCredentialCryptoService;
|
private ShopCredentialCryptoService shopCredentialCryptoService;
|
||||||
|
@Mock
|
||||||
|
private ShopCredentialCheckMapper shopCredentialCheckMapper;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ShopManageService service;
|
private ShopManageService service;
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import javax.net.ssl.SNIHostName;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class LlmGatewayTlsProbe {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String host = "ai.t8star.org";
|
||||||
|
String apiKey = args[0];
|
||||||
|
|
||||||
|
System.out.println("[probe] java=" + System.getProperty("java.version")
|
||||||
|
+ " tls=" + System.getProperty("java.vm.name"));
|
||||||
|
for (InetAddress a : InetAddress.getAllByName(host)) {
|
||||||
|
System.out.println("[probe] dns " + a);
|
||||||
|
}
|
||||||
|
|
||||||
|
rawHandshake(host, null, "default");
|
||||||
|
rawHandshake(host, "TLSv1.2", "tls12-only");
|
||||||
|
|
||||||
|
httpClientCall(host, apiKey, null, "jdk-http-default");
|
||||||
|
httpClientCall(host, apiKey, "TLSv1.2", "jdk-http-tls12");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rawHandshake(String host, String protocol, String label) throws Exception {
|
||||||
|
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||||
|
try (SSLSocket socket = (SSLSocket) factory.createSocket(host, 443)) {
|
||||||
|
socket.setSoTimeout(15000);
|
||||||
|
SSLParameters params = socket.getSSLParameters();
|
||||||
|
if (protocol != null) {
|
||||||
|
params.setProtocols(new String[]{protocol});
|
||||||
|
}
|
||||||
|
params.setServerNames(List.of(new SNIHostName(host)));
|
||||||
|
socket.setSSLParameters(params);
|
||||||
|
socket.startHandshake();
|
||||||
|
System.out.println("[probe] raw[" + label + "] OK proto=" + socket.getSession().getProtocol()
|
||||||
|
+ " cipher=" + socket.getSession().getCipherSuite());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
System.out.println("[probe] raw[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void httpClientCall(String host, String apiKey, String protocol, String label) throws Exception {
|
||||||
|
HttpClient.Builder builder = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1);
|
||||||
|
if (protocol != null) {
|
||||||
|
SSLContext context = SSLContext.getInstance("TLS");
|
||||||
|
context.init(null, null, null);
|
||||||
|
builder.sslContext(context);
|
||||||
|
}
|
||||||
|
HttpClient client = builder.build();
|
||||||
|
try {
|
||||||
|
String body = "{\"model\":\"gemini-3.5-flash-lite\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}";
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://" + host + "/v1/chat/completions"))
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
String text = response.body();
|
||||||
|
System.out.println("[probe] http[" + label + "] status=" + response.statusCode()
|
||||||
|
+ " body=" + text.substring(0, Math.min(160, text.length())));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
System.out.println("[probe] http[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||||
|
Throwable cause = ex;
|
||||||
|
while (cause.getCause() != null) {
|
||||||
|
cause = cause.getCause();
|
||||||
|
System.out.println("[probe] cause " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
|
||||||
|
* 用法:mvn compile test-compile 后执行
|
||||||
|
* java -cp target/classes;target/test-classes;$(cat cp.txt) com.nanri.aiimage.modules.similarasin.service.SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]
|
||||||
|
*/
|
||||||
|
public class SimilarAsinLlmLocalVerify {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.setOut(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.out), true, "UTF-8"));
|
||||||
|
System.setErr(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true, "UTF-8"));
|
||||||
|
if (args.length < 2) {
|
||||||
|
System.err.println("usage: SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String dataFile = args[0];
|
||||||
|
String apiKey = args[1];
|
||||||
|
boolean imgSwitch = args.length > 2 && Boolean.parseBoolean(args[2]);
|
||||||
|
boolean categorySwitch = args.length > 3 && Boolean.parseBoolean(args[3]);
|
||||||
|
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
JsonNode root = objectMapper.readTree(new File(dataFile));
|
||||||
|
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
|
||||||
|
if (root.isArray()) {
|
||||||
|
for (JsonNode node : root) {
|
||||||
|
rows.add(fromJson(objectMapper, node));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.add(fromJson(objectMapper, root));
|
||||||
|
}
|
||||||
|
System.out.println("[verify] loaded rows=" + rows.size() + " imgSwitch=" + imgSwitch
|
||||||
|
+ " categorySwitch=" + categorySwitch);
|
||||||
|
if (imgSwitch && categorySwitch) {
|
||||||
|
System.out.println("[verify] raw first row alibaba[0].url="
|
||||||
|
+ (rows.isEmpty() || rows.get(0).getAlibaba().isEmpty() ? "null"
|
||||||
|
: rows.get(0).getAlibaba().get(0).getUrl()));
|
||||||
|
}
|
||||||
|
|
||||||
|
SimilarAsinProperties props = new SimilarAsinProperties();
|
||||||
|
props.setLlmApiKey(apiKey);
|
||||||
|
props.setLlmRowConcurrency(2);
|
||||||
|
props.setLlmImageDownloadTimeoutSeconds(10);
|
||||||
|
|
||||||
|
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null);
|
||||||
|
OssProperties ossProps = new OssProperties();
|
||||||
|
ossProps.setEndpoint("https://oss.aishufu.top");
|
||||||
|
ossProps.setPublicEndpoint("https://oss.aishufu.top");
|
||||||
|
ossProps.setBucket("nanri-ai-images");
|
||||||
|
ossProps.setAccessKeyId("appuser");
|
||||||
|
ossProps.setAccessKeySecret("AppUser@2024SecureKey");
|
||||||
|
OssStorageService oss = new OssStorageService(ossProps);
|
||||||
|
PuzzleImageMerger merger = new PuzzleImageMerger(props);
|
||||||
|
|
||||||
|
// 生产真实类目数据(导出自 biz_product_category),spy 类目服务按 parentId 过滤。
|
||||||
|
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> categories = loadCategories(objectMapper);
|
||||||
|
ProductCategoryService categoryService = Mockito.spy(new ProductCategoryService(
|
||||||
|
Mockito.mock(com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper.class)));
|
||||||
|
Mockito.doAnswer(invocation -> {
|
||||||
|
Long parentId = invocation.getArgument(0);
|
||||||
|
List<com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo> items = categories.stream()
|
||||||
|
.filter(c -> parentId == null ? c.getParentId() == null : parentId.equals(c.getParentId()))
|
||||||
|
.map(c -> {
|
||||||
|
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo item =
|
||||||
|
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo();
|
||||||
|
item.setId(c.getId());
|
||||||
|
item.setParentId(c.getParentId());
|
||||||
|
item.setName(c.getName());
|
||||||
|
item.setCategoryKey(c.getCategoryKey());
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo vo =
|
||||||
|
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo();
|
||||||
|
vo.setItems(items);
|
||||||
|
vo.setTree(List.of());
|
||||||
|
vo.setTotal((long) items.size());
|
||||||
|
vo.setPage(1L);
|
||||||
|
vo.setPageSize((long) Math.max(1, items.size()));
|
||||||
|
vo.setHasMore(false);
|
||||||
|
return vo;
|
||||||
|
}).when(categoryService).children(Mockito.any(), Mockito.anyLong(), Mockito.anyLong());
|
||||||
|
|
||||||
|
SimilarAsinLlmService service = new SimilarAsinLlmService(
|
||||||
|
client, props, ossProps, categoryService, merger, oss);
|
||||||
|
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
List<SimilarAsinResultRowDto> result = service.inspectRows(rows, null, apiKey, imgSwitch, categorySwitch);
|
||||||
|
long elapsed = System.currentTimeMillis() - start;
|
||||||
|
System.out.println("[verify] done rows=" + result.size() + " elapsedMs=" + elapsed);
|
||||||
|
for (SimilarAsinResultRowDto row : result) {
|
||||||
|
System.out.println(String.format(
|
||||||
|
"asin=%s | status=%s | isConform=%s | category=%s | reason=%s | isStock=%s | similarity=%s | mainUrl=%s | puzzle1=%s | puzzle2=%s",
|
||||||
|
row.getAsin(), row.getStatus(), row.getIsConform(), row.getCategory(),
|
||||||
|
row.getReason(), row.getIsStock(), row.getSimilarity(),
|
||||||
|
shorten(row.getMainUrl()), shorten(row.getPuzzleImg1()), shorten(row.getPuzzleImg2())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> loadCategories(ObjectMapper objectMapper) throws Exception {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(
|
||||||
|
SimilarAsinLlmLocalVerify.class.getResourceAsStream("/categories.json"));
|
||||||
|
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> list = new ArrayList<>();
|
||||||
|
for (com.fasterxml.jackson.databind.JsonNode node : root) {
|
||||||
|
com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity entity =
|
||||||
|
new com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity();
|
||||||
|
entity.setId(node.get("id").asLong());
|
||||||
|
if (!node.get("parent_id").isNull()) {
|
||||||
|
entity.setParentId(node.get("parent_id").asLong());
|
||||||
|
}
|
||||||
|
entity.setName(node.get("name").asText());
|
||||||
|
entity.setCategoryKey(node.get("category_key").asText());
|
||||||
|
entity.setSortOrder(node.get("sort_order").isNull() ? null : node.get("sort_order").asInt());
|
||||||
|
entity.setDescription(node.get("description").isNull() ? null : node.get("description").asText());
|
||||||
|
entity.setIsBuiltin(node.get("is_builtin") != null && node.get("is_builtin").asBoolean());
|
||||||
|
list.add(entity);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimilarAsinResultRowDto fromJson(ObjectMapper objectMapper, JsonNode node) throws Exception {
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setAsin(text(node, "asin"));
|
||||||
|
row.setTitle(text(node, "title"));
|
||||||
|
row.setSku(text(node, "sku"));
|
||||||
|
row.setCountry(text(node, "country"));
|
||||||
|
row.setUrl(text(node, "url"));
|
||||||
|
JsonNode alibaba = node.get("alibaba");
|
||||||
|
if (alibaba != null && alibaba.isArray()) {
|
||||||
|
List<SimilarAsinResultRowDto.AlibabaItem> items = new ArrayList<>();
|
||||||
|
for (JsonNode item : alibaba) {
|
||||||
|
items.add(objectMapper.treeToValue(item, SimilarAsinResultRowDto.AlibabaItem.class));
|
||||||
|
}
|
||||||
|
row.setAlibaba(items);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shorten(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return value.length() <= 70 ? value : value.substring(0, 70) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||||
|
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
class SimilarAsinLlmServiceTest {
|
||||||
|
|
||||||
|
private static byte[] jpegBytes() {
|
||||||
|
try {
|
||||||
|
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "jpg", baos);
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinProperties properties() {
|
||||||
|
SimilarAsinProperties props = new SimilarAsinProperties();
|
||||||
|
props.setLlmApiKey("test-key");
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinLlmService service(SimilarAsinLlmClient llmClient) {
|
||||||
|
OssStorageService ossStorage = mock(OssStorageService.class);
|
||||||
|
when(ossStorage.getPublicUrl(anyString())).thenAnswer(invocation -> "https://oss.aishufu.top/nanri-ai-images/" + invocation.getArgument(0));
|
||||||
|
PuzzleImageMerger merger = mock(PuzzleImageMerger.class);
|
||||||
|
when(merger.merge(anyList(), Mockito.<SimilarAsinResultRowDto>any())).thenReturn(jpegBytes());
|
||||||
|
ProductCategoryService categoryService = mock(ProductCategoryService.class);
|
||||||
|
when(categoryService.children(any(), anyLong(), anyLong()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
Long parentId = invocation.getArgument(0);
|
||||||
|
if (parentId == null) {
|
||||||
|
return categoryPage("类目A", 1L);
|
||||||
|
}
|
||||||
|
if (parentId == 1L) {
|
||||||
|
return categoryPage("类目B", 2L);
|
||||||
|
}
|
||||||
|
if (parentId == 2L) {
|
||||||
|
return categoryPage("类目C", 3L);
|
||||||
|
}
|
||||||
|
return emptyCategoryPage();
|
||||||
|
});
|
||||||
|
SimilarAsinLlmService svc = new SimilarAsinLlmService(
|
||||||
|
llmClient,
|
||||||
|
properties(),
|
||||||
|
mock(OssProperties.class),
|
||||||
|
categoryService,
|
||||||
|
merger,
|
||||||
|
ossStorage);
|
||||||
|
svc.setDownloadHttpClientForTest(mockHttpClient());
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductCategoryListVo categoryPage(String name, long id) {
|
||||||
|
ProductCategoryItemVo item = new ProductCategoryItemVo();
|
||||||
|
item.setId(id);
|
||||||
|
item.setName(name);
|
||||||
|
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||||
|
vo.setItems(List.of(item));
|
||||||
|
vo.setTotal(1L);
|
||||||
|
vo.setPage(1L);
|
||||||
|
vo.setPageSize(1L);
|
||||||
|
vo.setHasMore(false);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductCategoryListVo emptyCategoryPage() {
|
||||||
|
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||||
|
vo.setItems(List.of());
|
||||||
|
vo.setTotal(0L);
|
||||||
|
vo.setPage(1L);
|
||||||
|
vo.setPageSize(1L);
|
||||||
|
vo.setHasMore(false);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient mockHttpClient() {
|
||||||
|
HttpClient client = mock(HttpClient.class);
|
||||||
|
try {
|
||||||
|
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
byte[] body = jpegBytes();
|
||||||
|
HttpRequest request = invocation.getArgument(0);
|
||||||
|
return new TestHttpResponse(200, body, request);
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException(ex);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestHttpResponse implements HttpResponse<byte[]> {
|
||||||
|
private final int statusCode;
|
||||||
|
private final byte[] body;
|
||||||
|
private final HttpRequest request;
|
||||||
|
|
||||||
|
TestHttpResponse(int statusCode, byte[] body, HttpRequest request) {
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.body = body;
|
||||||
|
this.request = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int statusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HttpRequest request() {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Optional<HttpResponse<byte[]>> previousResponse() {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.net.http.HttpHeaders headers() {
|
||||||
|
return java.net.http.HttpHeaders.of(Map.of(), (a, b) -> true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] body() {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.net.URI uri() {
|
||||||
|
return java.net.URI.create("http://test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.net.http.HttpClient.Version version() {
|
||||||
|
return HttpClient.Version.HTTP_1_1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Optional<javax.net.ssl.SSLSession> sslSession() {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private SimilarAsinLlmClient llmClient(String apiKey, Map<String, String> responses) {
|
||||||
|
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
|
||||||
|
when(client.resolveApiKey(anyString())).thenReturn(apiKey == null ? "" : apiKey);
|
||||||
|
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
|
||||||
|
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String response = responses.get("chat");
|
||||||
|
if (response == null) {
|
||||||
|
throw new IllegalStateException("unexpected chat call");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
String response = responses.get("images");
|
||||||
|
if (response == null) {
|
||||||
|
throw new IllegalStateException("unexpected images call");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER =
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
|
||||||
|
/** 类目匹配(按序号返回不同类目名)与合规检查(后续)返回不同 JSON。 */
|
||||||
|
private SimilarAsinLlmClient llmClientStaged(String apiKey, List<String> categoryJsons,
|
||||||
|
String conformJson, String imagesJson) {
|
||||||
|
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
|
||||||
|
int[] categoryIndex = {0};
|
||||||
|
when(client.resolveApiKey(anyString())).thenReturn(apiKey);
|
||||||
|
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
|
||||||
|
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
if (categoryIndex[0] < categoryJsons.size()) {
|
||||||
|
return categoryJsons.get(categoryIndex[0]++);
|
||||||
|
}
|
||||||
|
return conformJson;
|
||||||
|
});
|
||||||
|
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(invocation -> imagesJson);
|
||||||
|
when(client.parseJsonContent(anyString()))
|
||||||
|
.thenAnswer(invocation -> parseJson(invocation.getArgument(0)));
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static com.fasterxml.jackson.databind.JsonNode parseJson(String content) {
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.readTree(content);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noApiKeyKeepsRawRows() {
|
||||||
|
SimilarAsinLlmService service = service(llmClient("", Map.of()));
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setAsin("B0TEST");
|
||||||
|
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "", true, true);
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals("B0TEST", result.get(0).getAsin());
|
||||||
|
assertNull(result.get(0).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void categorySwitchOffOnlyPreparesImagesAndMarksNotExistsWhenNoMainUrl() {
|
||||||
|
SimilarAsinLlmService service = service(llmClient("k", Map.of()));
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setAsin("B0TEST");
|
||||||
|
row.setTitle("Test product");
|
||||||
|
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", false, false);
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals("不存在", result.get(0).getStatus());
|
||||||
|
assertNull(result.get(0).getIsConform());
|
||||||
|
assertNull(result.get(0).getPuzzleImg1());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void imageCompareStopsOnStockAndFillsFields() {
|
||||||
|
// 前 2 次 chat:一级/二级类目匹配返回名称(Java 按名回查候选取真实 ID);第 3 次:合规检查。
|
||||||
|
SimilarAsinLlmClient client = llmClientStaged("k", List.of("{\"name\":\"类目A\"}", "{\"name\":\"类目B\"}"),
|
||||||
|
"{\"asin\":\"B0TEST\",\"is_conform\":\"符合\",\"reason\":\"无\",\"category\":\"类目A->类目B->类目C\"}",
|
||||||
|
"{\"asin\":\"B0TEST\",\"is_stock\":\"有货\",\"similarity\":\"95%\",\"status\":\"成功\",\"is_conform\":\"符合\",\"category\":\"类目A->类目B->类目C\"}");
|
||||||
|
SimilarAsinLlmService service = service(client);
|
||||||
|
|
||||||
|
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||||
|
row.setAsin("B0TEST");
|
||||||
|
row.setTitle("Test product");
|
||||||
|
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
|
||||||
|
SimilarAsinResultRowDto.AlibabaItem item = new SimilarAsinResultRowDto.AlibabaItem();
|
||||||
|
item.setUrl("https://cbu01.alicdn.com/img/1.jpg");
|
||||||
|
row.setAlibaba(List.of(item));
|
||||||
|
|
||||||
|
// 一级/二级都匹配(按名回查 ID),三级候选可用,合规符合 → 图片对比,有货即停。
|
||||||
|
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", true, true);
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
SimilarAsinResultRowDto out = result.get(0);
|
||||||
|
assertEquals("成功", out.getStatus());
|
||||||
|
assertEquals("有货", out.getIsStock());
|
||||||
|
assertEquals("95%", out.getSimilarity());
|
||||||
|
assertEquals("符合", out.getIsConform());
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-2
@@ -17,6 +17,8 @@ import org.mockito.ArgumentCaptor;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -82,6 +84,8 @@ class ZiniaoShopIndexServiceTest {
|
|||||||
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L)));
|
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L)));
|
||||||
when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L))
|
when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L))
|
||||||
.thenReturn(List.of(shop("shop-2", "店铺B")));
|
.thenReturn(List.of(shop("shop-2", "店铺B")));
|
||||||
|
when(ziniaoMemoryStoreService.listAliveEntitiesByType(
|
||||||
|
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, 10000)).thenReturn(List.of());
|
||||||
|
|
||||||
service.refreshShopIndex();
|
service.refreshShopIndex();
|
||||||
|
|
||||||
@@ -98,7 +102,6 @@ class ZiniaoShopIndexServiceTest {
|
|||||||
any(ZiniaoShopIndexEntryDto.class),
|
any(ZiniaoShopIndexEntryDto.class),
|
||||||
any(Duration.class)
|
any(Duration.class)
|
||||||
);
|
);
|
||||||
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
|
|
||||||
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
||||||
eq(blocked),
|
eq(blocked),
|
||||||
eq("当前服务器 IP 未加入紫鸟白名单")
|
eq("当前服务器 IP 未加入紫鸟白名单")
|
||||||
@@ -149,7 +152,6 @@ class ZiniaoShopIndexServiceTest {
|
|||||||
any(ZiniaoShopIndexEntryDto.class),
|
any(ZiniaoShopIndexEntryDto.class),
|
||||||
any(Duration.class)
|
any(Duration.class)
|
||||||
);
|
);
|
||||||
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
|
|
||||||
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
||||||
eq(partiallyBlocked),
|
eq(partiallyBlocked),
|
||||||
eq("当前服务器 IP 未加入紫鸟白名单")
|
eq("当前服务器 IP 未加入紫鸟白名单")
|
||||||
@@ -158,6 +160,76 @@ class ZiniaoShopIndexServiceTest {
|
|||||||
verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(partiallyBlocked);
|
verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(partiallyBlocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whitelistBlockedApiKeyMarksItsActiveIndexRowsAsBypassEligible() throws Exception {
|
||||||
|
stubIpWhitelistDetection();
|
||||||
|
ZiniaoApiKeyProvider.ApiKeyAccount blocked = new ZiniaoApiKeyProvider.ApiKeyAccount("blocked-key", "blocked-account");
|
||||||
|
ZiniaoApiKeyProvider.ApiKeyAccount allowed = new ZiniaoApiKeyProvider.ApiKeyAccount("allowed-key", "allowed-account");
|
||||||
|
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(blocked, allowed));
|
||||||
|
when(ziniaoAuthService.resolveCompanyIdForIndex("blocked-key"))
|
||||||
|
.thenThrow(new BusinessException("当前服务器 IP 未加入紫鸟白名单"));
|
||||||
|
when(ziniaoAuthService.resolveCompanyIdForIndex("allowed-key")).thenReturn(2L);
|
||||||
|
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L)));
|
||||||
|
when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L))
|
||||||
|
.thenReturn(List.of(shop("shop-2", "店铺B")));
|
||||||
|
|
||||||
|
ZiniaoShopIndexEntryDto blockedDto = new ZiniaoShopIndexEntryDto();
|
||||||
|
blockedDto.setNormalizedShopName("blocked-shop");
|
||||||
|
blockedDto.setStatus("ACTIVE");
|
||||||
|
blockedDto.setApiKeyHash(sha256("blocked-key"));
|
||||||
|
blockedDto.setLastRefreshedAt(1000L);
|
||||||
|
ZiniaoMemoryStoreEntity blockedRow = new ZiniaoMemoryStoreEntity();
|
||||||
|
blockedRow.setId(1L);
|
||||||
|
blockedRow.setCacheType("SHOP_INDEX_ENTRY");
|
||||||
|
blockedRow.setCacheKey("s:blocked-shop");
|
||||||
|
blockedRow.setPayloadJson(new ObjectMapper().writeValueAsString(blockedDto));
|
||||||
|
blockedRow.setExpiresAt(LocalDateTime.now().plusHours(12));
|
||||||
|
|
||||||
|
ZiniaoShopIndexEntryDto allowedDto = new ZiniaoShopIndexEntryDto();
|
||||||
|
allowedDto.setNormalizedShopName("allowed-shop");
|
||||||
|
allowedDto.setStatus("ACTIVE");
|
||||||
|
allowedDto.setApiKeyHash(sha256("allowed-key"));
|
||||||
|
allowedDto.setLastRefreshedAt(1000L);
|
||||||
|
ZiniaoMemoryStoreEntity allowedRow = new ZiniaoMemoryStoreEntity();
|
||||||
|
allowedRow.setId(2L);
|
||||||
|
allowedRow.setCacheType("SHOP_INDEX_ENTRY");
|
||||||
|
allowedRow.setCacheKey("s:allowed-shop");
|
||||||
|
allowedRow.setPayloadJson(new ObjectMapper().writeValueAsString(allowedDto));
|
||||||
|
allowedRow.setExpiresAt(LocalDateTime.now().plusHours(12));
|
||||||
|
|
||||||
|
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000))
|
||||||
|
.thenReturn(List.of(blockedRow, allowedRow));
|
||||||
|
|
||||||
|
service.refreshShopIndex();
|
||||||
|
|
||||||
|
ArgumentCaptor<List<ZiniaoMemoryStoreEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(ziniaoMemoryStoreService).updateStaleMarks(captor.capture());
|
||||||
|
assertEquals(1, captor.getValue().size());
|
||||||
|
ZiniaoMemoryStoreEntity updated = captor.getValue().get(0);
|
||||||
|
assertEquals(1L, updated.getId());
|
||||||
|
var payload = new ObjectMapper().readTree(updated.getPayloadJson());
|
||||||
|
assertEquals("IP_WHITELIST", payload.get("refreshBlockedReason").asText());
|
||||||
|
assertEquals("ACTIVE", payload.get("status").asText());
|
||||||
|
assertTrue(payload.get("lastRefreshBlockedAt").asLong() >= 1000L,
|
||||||
|
"lastRefreshBlockedAt 应不小于行内 lastRefreshedAt,否则查询侧豁免条件不成立");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laterWhitelistClearRemovesBlockedMarksFromIndexRows() throws Exception {
|
||||||
|
ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct");
|
||||||
|
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key));
|
||||||
|
when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L);
|
||||||
|
when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L)));
|
||||||
|
when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L))
|
||||||
|
.thenReturn(List.of(shop("s-1", "shop-1")));
|
||||||
|
|
||||||
|
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.refreshShopIndex();
|
||||||
|
|
||||||
|
verify(ziniaoMemoryStoreService, never()).updateStaleMarks(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() {
|
void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() {
|
||||||
ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account");
|
ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account");
|
||||||
@@ -327,6 +399,16 @@ class ZiniaoShopIndexServiceTest {
|
|||||||
return (ZiniaoShopIndexRefreshCursorDto) captor.getAllValues().getLast();
|
return (ZiniaoShopIndexRefreshCursorDto) captor.getAllValues().getLast();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) throws Exception {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private void stubIpWhitelistDetection() {
|
private void stubIpWhitelistDetection() {
|
||||||
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class)))
|
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class)))
|
||||||
.thenAnswer(invocation -> invocation.<BusinessException>getArgument(0).getMessage().contains("白名单"));
|
.thenAnswer(invocation -> invocation.<BusinessException>getArgument(0).getMessage().contains("白名单"));
|
||||||
|
|||||||
@@ -1359,7 +1359,7 @@ _SHOP_DATA_CRAWL_ADMIN_COLUMNS = f"""
|
|||||||
r.result_file_size, r.result_content_type, r.row_count,
|
r.result_file_size, r.result_content_type, r.row_count,
|
||||||
r.success AS result_success, r.error_message AS result_error,
|
r.success AS result_success, r.error_message AS result_error,
|
||||||
r.created_at AS result_created_at,
|
r.created_at AS result_created_at,
|
||||||
t.task_no, t.status AS task_status, t.request_json, t.result_json,
|
t.task_no, t.status AS task_status, t.request_json,
|
||||||
t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at,
|
t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at,
|
||||||
{_SHOP_DATA_CRAWL_LATEST_TIME_SQL} AS latest_file_updated_at,
|
{_SHOP_DATA_CRAWL_LATEST_TIME_SQL} AS latest_file_updated_at,
|
||||||
df.country_codes_json, COALESCE(df.row_count, r.row_count) AS row_count_display,
|
df.country_codes_json, COALESCE(df.row_count, r.row_count) AS row_count_display,
|
||||||
@@ -3395,6 +3395,19 @@ def delete_invalid_asin_data(item_id):
|
|||||||
# ---------- 店铺管理 ----------
|
# ---------- 店铺管理 ----------
|
||||||
|
|
||||||
def _format_shop_manage_item(item):
|
def _format_shop_manage_item(item):
|
||||||
|
latest_check = item.get('latestCheck')
|
||||||
|
if isinstance(latest_check, dict):
|
||||||
|
latest_check = {
|
||||||
|
'id': latest_check.get('id'),
|
||||||
|
'status': latest_check.get('status') or '',
|
||||||
|
'detail': latest_check.get('detail') or '',
|
||||||
|
'client_host': latest_check.get('clientHost') or '',
|
||||||
|
'try_requested_at': (latest_check.get('tryRequestedAt') or '').replace('T', ' ')[:19],
|
||||||
|
'check_started_at': (latest_check.get('checkStartedAt') or '').replace('T', ' ')[:19],
|
||||||
|
'check_finished_at': (latest_check.get('checkFinishedAt') or '').replace('T', ' ')[:19],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
latest_check = None
|
||||||
return {
|
return {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_id': item.get('groupId'),
|
'group_id': item.get('groupId'),
|
||||||
@@ -3404,6 +3417,7 @@ def _format_shop_manage_item(item):
|
|||||||
'zn_username': item.get('znUsername') or '',
|
'zn_username': item.get('znUsername') or '',
|
||||||
'account': item.get('account') or '',
|
'account': item.get('account') or '',
|
||||||
'password': item.get('passwordMasked') or '',
|
'password': item.get('passwordMasked') or '',
|
||||||
|
'latest_check': latest_check,
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||||
}
|
}
|
||||||
@@ -3483,6 +3497,36 @@ def list_shop_manages():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/shop-manage/<int:item_id>/credential-check', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_shop_credential_check(item_id):
|
||||||
|
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
shop_name = (request.args.get('shop_name') or '').strip()
|
||||||
|
if not shop_name:
|
||||||
|
return jsonify({'success': False, 'error': '店铺名不能为空'}), 400
|
||||||
|
|
||||||
|
internal_token = _resolve_internal_token()
|
||||||
|
if not internal_token:
|
||||||
|
return jsonify({'success': False, 'error': '内部凭据服务未配置'}), 503
|
||||||
|
result, error_response, status = _proxy_backend_java(
|
||||||
|
'POST',
|
||||||
|
'/api/admin/shop-credential-checks',
|
||||||
|
json_data={'shopName': shop_name},
|
||||||
|
headers={'X-Internal-Token': internal_token},
|
||||||
|
)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response, status
|
||||||
|
check = result.get('data') or {}
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'msg': '检测任务已创建,客户端将在 1 分钟内执行',
|
||||||
|
'check': {'id': check.get('id'), 'status': check.get('status') or 'PENDING'},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/shop-manage/<int:item_id>/credential')
|
@admin_api.route('/shop-manage/<int:item_id>/credential')
|
||||||
@login_required
|
@login_required
|
||||||
def get_shop_manage_credential(item_id):
|
def get_shop_manage_credential(item_id):
|
||||||
|
|||||||
+58
-3
@@ -2777,7 +2777,7 @@
|
|||||||
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
|
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== 不符合ASIN数据 ==========
|
// ========== 品牌数据库 ==========
|
||||||
var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15;
|
var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15;
|
||||||
function buildInvalidAsinDataQuery(page) {
|
function buildInvalidAsinDataQuery(page) {
|
||||||
var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize;
|
var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize;
|
||||||
@@ -3128,11 +3128,40 @@
|
|||||||
function renderShopPasswordCell(item) {
|
function renderShopPasswordCell(item) {
|
||||||
var maskedPassword = item.password || '******';
|
var maskedPassword = item.password || '******';
|
||||||
return '<span class="shop-password-cell">' +
|
return '<span class="shop-password-cell">' +
|
||||||
'<span class="shop-password-value" data-shop-password-value>' + escapeHtml(maskedPassword) + '</span>' +
|
'<span class="shop-password-value" data-shop-password-value title="' + escapeHtml(maskedPassword) + '">' + escapeHtml(maskedPassword) + '</span>' +
|
||||||
'<button type="button" class="shop-password-toggle" data-shop-password-toggle="' + escapeHtml(item.id) + '" data-shop-name="' + escapeHtml(item.shop_name || '') + '" data-masked-password="' + escapeHtml(maskedPassword) + '" aria-label="显示密码" aria-pressed="false" title="显示密码">' +
|
'<button type="button" class="shop-password-toggle" data-shop-password-toggle="' + escapeHtml(item.id) + '" data-shop-name="' + escapeHtml(item.shop_name || '') + '" data-masked-password="' + escapeHtml(maskedPassword) + '" aria-label="显示密码" aria-pressed="false" title="显示密码">' +
|
||||||
shopPasswordIcon(false) + '</button></span>';
|
shopPasswordIcon(false) + '</button></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderShopCheckBadge(check) {
|
||||||
|
if (!check) return '';
|
||||||
|
var map = {
|
||||||
|
'SUCCESS': ['ok', '密码正确'],
|
||||||
|
'FAILED': ['bad', '密码错误'],
|
||||||
|
'RUNNING': ['run', '检测中'],
|
||||||
|
'PENDING': ['wait', '等待客户端'],
|
||||||
|
'NO_NEED_LOGIN': ['warn', '已登录态'],
|
||||||
|
'ERROR': ['bad', '检测异常']
|
||||||
|
};
|
||||||
|
var entry = map[check.status] || ['wait', check.status || '未知'];
|
||||||
|
var tipText = [check.status, check.detail, check.check_finished_at].filter(Boolean).join(' · ');
|
||||||
|
return '<div class="shop-check-badge ' + entry[0] + '" title="' + escapeHtml(tipText) + '">' + escapeHtml(entry[1]) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var shopCheckPollTimer = null;
|
||||||
|
function startShopCheckPolling() {
|
||||||
|
if (shopCheckPollTimer) return;
|
||||||
|
var ticks = 0;
|
||||||
|
shopCheckPollTimer = setInterval(function () {
|
||||||
|
ticks += 1;
|
||||||
|
loadShopManage(shopManagePage);
|
||||||
|
if (ticks >= 9) {
|
||||||
|
clearInterval(shopCheckPollTimer);
|
||||||
|
shopCheckPollTimer = null;
|
||||||
|
}
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
function renderShopTableText(value, fallback) {
|
function renderShopTableText(value, fallback) {
|
||||||
var text = String(value == null ? '' : value).trim();
|
var text = String(value == null ? '' : value).trim();
|
||||||
var shown = text || fallback || '-';
|
var shown = text || fallback || '-';
|
||||||
@@ -3162,11 +3191,12 @@
|
|||||||
'<td class="shop-col-mall">' + renderShopTableText(item.mall_name) + '</td>' +
|
'<td class="shop-col-mall">' + renderShopTableText(item.mall_name) + '</td>' +
|
||||||
'<td class="shop-col-automation">' + renderShopTableText(item.zn_username) + '</td>' +
|
'<td class="shop-col-automation">' + renderShopTableText(item.zn_username) + '</td>' +
|
||||||
'<td class="shop-col-account">' + renderShopTableText(item.account) + '</td>' +
|
'<td class="shop-col-account">' + renderShopTableText(item.account) + '</td>' +
|
||||||
'<td class="shop-col-password">' + renderShopPasswordCell(item) + '</td>' +
|
'<td class="shop-col-password">' + renderShopPasswordCell(item) + renderShopCheckBadge(item.latest_check) + '</td>' +
|
||||||
'<td class="shop-col-created">' + renderShopTableText(item.created_at) + '</td>' +
|
'<td class="shop-col-created">' + renderShopTableText(item.created_at) + '</td>' +
|
||||||
'<td class="shop-col-updated">' + renderShopTableText(item.updated_at) + '</td>' +
|
'<td class="shop-col-updated">' + renderShopTableText(item.updated_at) + '</td>' +
|
||||||
'<td class="shop-col-actions">' +
|
'<td class="shop-col-actions">' +
|
||||||
'<button type="button" class="btn btn-sm" data-shop-manage-edit="' + escapeHtml(item.id) + '" data-shop-manage="' + (JSON.stringify(item).replace(/"/g, '"')) + '">编辑</button> ' +
|
'<button type="button" class="btn btn-sm" data-shop-manage-edit="' + escapeHtml(item.id) + '" data-shop-manage="' + (JSON.stringify(item).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-check" data-shop-credential-check="' + escapeHtml(item.id) + '" data-shop-check-name="' + escapeHtml(item.shop_name || '') + '">检测密码</button> ' +
|
||||||
'<button type="button" class="btn btn-sm btn-danger" data-shop-manage-delete="' + escapeHtml(item.id) + '" data-shop-manage-name="' + escapeHtml(item.shop_name || '') + '">删除</button>' +
|
'<button type="button" class="btn btn-sm btn-danger" data-shop-manage-delete="' + escapeHtml(item.id) + '" data-shop-manage-name="' + escapeHtml(item.shop_name || '') + '">删除</button>' +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -3186,6 +3216,7 @@
|
|||||||
var revealed = btn.dataset.revealed === 'true';
|
var revealed = btn.dataset.revealed === 'true';
|
||||||
if (revealed) {
|
if (revealed) {
|
||||||
valueEl.textContent = btn.dataset.maskedPassword || '******';
|
valueEl.textContent = btn.dataset.maskedPassword || '******';
|
||||||
|
valueEl.title = btn.dataset.maskedPassword || '******';
|
||||||
btn.dataset.revealed = 'false';
|
btn.dataset.revealed = 'false';
|
||||||
btn.setAttribute('aria-label', '显示密码');
|
btn.setAttribute('aria-label', '显示密码');
|
||||||
btn.setAttribute('aria-pressed', 'false');
|
btn.setAttribute('aria-pressed', 'false');
|
||||||
@@ -3201,6 +3232,7 @@
|
|||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.success) throw new Error(res.error || '读取密码失败');
|
if (!res.success) throw new Error(res.error || '读取密码失败');
|
||||||
valueEl.textContent = res.password || '';
|
valueEl.textContent = res.password || '';
|
||||||
|
valueEl.title = res.password || '';
|
||||||
btn.dataset.revealed = 'true';
|
btn.dataset.revealed = 'true';
|
||||||
btn.setAttribute('aria-label', '隐藏密码');
|
btn.setAttribute('aria-label', '隐藏密码');
|
||||||
btn.setAttribute('aria-pressed', 'true');
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
@@ -3245,6 +3277,29 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-shop-credential-check]').forEach(function (btn) {
|
||||||
|
btn.onclick = function () {
|
||||||
|
var name = (btn.dataset.shopCheckName || '').replace(/"/g, '"');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '已提交...';
|
||||||
|
fetch('/api/admin/shop-manage/' + encodeURIComponent(btn.dataset.shopCredentialCheck) + '/credential-check?shop_name=' + encodeURIComponent(name), { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.success) {
|
||||||
|
alert(res.msg || '检测任务已创建,客户端将在 1 分钟内执行');
|
||||||
|
startShopCheckPolling();
|
||||||
|
loadShopManage(shopManagePage);
|
||||||
|
} else {
|
||||||
|
alert(res.error || '发起检测失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { alert('发起检测失败'); })
|
||||||
|
.finally(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '检测密码';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInvalidAsinDataLockedGroupId() {
|
function getInvalidAsinDataLockedGroupId() {
|
||||||
|
|||||||
@@ -1048,6 +1048,34 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-check-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 17px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-check-badge.ok { color: #067647; background: #e6f4ea; border: 1px solid #b7e0c3; }
|
||||||
|
.shop-check-badge.bad { color: #b42318; background: #fee4e2; border: 1px solid #fecdca; }
|
||||||
|
.shop-check-badge.run { color: #175cd3; background: #eaf2ff; border: 1px solid #b8d2ff; }
|
||||||
|
.shop-check-badge.wait { color: #667085; background: #f2f4f7; border: 1px solid #d0d5dd; }
|
||||||
|
.shop-check-badge.warn { color: #b54708; background: #fef0c7; border: 1px solid #fedf89; }
|
||||||
|
|
||||||
|
.btn-check {
|
||||||
|
color: #5158d9;
|
||||||
|
border-color: #c7cbfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-check:hover:not(:disabled) {
|
||||||
|
color: #fff;
|
||||||
|
background: #5158d9;
|
||||||
|
border-color: #5158d9;
|
||||||
|
}
|
||||||
|
|
||||||
.dedupe-group-access {
|
.dedupe-group-access {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3478,20 +3506,22 @@
|
|||||||
#panel-dedupe-total-data .dedupe-table-scroll th:nth-child(6) { width: 16%; }
|
#panel-dedupe-total-data .dedupe-table-scroll th:nth-child(6) { width: 16%; }
|
||||||
#panel-dedupe-total-data .dedupe-table-scroll th:nth-child(7) { width: 9%; }
|
#panel-dedupe-total-data .dedupe-table-scroll th:nth-child(7) { width: 9%; }
|
||||||
|
|
||||||
.shop-manage-table-scroll > table { min-width: 1220px; table-layout: fixed; }
|
.shop-manage-table-scroll > table { min-width: 1360px; table-layout: fixed; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(1) { width: 58px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(1) { width: 58px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(2) { width: 116px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(2) { width: 116px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(3) { width: 126px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(3) { width: 126px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(4) { width: 230px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(4) { width: 210px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(5) { width: 150px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(5) { width: 140px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(6) { width: 190px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(6) { width: 180px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(7) { width: 108px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(7) { width: 152px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(8), #panel-shop-manage .shop-manage-table-scroll th:nth-child(9) { width: 132px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(8), #panel-shop-manage .shop-manage-table-scroll th:nth-child(9) { width: 132px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll th:nth-child(10) { width: 150px; }
|
#panel-shop-manage .shop-manage-table-scroll th:nth-child(10) { width: 200px; }
|
||||||
#panel-shop-manage .shop-manage-table-scroll td { overflow: hidden; }
|
#panel-shop-manage .shop-manage-table-scroll td { overflow: hidden; }
|
||||||
.table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.shop-password-cell { max-width: 100%; }
|
.shop-password-cell { max-width: 100%; }
|
||||||
.shop-password-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.shop-password-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.shop-col-password { min-width: 0; }
|
||||||
|
.shop-col-actions { white-space: nowrap; }
|
||||||
|
|
||||||
.category-table-scroll > table { min-width: 860px; }
|
.category-table-scroll > table { min-width: 860px; }
|
||||||
.category-table-scroll td { vertical-align: middle; }
|
.category-table-scroll td { vertical-align: middle; }
|
||||||
@@ -3887,7 +3917,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="menu-group-body">
|
<div class="menu-group-body">
|
||||||
<button class="tab" type="button" data-tab="dedupe-total-data" aria-controls="panel-dedupe-total-data"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path></svg></span><span class="adm-label">去重数据汇总</span></button>
|
<button class="tab" type="button" data-tab="dedupe-total-data" aria-controls="panel-dedupe-total-data"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path></svg></span><span class="adm-label">去重数据汇总</span></button>
|
||||||
<button class="tab" type="button" data-tab="invalid-asin-data" aria-controls="panel-invalid-asin-data"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span class="adm-label">无效ASIN数据</span></button>
|
<button class="tab" type="button" data-tab="invalid-asin-data" aria-controls="panel-invalid-asin-data"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span class="adm-label">品牌数据库</span></button>
|
||||||
<button class="tab" type="button" data-tab="query-asin" aria-controls="panel-query-asin"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></span><span class="adm-label">查询 ASIN</span></button>
|
<button class="tab" type="button" data-tab="query-asin" aria-controls="panel-query-asin"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></span><span class="adm-label">查询 ASIN</span></button>
|
||||||
<button class="tab" type="button" data-tab="product-categories" aria-controls="panel-product-categories"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg></span><span class="adm-label">商品类目</span></button>
|
<button class="tab" type="button" data-tab="product-categories" aria-controls="panel-product-categories"><span class="adm-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg></span><span class="adm-label">商品类目</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4302,7 +4332,7 @@
|
|||||||
<!-- 不符合ASIN数据 -->
|
<!-- 不符合ASIN数据 -->
|
||||||
<div id="panel-invalid-asin-data" class="tab-panel">
|
<div id="panel-invalid-asin-data" class="tab-panel">
|
||||||
<div class="form-box">
|
<div class="form-box">
|
||||||
<h3 style="margin-bottom:16px;">新增无效ASIN数据</h3>
|
<h3 style="margin-bottom:16px;">新增品牌数据</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="min-width:220px;">
|
<div class="form-group" style="min-width:220px;">
|
||||||
<label>ASIN</label>
|
<label>ASIN</label>
|
||||||
@@ -4326,7 +4356,7 @@
|
|||||||
<p class="msg" id="msgInvalidAsinData"></p>
|
<p class="msg" id="msgInvalidAsinData"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-box">
|
<div class="panel-box">
|
||||||
<h3 style="margin-bottom:16px;">无效ASIN数据列表</h3>
|
<h3 style="margin-bottom:16px;">品牌数据列表</h3>
|
||||||
<div class="form-row" style="margin-bottom:16px;">
|
<div class="form-row" style="margin-bottom:16px;">
|
||||||
<div class="form-group" style="min-width:220px;">
|
<div class="form-group" style="min-width:220px;">
|
||||||
<label>ASIN / 品牌(模糊搜索)</label>
|
<label>ASIN / 品牌(模糊搜索)</label>
|
||||||
@@ -4970,7 +5000,7 @@
|
|||||||
<!-- 编辑不符合ASIN数据弹窗 -->
|
<!-- 编辑不符合ASIN数据弹窗 -->
|
||||||
<div class="modal-mask" id="editInvalidAsinDataModal">
|
<div class="modal-mask" id="editInvalidAsinDataModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>编辑无效ASIN数据</h3>
|
<h3>编辑品牌数据</h3>
|
||||||
<input type="hidden" id="editInvalidAsinDataId">
|
<input type="hidden" id="editInvalidAsinDataId">
|
||||||
<input type="hidden" id="editInvalidAsinDataRecordSource">
|
<input type="hidden" id="editInvalidAsinDataRecordSource">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -5415,7 +5445,7 @@
|
|||||||
window.__initAdminMenuCollapse();
|
window.__initAdminMenuCollapse();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/admin.js?v=admin-menu-v2"></script>
|
<script src="/static/admin.js?v=admin-menu-v3"></script>
|
||||||
<div class="admin-toast-region" id="adminToastRegion" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div class="admin-toast-region" id="adminToastRegion" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
<div class="admin-confirm-mask" id="adminConfirmModal" aria-hidden="true">
|
<div class="admin-confirm-mask" id="adminConfirmModal" aria-hidden="true">
|
||||||
<section class="admin-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="adminConfirmTitle" aria-describedby="adminConfirmMessage">
|
<section class="admin-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="adminConfirmTitle" aria-describedby="adminConfirmMessage">
|
||||||
|
|||||||
Reference in New Issue
Block a user