feat: 外观专利改直连 LLM(DeepSeek/Gemini)、无效ASIN菜单更名 BRAND_DB、紫鸟店铺索引场景化
Build Backend JAR / build (push) Has been cancelled

- AppearancePatent:Coze 工作流切换为 llm-host 直连模型(title/appearance 双模型、行并发、重试),配置键迁移并保留旧环境变量兜底
- InvalidAsinDataMapper 补充按值+品牌唯一键查询
- ZiniaoShopIndexService 新增店铺分类/索引场景
- V98:无效ASIN菜单 rename 补充 BRAND_DB;docs 架构优化规划
- 配套单测更新
This commit is contained in:
2026-08-31 17:02:59 +08:00
parent 14a41e64f1
commit c8cd851b2f
20 changed files with 1291 additions and 2338 deletions
@@ -3,36 +3,39 @@ package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "aiimage.appearance-patent")
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}";
private String cozeWorkflowId = "7632683471312355338";
private String cozeToken = "";
private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 5;
private int cozeBatchSize = 10;
private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 30000;
private int cozePollTimeoutMillis = 600000;
/**
* LLM APIOpenAI 兼容 /v1/chat/completions)地址
*/
private String llmHost = "https://ai.t8star.org";
/**
* 商标关键词提取模型
*/
private String titleModel = "deepseek-v4-flash";
/**
* 外观检测模型(视觉)
*/
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 String staleFinalizeCron = "0 */2 * * * *";
/**
* 末尾不足一批的数据等待该时长后强制提交 Coze
* 末尾不足一批的数据等待该时长后强制提交检测
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
*/
private int cozeFlushPendingMinutes = 1;
@Data
public static class CozeCredential {
private String name;
private String workflowId;
private String token;
}
private int flushPendingMinutes = 1;
}
@@ -199,6 +199,38 @@ public class SimilarAsinProperties {
*/
private long cozeSubmitLockRetryDelayMillis = 500L;
/**
* 货源查询直连 LLM 模式开关(默认 true:新任务与存量 PENDING 批次都走直连 LLM
* 不再经过 Coze)。false 时回退到原 Coze 工作流链路(轮询/重试状态机保留)。
*/
private boolean directLlmEnabled = true;
/** 直连 LLM 的 base urlOpenAI 兼容 /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
public static class CozeCredential {
private String name;
@@ -35,7 +35,7 @@ import java.nio.charset.StandardCharsets;
@RestController
@RequiredArgsConstructor
@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 {
private final AppearancePatentTaskService service;
@@ -110,7 +110,7 @@ public class AppearancePatentController {
}
@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(
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
@PathVariable Long taskId,
@@ -24,17 +24,17 @@ public class AppearancePatentParseRequest {
@JsonProperty("ai_prompt")
@JsonAlias({"aiPrompt", "prompt"})
@Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
@Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 LLM 时作为附加要求传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
private String aiPrompt;
@JsonProperty("api_key")
@JsonAlias({"apiKey"})
@Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥。")
@Schema(description = "调用 LLM API 的任务级密钥。")
@NotBlank(message = "密钥不能为空")
private String apiKey;
@JsonProperty("patent_token")
@JsonAlias({"patentToken"})
@Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌。非必填。")
@Schema(description = "专利汇令牌。非必填。")
private String patentToken;
}
@@ -14,10 +14,10 @@ public class AppearancePatentParsedPayloadDto {
@Schema(description = "AI 提示词")
private String aiPrompt;
@Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥")
@Schema(description = "调用 LLM API 的任务级密钥")
private String apiKey;
@Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌")
@Schema(description = "专利汇令牌")
private String patentToken;
@Schema(description = "本次解析的源文件列表")
@@ -29,7 +29,7 @@ public class AppearancePatentResultRowDto {
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
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"})
private String sku;
@@ -41,7 +41,7 @@ public class AppearancePatentResultRowDto {
@JsonAlias({"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({
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
"mainImage", "main_image", "mainImageUrl", "main_image_url",
@@ -51,24 +51,24 @@ public class AppearancePatentResultRowDto {
})
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", "商品标题", "商品名称", "标题"})
private String title;
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;LLM 检测失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
private String error;
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done;
@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;
@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;
@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;
@JsonAlias({"patent ", "patent"})
@@ -126,11 +126,51 @@ public class AppearancePatentTaskCacheService {
try {
stringRedisTemplate.delete(pendingRowsKey(taskId));
stringRedisTemplate.delete(heartbeatKey(taskId));
stringRedisTemplate.delete(processedRowsKey(taskId));
} catch (Exception ex) {
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) {
return "appearance-patent:task:pending-rows:" + taskId;
}
@@ -11,14 +11,19 @@ import java.util.List;
@Mapper
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("""
<script>
INSERT IGNORE INTO biz_invalid_asin_data
(data_value, brand, record_source, created_at, updated_at)
(data_value, brand, record_source)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.dataValue}, #{row.brand}, #{row.recordSource}, #{row.createdAt}, #{row.updatedAt})
(#{row.dataValue}, #{row.brand}, #{row.recordSource})
</foreach>
</script>
""")
@@ -195,6 +195,7 @@ public class ZiniaoShopIndexService {
int skippedApiKeyCount = 0;
int whitelistSkippedApiKeyCount = 0;
boolean completeCoverage = true;
List<String> whitelistBlockedApiKeys = new ArrayList<>();
try {
List<ZiniaoApiKeyProvider.ApiKeyAccount> allApiKeyAccounts = ziniaoApiKeyProvider.listApiKeyAccounts();
List<ZiniaoApiKeyProvider.ApiKeyAccount> apiKeyAccounts = selectApiKeyBatchByOffset(
@@ -217,6 +218,7 @@ public class ZiniaoShopIndexService {
completeCoverage = false;
if (ziniaoAuthService.isIpWhitelistError(ex)) {
whitelistSkippedApiKeyCount++;
whitelistBlockedApiKeys.add(apiKey);
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} msg={}", companyName, ex.getMessage());
continue;
@@ -233,6 +235,7 @@ public class ZiniaoShopIndexService {
if (ziniaoAuthService.isIpWhitelistError(ex)) {
skippedApiKeyCount++;
whitelistSkippedApiKeyCount++;
whitelistBlockedApiKeys.add(apiKey);
completeCoverage = false;
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} msg={}",
@@ -252,6 +255,7 @@ public class ZiniaoShopIndexService {
if (ziniaoAuthService.isIpWhitelistError(ex)) {
skippedApiKeyCount++;
whitelistSkippedApiKeyCount++;
whitelistBlockedApiKeys.add(apiKey);
completeCoverage = false;
markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage());
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={}",
completedApiKeyCount, allApiKeyAccounts.size(), skippedApiKeyCount, nextOffset);
}
syncWhitelistBlockedMarks(whitelistBlockedApiKeys, now);
cursor.setStatus("SUCCESS");
List<String> refreshMessages = new ArrayList<>();
@@ -583,6 +588,75 @@ public class ZiniaoShopIndexService {
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) {
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreService.listAliveEntitiesByType(
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY,
+22 -10
View File
@@ -225,16 +225,16 @@ aiimage:
connect-timeout-millis: ${AIIMAGE_BRAND_CHECK_CONNECT_TIMEOUT_MILLIS:10000}
read-timeout-millis: ${AIIMAGE_BRAND_CHECK_READ_TIMEOUT_MILLIS:60000}
appearance-patent:
coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10}
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
coze-flush-pending-minutes: ${AIIMAGE_APPEARANCE_PATENT_COZE_FLUSH_PENDING_MINUTES:1}
llm-host: ${AIIMAGE_APPEARANCE_PATENT_LLM_HOST:https://ai.t8star.org}
title-model: ${AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL:deepseek-v4-flash}
appearance-model: ${AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL:gemini-3.7-flash}
llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000}
llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000}
llm-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_READ_TIMEOUT_MILLIS:180000}
llm-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10}
llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10}
llm-retry-times: ${AIIMAGE_APPEARANCE_PATENT_LLM_RETRY_TIMES:3}
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-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
similar-asin:
@@ -266,6 +266,18 @@ aiimage:
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-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:
stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES: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';
@@ -26,21 +26,21 @@ class AppearancePatentCozeClientTest {
);
@Test
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
void markRowsFailedLeavesUserFacingResultFilledWithReviewMessage() {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
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);
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.getTitleRisk()).isNull();
assertThat(failed.getAppearanceRisk()).isNull();
assertThat(failed.getPatentRisk()).isNull();
assertThat(failed.getConclusion()).isNull();
assertThat(failed.getTitleRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getAppearanceRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getPatentRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getConclusion()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25");
}
@Test
@@ -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 = "AppleApple";
} 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("产品描述:普通数据线");
}
}
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import org.junit.jupiter.api.Test;
@@ -25,6 +26,8 @@ class ShopManageServiceTest {
private ShopManageGroupService shopManageGroupService;
@Mock
private ShopCredentialCryptoService shopCredentialCryptoService;
@Mock
private ShopCredentialCheckMapper shopCredentialCheckMapper;
@InjectMocks
private ShopManageService service;
@@ -17,6 +17,8 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
@@ -82,6 +84,8 @@ class ZiniaoShopIndexServiceTest {
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")));
when(ziniaoMemoryStoreService.listAliveEntitiesByType(
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, 10000)).thenReturn(List.of());
service.refreshShopIndex();
@@ -98,7 +102,6 @@ class ZiniaoShopIndexServiceTest {
any(ZiniaoShopIndexEntryDto.class),
any(Duration.class)
);
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
eq(blocked),
eq("当前服务器 IP 未加入紫鸟白名单")
@@ -149,7 +152,6 @@ class ZiniaoShopIndexServiceTest {
any(ZiniaoShopIndexEntryDto.class),
any(Duration.class)
);
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
eq(partiallyBlocked),
eq("当前服务器 IP 未加入紫鸟白名单")
@@ -158,6 +160,76 @@ class ZiniaoShopIndexServiceTest {
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
void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() {
ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account");
@@ -327,6 +399,16 @@ class ZiniaoShopIndexServiceTest {
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() {
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class)))
.thenAnswer(invocation -> invocation.<BusinessException>getArgument(0).getMessage().contains("白名单"));
+45 -1
View File
@@ -1359,7 +1359,7 @@ _SHOP_DATA_CRAWL_ADMIN_COLUMNS = f"""
r.result_file_size, r.result_content_type, r.row_count,
r.success AS result_success, r.error_message AS result_error,
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,
{_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,
@@ -3395,6 +3395,19 @@ def delete_invalid_asin_data(item_id):
# ---------- 店铺管理 ----------
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 {
'id': item.get('id'),
'group_id': item.get('groupId'),
@@ -3404,6 +3417,7 @@ def _format_shop_manage_item(item):
'zn_username': item.get('znUsername') or '',
'account': item.get('account') or '',
'password': item.get('passwordMasked') or '',
'latest_check': latest_check,
'created_at': (item.get('createdAt') 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')
@login_required
def get_shop_manage_credential(item_id):
+58 -3
View File
@@ -2777,7 +2777,7 @@
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
};
// ========== 不符合ASIN数据 ==========
// ========== 品牌数据 ==========
var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15;
function buildInvalidAsinDataQuery(page) {
var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize;
@@ -3128,11 +3128,40 @@
function renderShopPasswordCell(item) {
var maskedPassword = item.password || '******';
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="显示密码">' +
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) {
var text = String(value == null ? '' : value).trim();
var shown = text || fallback || '-';
@@ -3162,11 +3191,12 @@
'<td class="shop-col-mall">' + renderShopTableText(item.mall_name) + '</td>' +
'<td class="shop-col-automation">' + renderShopTableText(item.zn_username) + '</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-updated">' + renderShopTableText(item.updated_at) + '</td>' +
'<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, '&quot;')) + '">编辑</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>' +
'</td></tr>';
}).join('');
@@ -3186,6 +3216,7 @@
var revealed = btn.dataset.revealed === 'true';
if (revealed) {
valueEl.textContent = btn.dataset.maskedPassword || '******';
valueEl.title = btn.dataset.maskedPassword || '******';
btn.dataset.revealed = 'false';
btn.setAttribute('aria-label', '显示密码');
btn.setAttribute('aria-pressed', 'false');
@@ -3201,6 +3232,7 @@
.then(function (res) {
if (!res.success) throw new Error(res.error || '读取密码失败');
valueEl.textContent = res.password || '';
valueEl.title = res.password || '';
btn.dataset.revealed = 'true';
btn.setAttribute('aria-label', '隐藏密码');
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(/&quot;/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() {
+41 -11
View File
@@ -1048,6 +1048,34 @@
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 {
display: flex;
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(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(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(4) { width: 230px; }
#panel-shop-manage .shop-manage-table-scroll th:nth-child(5) { width: 150px; }
#panel-shop-manage .shop-manage-table-scroll th:nth-child(6) { width: 190px; }
#panel-shop-manage .shop-manage-table-scroll th:nth-child(7) { width: 108px; }
#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: 140px; }
#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: 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(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; }
.table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shop-password-cell { max-width: 100%; }
.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 td { vertical-align: middle; }
@@ -3887,7 +3917,7 @@
</button>
<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="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="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>
@@ -4302,7 +4332,7 @@
<!-- 不符合ASIN数据 -->
<div id="panel-invalid-asin-data" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;">新增无效ASIN数据</h3>
<h3 style="margin-bottom:16px;">新增品牌数据</h3>
<div class="form-row">
<div class="form-group" style="min-width:220px;">
<label>ASIN</label>
@@ -4326,7 +4356,7 @@
<p class="msg" id="msgInvalidAsinData"></p>
</div>
<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-group" style="min-width:220px;">
<label>ASIN / 品牌(模糊搜索)</label>
@@ -4970,7 +5000,7 @@
<!-- 编辑不符合ASIN数据弹窗 -->
<div class="modal-mask" id="editInvalidAsinDataModal">
<div class="modal">
<h3>编辑无效ASIN数据</h3>
<h3>编辑品牌数据</h3>
<input type="hidden" id="editInvalidAsinDataId">
<input type="hidden" id="editInvalidAsinDataRecordSource">
<div class="form-group">
@@ -5415,7 +5445,7 @@
window.__initAdminMenuCollapse();
})();
</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-confirm-mask" id="adminConfirmModal" aria-hidden="true">
<section class="admin-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="adminConfirmTitle" aria-describedby="adminConfirmMessage">