Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16e6920b7 | ||
|
|
def0a86f63 | ||
|
|
ba7d2a6b72 | ||
|
|
a3cf98940a | ||
|
|
56706a0b45 | ||
|
|
a4874091a8 | ||
|
|
9e0dcc254e | ||
|
|
cda1978bc6 | ||
|
|
c80d0c91d5 | ||
|
|
be491c7692 | ||
|
|
90c1668fe3 | ||
|
|
804448ede0 | ||
|
|
5a7e219230 | ||
|
|
36d1f82ce2 | ||
|
|
1c998911be | ||
|
|
45437a9214 | ||
|
|
42b5312198 | ||
|
|
7b540f3163 | ||
|
|
ffa5fc0710 | ||
|
|
66ab36c88c | ||
|
|
35b69fc25a | ||
|
|
b4dc7fc9ae | ||
|
|
baa9408a36 | ||
|
|
e01fcd9860 | ||
|
|
be03972779 | ||
|
|
c137f5072f | ||
|
|
82b4ebd406 | ||
|
|
b83f20c6b7 | ||
|
|
e0ed05d15a | ||
|
|
8163dd10eb | ||
|
|
aed295c8c1 | ||
|
|
c14f5f528b | ||
|
|
8293b27c19 | ||
|
|
c24e8a4439 | ||
|
|
40a254dc41 | ||
|
|
503b3cbbc5 | ||
|
|
cad9f6d185 | ||
|
|
78df997fc3 | ||
|
|
20f59b68bd | ||
|
|
652bf30f5a |
@@ -7,6 +7,8 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.xunmeng.pinduoduo" />
|
||||
|
||||
@@ -38,6 +38,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
setContentView(buildContent())
|
||||
requestNotificationPermission()
|
||||
requestMediaPermission()
|
||||
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
|
||||
AgentAppUpdateManager.checkOnceAtStartup(this)
|
||||
}
|
||||
@@ -127,6 +128,14 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaPermission() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScreenPolicy() {
|
||||
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
|
||||
if (keepScreenOn) {
|
||||
@@ -145,5 +154,6 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val TAB_PREFERENCES = "goauto_agent_ui"
|
||||
private const val LAST_SELECTED_TAB = "last_selected_tab"
|
||||
private const val STATE_SELECTED_TAB = "selected_tab"
|
||||
private const val MEDIA_PERMISSION_REQUEST = 101
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class PreparedImageSearchAsset(val uri: Uri)
|
||||
|
||||
object ImageSearchAssetPolicy {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
|
||||
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
|
||||
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
|
||||
return sha256(bytes) == reference.sha256.lowercase()
|
||||
}
|
||||
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
private val resolver = context.applicationContext.contentResolver
|
||||
|
||||
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
|
||||
return try {
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||
PreparedImageSearchAsset(uri)
|
||||
} catch (_: Exception) { resolver.delete(uri, null, null); null }
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
|
||||
}
|
||||
@@ -60,6 +60,14 @@ data class AgentTask(
|
||||
val status: String,
|
||||
val source: String,
|
||||
val replacementOriginType: String,
|
||||
val imageSearch: ImageSearchImage?,
|
||||
)
|
||||
|
||||
data class ImageSearchImage(
|
||||
val imageUrl: String,
|
||||
val mediaType: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
)
|
||||
|
||||
data class CurrentPageIdentity(
|
||||
@@ -584,6 +592,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
status = data.getString("status"),
|
||||
source = data.optString("source", "admin"),
|
||||
replacementOriginType = data.optString("replacementOriginType"),
|
||||
imageSearch = data.optJSONObject("imageSearch")?.let { image -> ImageSearchImage(image.getString("imageUrl"), image.getString("mediaType"), image.getLong("sizeBytes"), image.getString("sha256")) },
|
||||
)
|
||||
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
|
||||
@@ -708,6 +708,8 @@ class AgentForegroundService : Service() {
|
||||
).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} else if (task.source == "image_search") {
|
||||
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: aea36cd232cc24af80fd8576ef206fe92e6161f2
|
||||
synchronized_at: 2026-09-10T08:09:12Z
|
||||
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-11T09:09:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -429,3 +429,34 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
|
||||
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
|
||||
|
||||
## 探测到正式采购的单次面板交接(#262)
|
||||
|
||||
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
|
||||
|
||||
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
|
||||
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: ab871d61c53fe5edae1b404adaebf2495d60f099
|
||||
synchronized_at: 2026-09-10T08:09:15Z
|
||||
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-11T09:09:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -579,3 +579,34 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
|
||||
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
|
||||
|
||||
## 探测到正式采购的单次面板交接(#262)
|
||||
|
||||
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
|
||||
|
||||
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
|
||||
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
```
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: a2e73f5b892385a3c306dfdeca543a3aeb89a32e
|
||||
synchronized_at: 2026-09-10T08:09:35Z
|
||||
wiki_revision: ba259178cf6514d5b48ea425005dfd366ba803a3
|
||||
synchronized_at: 2026-09-12T03:09:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -100,6 +100,33 @@ POST /api/admin/v1/syb-shops/discover
|
||||
|
||||
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
|
||||
|
||||
## 管理端:SYB 商品过滤(#269)
|
||||
|
||||
```http
|
||||
GET /api/admin/v1/syb-product-filters
|
||||
POST /api/admin/v1/syb-product-filters
|
||||
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
|
||||
DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
```
|
||||
|
||||
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
|
||||
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
|
||||
`lastChangedBy`、`lastChangedAt`。
|
||||
|
||||
`[必须]` 服务端必须拒绝对 `kind=char` 行的 `POST` 新增与 `DELETE` 删除,分别返回
|
||||
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
|
||||
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
|
||||
|
||||
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
|
||||
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
|
||||
不得写死——写死的数字在数据变化后会给出错误警告。
|
||||
|
||||
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
|
||||
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
|
||||
`SYB_PRODUCT_FILTER_DUPLICATE`。写操作由 go-admin/Casbin 仅授权管理员,其他角色只读。
|
||||
删除为软删除,不清理已经入库的商品明细。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
```http
|
||||
@@ -556,7 +583,15 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
|
||||
|
||||
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单错误码,并按错误码写入固定提示作为面向人的主文案;不采信 Agent 文本作为权威描述,也不保存原始控件树或截图。阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
#271 修订:`errorMessage` 的语义由「仅固定提示」改为「**固定提示 + Agent 诊断证据**」。固定提示仍由错误码唯一决定并排在最前,Agent 提交的脱敏诊断追加在其后。
|
||||
|
||||
`[必须]` 追加前压平为单行:去除控制字符与零宽不换行空格、连续空白合并为一个空格;按 `error_message` 列宽 1000 截断,且**固定提示优先完整保留**,被截断的证据以 `…` 结尾。证据与固定提示相同时不重复拼接。Agent 文本为空或错误码不在白名单时行为不变,仍返回 `INVALID_REQUEST`。
|
||||
|
||||
改动原因:原做法丢弃了 Agent 已经算出并上报的诊断。`#210` 为 `PURCHASE_ORDER_PAYMENT_REPEATED` 计算 `paymentBackAttempts` 与 `consecutivePaymentSamplesAfterBack`,但 2026-09-07 至 09-11 线上 61 次该错误的 `error_message` 无一携带这两个值,导致「返回动作未生效」「页面渲染慢于容忍窗口」「页面已是订单页但关键词未命中」三种情形无法区分。相邻的 `failed` 结果分支本就原样保存 Agent 文本,因此本修订不引入新的信任假设。
|
||||
|
||||
`[必须]` `failed` 结果分支同样把错误码与消息写入 attempt,不再只写任务。此前该路径产生的 attempt 行 `error_code` 为 NULL,按 attempt 统计失败时这些记录是隐形的。
|
||||
|
||||
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
@@ -1125,3 +1160,12 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购规格现场一次重新匹配(#263)
|
||||
|
||||
2026-09-11 实现绑定 `c6ee6c0`,Server采购与Android Agent;未发布、未安装、未完成真实采购验收。
|
||||
|
||||
- 正式采购阶段仅在服务端下发 `specRematchAllowed=true`、Android 返回稳定的 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 且尚未进入地址/下单阶段时触发;颜色/尺码选中复核失败、售罄、点击失败、歧义、价格、地址和订单错误不触发。
|
||||
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
|
||||
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
|
||||
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 214485db630141aeb03d4b49b22a65e572fb592a
|
||||
synchronized_at: 2026-09-08T02:24:10Z
|
||||
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
|
||||
synchronized_at: 2026-09-11T09:11:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -495,6 +495,27 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
|
||||
|
||||
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
|
||||
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
|
||||
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
|
||||
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
|
||||
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
|
||||
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
|
||||
是为档口改用不含 `#` 的编码时兜底。
|
||||
|
||||
`[必须]` 空 `variationSku` **不过滤**。核验时 2724 行(21%)为空,分布在正常启用店铺且持续
|
||||
产生,属于尚未填写供应商编码的正常商品,不是档口货。
|
||||
|
||||
`[必须]` 两类命中数分别统计并分别回写到每条规则自己的 `last_hit_count`,不得合并成单一
|
||||
「过滤总数」,也不得按 `kind` 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0
|
||||
时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 `#` 和 `-` 提示同一个数字。
|
||||
|
||||
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
|
||||
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# PDD 首页控件摘要(设备 192.168.0.9:35727)
|
||||
|
||||
- 采集时间:2026-09-15
|
||||
- 包名:`com.xunmeng.pinduoduo`
|
||||
- 发现语义入口:`content-desc=拍照搜索`
|
||||
- 发现搜索入口:`content-desc=搜索`
|
||||
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
|
||||
- 未保存坐标、完整控件树、截图、账号或个人数据。
|
||||
@@ -0,0 +1,38 @@
|
||||
param(
|
||||
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
|
||||
[int]$BatchSize = 100,
|
||||
[int]$PageSize = 500,
|
||||
[int]$SleepSeconds = 1,
|
||||
[switch]$Force,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
|
||||
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
|
||||
}
|
||||
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
|
||||
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
|
||||
$ids = [System.Collections.Generic.List[UInt64]]::new()
|
||||
for ($page = 1; ; $page++) {
|
||||
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
|
||||
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
|
||||
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
|
||||
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
|
||||
if ($items.Count -lt $PageSize) { break }
|
||||
}
|
||||
Write-Host "发现失败记录 $($ids.Count) 条"
|
||||
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
|
||||
$ok = 0; $failed = 0
|
||||
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
|
||||
$end = [Math]::Min($i + $BatchSize, $ids.Count)
|
||||
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
|
||||
$rows = @($result.data.results)
|
||||
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
|
||||
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
|
||||
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
|
||||
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
|
||||
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
goautosybimport "go-admin/app/goauto/sybimport"
|
||||
goautosybinnercode "go-admin/app/goauto/sybinnercode"
|
||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
common "go-admin/common/middleware"
|
||||
@@ -67,4 +68,5 @@ func InitRouter() {
|
||||
goautosybimport.InitRouter(r, authMiddleware)
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ package access
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBProductFilters = "syb_product_filters"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
Sort: 51,
|
||||
ModuleKeys: []string{
|
||||
ModuleSYBShops,
|
||||
ModuleSYBProductFilters,
|
||||
ModuleCollectionRules,
|
||||
ModulePurchaseRules,
|
||||
ModuleDevices,
|
||||
@@ -91,13 +93,14 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
|
||||
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
|
||||
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
|
||||
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -144,6 +147,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleSYBProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
|
||||
return ModuleSYBShops
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
|
||||
return ModuleSYBProductFilters
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
|
||||
return ModuleSYBInnerCodes
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 12 {
|
||||
t.Fatalf("got %d modules, want 12", len(modules))
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for groupIndex, group := range groups {
|
||||
|
||||
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
|
||||
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
|
||||
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
|
||||
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
|
||||
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
|
||||
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
|
||||
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
|
||||
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
|
||||
|
||||
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
|
||||
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
|
||||
@@ -94,6 +98,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
|
||||
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
|
||||
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
|
||||
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
|
||||
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
|
||||
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
|
||||
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
|
||||
|
||||
@@ -15,12 +15,14 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/syb-product-filters": true,
|
||||
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
}
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if denied[permission.Method+" "+permission.Path] {
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
traditionalToSimplified *gocc.OpenCC
|
||||
traditionalErr error
|
||||
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
|
||||
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
|
||||
)
|
||||
|
||||
// Normalize makes a deterministic comparison key. It intentionally removes
|
||||
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
|
||||
// deliberately treated as no match instead of picking the first label.
|
||||
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
|
||||
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
|
||||
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
|
||||
if !colorOK || !sizeOK {
|
||||
return MatchResult{}, false
|
||||
}
|
||||
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
return result, true
|
||||
}
|
||||
|
||||
func exactSizeCandidate(target string, candidates []string) (string, bool) {
|
||||
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
|
||||
if key == "" {
|
||||
return exactCandidate(target, candidates)
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ManualMatch records an already-confirmed mapping without reinterpreting it.
|
||||
// The caller owns the human-confirmation check; this helper only creates the
|
||||
// same bounded task snapshot shape used by automatic decisions.
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
if len(s.Modules) != 13 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
|
||||
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
|
||||
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
|
||||
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
|
||||
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
|
||||
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -41,6 +42,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.SYBInnerCodeRecord{},
|
||||
&models.SYBInnerCodeItem{},
|
||||
@@ -85,6 +87,15 @@ func Migrate(db *gorm.DB) error {
|
||||
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
|
||||
if err := db.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
CollectionTaskSourceImageSearch = "image_search"
|
||||
)
|
||||
|
||||
// AgentDevice identifies one Agent installation. InstallID is generated by the
|
||||
@@ -181,13 +182,14 @@ type CollectionTask struct {
|
||||
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
|
||||
Device *AgentDevice `json:"-"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
|
||||
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
|
||||
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
|
||||
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
|
||||
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
|
||||
ImageSearchSnapshot *string `json:"-" gorm:"type:text"`
|
||||
AttemptNumber int `json:"attemptNumber" gorm:"not null;default:1;check:ck_collection_task_attempt_number,attempt_number >= 1"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
|
||||
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
|
||||
@@ -416,7 +418,8 @@ type ShopeeProduct struct {
|
||||
// PDDProductID stays nullable and must NOT be unique: one Shopee product maps
|
||||
// to at most one PDD product, but several Shopee products may share the same
|
||||
// PDD product.
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
|
||||
// ImageURL holds the SYB-provided reference image URL. It is written by the
|
||||
// #41 import and may be overridden manually; the product domain never joins
|
||||
// syb_products at read time.
|
||||
@@ -455,7 +458,7 @@ func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error {
|
||||
// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse.
|
||||
const (
|
||||
SYBParseStatusSuccess = "success"
|
||||
SYBParseStatusUncertain = "uncertain"
|
||||
SYBParseStatusUncertain = "uncertain" // legacy input only; persistence normalizes to failed
|
||||
SYBParseStatusFailed = "failed"
|
||||
)
|
||||
|
||||
@@ -600,32 +603,61 @@ type SYBShop struct {
|
||||
|
||||
func (SYBShop) TableName() string { return "syb_shop" }
|
||||
|
||||
// SYBProductFilter controls which SYB detail variationSku values are skipped.
|
||||
// Char rows are seeded business判据 and may only be enabled/disabled; keyword
|
||||
// rows are operator-maintained. DeletedFlag keeps the composite unique key
|
||||
// reusable after a keyword is soft-deleted.
|
||||
type SYBProductFilter struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null;index;uniqueIndex:ux_syb_product_filter_key,priority:1;check:ck_syb_product_filter_kind,kind IN ('char','keyword')"`
|
||||
// Keyword and NormalizedKeyword are sized, not text: MySQL refuses a
|
||||
// BLOB/TEXT column in a key specification without a prefix length, so the
|
||||
// unique index below cannot be built on a text column (Error 1170). SQLite
|
||||
// accepts either, which is why only a real MySQL migration surfaces this.
|
||||
Keyword string `json:"keyword" gorm:"size:200;not null"`
|
||||
NormalizedKeyword string `json:"normalizedKeyword" gorm:"size:200;not null;uniqueIndex:ux_syb_product_filter_key,priority:2"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
LastHitAt *time.Time `json:"lastHitAt"`
|
||||
LastHitCount *int `json:"lastHitCount"`
|
||||
Note *string `json:"note" gorm:"type:text"`
|
||||
LastChangedBy string `json:"lastChangedBy" gorm:"size:128;not null;default:''"`
|
||||
LastChangedAt *time.Time `json:"lastChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_product_filter_key,priority:3"`
|
||||
}
|
||||
|
||||
func (SYBProductFilter) TableName() string { return "syb_product_filter" }
|
||||
|
||||
// SYBSyncRun records one asynchronous SYB import. ActiveSlot is set only while
|
||||
// a run is active; its unique index is the database-level single-flight guard
|
||||
// across processes and is cleared when the run reaches a terminal state.
|
||||
type SYBSyncRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped" gorm:"column:char_filter_skipped;not null;default:0"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped" gorm:"column:keyword_filter_skipped;not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSyncRun) TableName() string { return "syb_sync_run" }
|
||||
|
||||
@@ -422,6 +422,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
// Readiness must agree with the mapping shown in the product detail. A
|
||||
// deterministic suggestion is only a preview; it is not a persisted,
|
||||
// confirmed mapping and must not make the row appear purchase-ready.
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, item.MappedColor, item.MappedSize) {
|
||||
// Keep Eligible for the existing live-probe path, but expose the
|
||||
// unresolved state so the UI cannot label the row purchase-ready.
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "采购规格尚未匹配并保存", "open_mapping"
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
|
||||
}
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
@@ -231,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
t.Fatalf("preview failed: %+v err=%v", preview, err)
|
||||
}
|
||||
item := preview.Items[0]
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
||||
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
|
||||
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/device"
|
||||
@@ -397,6 +398,11 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
case "failed":
|
||||
next = models.PurchaseTaskStatusFailed
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
// `[必须]` Write the failure onto the attempt too, not only the task.
|
||||
// Without it every attempt produced by this branch carries a NULL
|
||||
// error_code, so attempt-level statistics silently omit them — on
|
||||
// production the spec-panel failures were invisible that way (#271).
|
||||
a.ErrorCode, a.ErrorMessage = &req.ErrorCode, &req.ErrorMessage
|
||||
t.ErrorCode = &req.ErrorCode
|
||||
t.ErrorMessage = &req.ErrorMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
@@ -447,6 +453,26 @@ var allowedOrderUnknownFailures = map[string]string{
|
||||
CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据",
|
||||
}
|
||||
|
||||
// orderFailureMessageLimit matches the error_message column width on both
|
||||
// purchase_task and purchase_task_attempt (size:1000). The canonical sentence is
|
||||
// never sacrificed to fit: only the appended agent evidence is truncated.
|
||||
const orderFailureMessageLimit = 1000
|
||||
|
||||
// normalizeOrderUnknownFailure keeps the canonical sentence as the message
|
||||
// people read, and appends the agent's own diagnostic behind it.
|
||||
//
|
||||
// `[必须]` The canonical text stays authoritative. The agent supplies free text,
|
||||
// so it must not become the primary description of what happened, and the UI
|
||||
// wording has to stay stable per error code.
|
||||
//
|
||||
// `[必须]` The agent's evidence is nevertheless kept. Discarding it left the
|
||||
// PAYMENT_REPEATED failures unanalysable: #210 computes paymentBackAttempts and
|
||||
// consecutivePaymentSamplesAfterBack, the agent reports them, and none of the 61
|
||||
// production failures between 2026-09-07 and 09-11 carried either value, so
|
||||
// "Back did not work", "the page rendered slower than the ~1.1s window" and "the
|
||||
// page was the order page but no marker matched" could not be told apart. The
|
||||
// sibling "failed" branch already stores agent text verbatim, so keeping it here
|
||||
// introduces no new trust assumption (#271).
|
||||
func normalizeOrderUnknownFailure(code, message string) (string, string, error) {
|
||||
code, message = strings.TrimSpace(code), strings.TrimSpace(message)
|
||||
if code == "" && message == "" {
|
||||
@@ -456,7 +482,53 @@ func normalizeOrderUnknownFailure(code, message string) (string, string, error)
|
||||
if !ok || message == "" {
|
||||
return "", "", fail(CodeInvalidRequest, "订单核单失败阶段无效")
|
||||
}
|
||||
return code, canonicalMessage, nil
|
||||
return code, appendAgentEvidence(canonicalMessage, message), nil
|
||||
}
|
||||
|
||||
// appendAgentEvidence joins the canonical sentence with the agent's diagnostic.
|
||||
//
|
||||
// The evidence is flattened to a single line first: agent text reaches the
|
||||
// purchase list, task detail and exports, and an embedded newline or control
|
||||
// character would break those renderings.
|
||||
func appendAgentEvidence(canonical, evidence string) string {
|
||||
evidence = flattenAgentEvidence(evidence)
|
||||
if evidence == "" || evidence == canonical {
|
||||
return canonical
|
||||
}
|
||||
room := orderFailureMessageLimit - len([]rune(canonical)) - 1
|
||||
if room <= 0 {
|
||||
return canonical
|
||||
}
|
||||
if runes := []rune(evidence); len(runes) > room {
|
||||
if room <= 1 {
|
||||
return canonical
|
||||
}
|
||||
evidence = string(runes[:room-1]) + "…"
|
||||
}
|
||||
return canonical + " " + evidence
|
||||
}
|
||||
|
||||
// flattenAgentEvidence removes control characters and collapses whitespace so
|
||||
// the appended text is always one readable line.
|
||||
func flattenAgentEvidence(evidence string) string {
|
||||
var b strings.Builder
|
||||
lastWasSpace := false
|
||||
for _, r := range evidence {
|
||||
switch {
|
||||
case r == 0xFEFF:
|
||||
// Zero-width no-break space: invisible, so drop it rather than
|
||||
// turning it into a space that would look like a stray gap.
|
||||
case unicode.IsControl(r) || unicode.IsSpace(r):
|
||||
if !lastWasSpace {
|
||||
b.WriteRune(' ')
|
||||
lastWasSpace = true
|
||||
}
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastWasSpace = false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 这条诊断就是 #210 让 Agent 算出来、却一直没能落库的那一段。
|
||||
const paymentRepeatAgentMessage = "支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]"
|
||||
|
||||
func TestNormalizeOrderUnknownFailureKeepsCanonicalAndAgentEvidence(t *testing.T) {
|
||||
code, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, paymentRepeatAgentMessage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != CodeOrderPaymentRepeat {
|
||||
t.Fatalf("code changed: %q", code)
|
||||
}
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence must lead the message, got %q", message)
|
||||
}
|
||||
// 没有这两个值就无法区分「Back 没生效」「渲染慢于窗口」「关键词没匹配」。
|
||||
for _, want := range []string{"paymentBackAttempts=1", "consecutivePaymentSamplesAfterBack=3"} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("agent evidence %q missing from %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderUnknownFailureFlattensAndBounds(t *testing.T) {
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
|
||||
t.Run("控制字符和换行被压平", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, "证据行一\n\t行二\r\n行三\ufeff")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(message, "\n\r\t") {
|
||||
t.Fatalf("message must stay on one line: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "证据行一 行二 行三") {
|
||||
t.Fatalf("whitespace should collapse to single spaces: %q", message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超长证据被截断且固定文案完整保留", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, strings.Repeat("证", 4000))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence was sacrificed to fit: %q", message)
|
||||
}
|
||||
if n := len([]rune(message)); n > orderFailureMessageLimit {
|
||||
t.Fatalf("message is %d runes, column holds %d", n, orderFailureMessageLimit)
|
||||
}
|
||||
if !strings.HasSuffix(message, "…") {
|
||||
t.Fatalf("truncation should be visible: %q", message[len(message)-30:])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空消息行为不变", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, ""); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("empty agent message must still be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未批准的错误码仍被拒绝", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure("UNAPPROVED", "whatever"); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("unapproved code must be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("证据与固定文案相同时不重复拼接", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, canonical)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message != canonical {
|
||||
t.Fatalf("identical evidence should not be appended twice: %q", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 端到端确认证据真的写进了两张表,而不是只在函数里拼对了。
|
||||
func TestSubmitOrderResultUnknownPersistsAgentEvidence(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||||
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var saved models.PurchaseTask
|
||||
if err := db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, got := range map[string]*string{"task": saved.ErrorMessage, "attempt": attempt.ErrorMessage} {
|
||||
if got == nil {
|
||||
t.Fatalf("%s kept no error message", name)
|
||||
}
|
||||
if !strings.Contains(*got, "consecutivePaymentSamplesAfterBack=3") {
|
||||
t.Fatalf("%s dropped the agent evidence: %q", name, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "failed" 分支过去只写任务不写 attempt,attempt.error_code 全是 NULL,
|
||||
// 导致按 attempt 统计时这些失败是隐形的(线上规格失败即如此)。
|
||||
func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
|
||||
const failCode = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
const failMessage = "规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=1]"
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
|
||||
ErrorCode: failCode, ErrorMessage: failMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt.ErrorCode == nil || *attempt.ErrorCode != failCode {
|
||||
t.Fatalf("attempt error code not recorded: %+v", attempt.ErrorCode)
|
||||
}
|
||||
if attempt.ErrorMessage == nil || *attempt.ErrorMessage != failMessage {
|
||||
t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,18 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var imageSearchLinked *bool
|
||||
if raw := strings.TrimSpace(c.Query("imageSearchLinked")); raw != "" {
|
||||
value, parseErr := strconv.ParseBool(raw)
|
||||
if parseErr != nil {
|
||||
writeError(c, invalidRequest("imageSearchLinked 必须是布尔值"))
|
||||
return
|
||||
}
|
||||
imageSearchLinked = &value
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
ImageSearchLinked: imageSearchLinked,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -231,7 +231,8 @@ type ListRequest struct {
|
||||
// Status filters by lifecycle: "" (default) live only, "deleted" soft-deleted
|
||||
// only. There is no "all" value: mixing live and deleted rows in one page
|
||||
// would defeat the point of the filter (#40 prototype: 全部(不含已删除)).
|
||||
Status string
|
||||
Status string
|
||||
ImageSearchLinked *bool
|
||||
}
|
||||
|
||||
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
|
||||
@@ -257,6 +258,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
like := "%" + request.Keyword + "%"
|
||||
query = query.Where("shopee_item_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like)
|
||||
}
|
||||
if request.ImageSearchLinked != nil {
|
||||
query = query.Where("image_search_linked = ?", *request.ImageSearchLinked)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
@@ -303,7 +307,7 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"pdd_product_id": request.PDDProductID, "last_update_request_id": request.RequestID,
|
||||
"pdd_product_id": request.PDDProductID, "image_search_linked": false, "last_update_request_id": request.RequestID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return SaveResponse{}, internalError(result.Error)
|
||||
|
||||
@@ -84,6 +84,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
}
|
||||
|
||||
parsed := Parse(detail.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
rawJSON, err := normalizeRaw(detail.Raw)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
|
||||
@@ -25,7 +25,6 @@ var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one
|
||||
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
|
||||
// its bracket to "黑色+白色 簡約親膚", which still carries a '+' and internal
|
||||
// whitespace, so it must not be reported as a confident match.
|
||||
var ambiguousPattern = regexp.MustCompile(`[++\s]`)
|
||||
|
||||
// ParseResult is the color/size candidate extracted from one productSpec
|
||||
// string, plus how much the caller should trust it.
|
||||
@@ -73,7 +72,10 @@ func Parse(raw string) ParseResult {
|
||||
if size == "" {
|
||||
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"}
|
||||
}
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
|
||||
if explicitSizePattern.MatchString(size) {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
@@ -81,7 +83,17 @@ func Parse(raw string) ParseResult {
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
|
||||
only := colorPart
|
||||
if only == "" {
|
||||
only = sizePart
|
||||
}
|
||||
if explicitSizePattern.MatchString(only) {
|
||||
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if only != "" {
|
||||
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
@@ -90,9 +102,6 @@ func Parse(raw string) ParseResult {
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
if ambiguousPattern.MatchString(colorPart) {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
outcome.NewStatus = parsed.Status
|
||||
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
|
||||
@@ -42,6 +42,9 @@ func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairRe
|
||||
continue
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
|
||||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -52,8 +53,14 @@ type SyncReport struct {
|
||||
// ShopSkipped how many did not. OrderCount stays the raw total: keeping the
|
||||
// unfiltered number visible is what makes a shop missing from the
|
||||
// allow-list obvious rather than silent.
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped"`
|
||||
// filterHits accumulates hits per filter rule id so each rule's own
|
||||
// LastHitCount can be written back. It is unexported because it is run
|
||||
// bookkeeping, not part of the report shown to people or stored as JSON.
|
||||
filterHits sybproductfilter.HitCounter
|
||||
// ShopBreakdown separates accepted and skipped orders for every shop. Without
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
@@ -119,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
// run's effective allow-list differ from the snapshot recorded on its row.
|
||||
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed),
|
||||
filterHits: sybproductfilter.HitCounter{}}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
@@ -128,6 +136,10 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
filters, err := sybproductfilter.LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("读取商品过滤规则失败: %w", err)
|
||||
}
|
||||
report.ShopFilterHash = enabledShopHash(allowed)
|
||||
emit := func(daysProcessed int) error {
|
||||
if progress == nil {
|
||||
@@ -181,7 +193,7 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
}
|
||||
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
|
||||
report.OrderCount += len(rows)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report, filters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
|
||||
}
|
||||
@@ -204,6 +216,9 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
}
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
|
||||
@@ -270,6 +285,24 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
// SYB may change a page while the list is being read. Re-read once
|
||||
// before treating a boundary-page count drift as a fatal snapshot error.
|
||||
if len(page) != expectedPageCount {
|
||||
pause := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
pause.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-pause.C:
|
||||
}
|
||||
page, pageCount, err = client.ListPage(ctx, date, date, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("重读 %s 货运单列表第 %d 页失败:%w", date, pageIndex, err)
|
||||
}
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页重读响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
}
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
@@ -347,7 +380,11 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
|
||||
// applyStockDetail writes one shipment order's items. Each item is its own
|
||||
// ApplyDetail call and its own transaction — a several-thousand-row sync must
|
||||
// not sit in one long-held transaction.
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport) error {
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
order := OrderInput{
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
@@ -357,6 +394,23 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
order.Code = row.Code
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
report.KeywordFilterSkipped++
|
||||
}
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("货运单 %s 明细 %d 原始数据编码失败: %w", order.Code, item.ID, err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,7 +15,11 @@ func (e *syncFatalError) Unwrap() error { return e.error }
|
||||
|
||||
// Read all remote details before opening a page transaction. Publish counters
|
||||
// only after commit, so a rollback never reports records as saved.
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport) error {
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
next := *report
|
||||
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
|
||||
for k, v := range report.ShopBreakdown {
|
||||
@@ -69,7 +74,7 @@ func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client,
|
||||
next.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next); err != nil {
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next, filters); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
"days_total": progress.DaysTotal, "days_processed": progress.DaysProcessed,
|
||||
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
|
||||
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
|
||||
"char_filter_skipped": progress.Report.CharFilterSkipped, "keyword_filter_skipped": progress.Report.KeywordFilterSkipped,
|
||||
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
|
||||
}).Error
|
||||
@@ -92,6 +93,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
"status": status, "active_slot": nil, "finished_at": now, "error_message": message,
|
||||
"order_count": report.OrderCount, "detail_count": report.DetailCount,
|
||||
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
|
||||
"char_filter_skipped": report.CharFilterSkipped, "keyword_filter_skipped": report.KeywordFilterSkipped,
|
||||
"created_count": report.Created, "updated_count": report.Updated,
|
||||
"shop_breakdown_json": payload,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (h Handler) service(c *gin.Context) (*Service, bool) {
|
||||
db := h.DB
|
||||
var e error
|
||||
if db == nil {
|
||||
db, e = pkg.GetOrm(c)
|
||||
}
|
||||
if e != nil {
|
||||
writeError(c, internal(e))
|
||||
return nil, false
|
||||
}
|
||||
return NewService(db), true
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
ps, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
|
||||
r, e := s.List(c.Request.Context(), ListRequest{Page: p, PageSize: ps, Kind: c.Query("kind"), Keyword: c.Query("keyword"), EnabledOnly: c.Query("enabledOnly") == "true"})
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": r})
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
var r CreateRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
x, e := s.Create(c.Request.Context(), r)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var r SetEnabledRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
name, _ := claims["nice"].(string)
|
||||
x, e := s.SetEnabled(c.Request.Context(), id, r, name)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := s.Delete(c.Request.Context(), id); e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"deleted": true}})
|
||||
}
|
||||
func idParam(c *gin.Context) (uint64, bool) {
|
||||
id, e := strconv.ParseUint(c.Param("filterId"), 10, 64)
|
||||
if e != nil || id == 0 {
|
||||
writeError(c, invalid("过滤规则 id 必须是正整数"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
func decode(c *gin.Context, t any) bool {
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if e := d.Decode(t); e != nil {
|
||||
writeError(c, invalid("请求体解析失败:"+e.Error()))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func writeError(c *gin.Context, e error) {
|
||||
var se *ServiceError
|
||||
if !errors.As(e, &se) {
|
||||
se = internal(e).(*ServiceError)
|
||||
}
|
||||
status := 500
|
||||
switch se.Code {
|
||||
case CodeInvalidRequest:
|
||||
status = 422
|
||||
case CodeConflict:
|
||||
status = 409
|
||||
case CodeNotFound:
|
||||
status = 404
|
||||
case CodeForbidden:
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"code": se.Code, "message": se.Message})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 用线上真实取到的 variationSku 样本,验证过滤判定与每条规则各自计数。
|
||||
func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samples := []struct {
|
||||
v string
|
||||
wantKind string // "" = 不过滤
|
||||
}{
|
||||
{"新寮-雅伊阁大码女装#301", "char"},
|
||||
{"广达-爆款大码女装#9018", "char"},
|
||||
{"DD#004", "char"},
|
||||
{"300斤牛奶絲圓領#A057", "char"},
|
||||
{"上寮-琪琪尚雅网批#2604##(&15)", "char"},
|
||||
{"PDD0582", ""},
|
||||
{"my238", ""},
|
||||
{"le171", ""},
|
||||
{"1", ""},
|
||||
{"引流款", ""},
|
||||
{"", ""},
|
||||
}
|
||||
hits := HitCounter{}
|
||||
var charN, kwN, keptN int
|
||||
for _, s := range samples {
|
||||
rule := set.Match(s.v)
|
||||
if s.wantKind == "" {
|
||||
if rule != nil {
|
||||
t.Fatalf("%q 不应被过滤,却命中了 %q", s.v, rule.Keyword)
|
||||
}
|
||||
keptN++
|
||||
continue
|
||||
}
|
||||
if rule == nil || rule.Kind != s.wantKind {
|
||||
t.Fatalf("%q 应命中 %s 规则,实际 %v", s.v, s.wantKind, rule)
|
||||
}
|
||||
hits.Add(rule)
|
||||
if rule.Kind == "char" {
|
||||
charN++
|
||||
} else {
|
||||
kwN++
|
||||
}
|
||||
}
|
||||
if charN != 5 || kwN != 0 || keptN != 6 {
|
||||
t.Fatalf("统计不符:char=%d keyword=%d kept=%d", charN, kwN, keptN)
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, x := range after.Chars {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("规则 %q 必须被写入命中数", x.Keyword)
|
||||
}
|
||||
got[x.Keyword] = *x.LastHitCount
|
||||
}
|
||||
// "#" 命中 4 条(301/9018/DD#004/A057 ... 其中带 - 的先被 "-" 规则接走)
|
||||
if got["-"]+got["#"] != 5 {
|
||||
t.Fatalf("两条结构规则命中数之和应为 5,实际 - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
if got["-"] == got["#"] {
|
||||
t.Fatalf("两条规则不应记成同一个数(这正是 #269 修的 bug): - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
for _, x := range after.Keywords {
|
||||
if x.LastHitCount == nil || *x.LastHitCount != 0 {
|
||||
t.Fatalf("未命中的关键词 %q 应记 0,实际 %v", x.Keyword, x.LastHitCount)
|
||||
}
|
||||
}
|
||||
t.Logf("结构规则命中:- =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
|
||||
g := e.Group("/api/admin/v1/syb-product-filters").Use(a.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
g.GET("", Handler{}.List)
|
||||
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
|
||||
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
|
||||
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
Code, Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ServiceError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
func (e *ServiceError) Unwrap() error { return e.Cause }
|
||||
|
||||
const (
|
||||
CodeInvalidRequest = "INVALID_REQUEST"
|
||||
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
|
||||
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
)
|
||||
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
func internal(e error) error {
|
||||
return &ServiceError{Code: "INTERNAL_ERROR", Message: "服务器内部错误", Cause: e}
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Kind, Keyword string
|
||||
EnabledOnly bool
|
||||
}
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProductFilter `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
|
||||
if r.Page < 1 {
|
||||
r.Page = 1
|
||||
}
|
||||
if r.PageSize < 1 || r.PageSize > 200 {
|
||||
r.PageSize = 50
|
||||
}
|
||||
q := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{})
|
||||
if r.Kind != "" {
|
||||
q = q.Where("kind = ?", r.Kind)
|
||||
}
|
||||
if k := sybshop.Normalize(r.Keyword); k != "" {
|
||||
q = q.Where("normalized_keyword LIKE ?", "%"+k+"%")
|
||||
}
|
||||
if r.EnabledOnly {
|
||||
q = q.Where("enabled = ?", true)
|
||||
}
|
||||
var total, enabled int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Keyword string `json:"keyword"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
func validate(kind, keyword string) (string, string, error) {
|
||||
if kind != "char" && kind != "keyword" {
|
||||
return "", "", invalid("kind 必须是 char 或 keyword")
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return "", "", invalid("关键词不能为空")
|
||||
}
|
||||
if sybshop.ContainsControl(keyword) {
|
||||
return "", "", invalid("关键词不能包含控制字符")
|
||||
}
|
||||
return keyword, sybshop.Normalize(keyword), nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, r CreateRequest) (models.SYBProductFilter, error) {
|
||||
if r.Kind == "char" {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeForbidden, Message: "结构过滤不可新增"}
|
||||
}
|
||||
k, n, e := validate(r.Kind, r.Keyword)
|
||||
if e != nil {
|
||||
return models.SYBProductFilter{}, e
|
||||
}
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).Where("kind = ? AND normalized_keyword = ?", r.Kind, n).First(&x).Error; err == nil {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeConflict, Message: fmt.Sprintf("该关键词已存在(归一化后与「%s」相同)", x.Keyword)}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
en := true
|
||||
if r.Enabled != nil {
|
||||
en = *r.Enabled
|
||||
}
|
||||
x = models.SYBProductFilter{Kind: r.Kind, Keyword: k, NormalizedKeyword: n, Enabled: en, Note: r.Note}
|
||||
if err := s.DB.WithContext(ctx).Create(&x).Error; err != nil {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type SetEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Service) SetEnabled(ctx context.Context, id uint64, r SetEnabledRequest, operator string) (models.SYBProductFilter, error) {
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return x, &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return x, internal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"enabled": r.Enabled, "updated_at": now}
|
||||
if x.Kind == "char" {
|
||||
updates["last_changed_by"] = strings.TrimSpace(operator)
|
||||
updates["last_changed_at"] = now
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&x).Updates(updates).Error; err != nil {
|
||||
return x, internal(err)
|
||||
}
|
||||
x.Enabled = r.Enabled
|
||||
x.UpdatedAt = now
|
||||
if x.Kind == "char" {
|
||||
x.LastChangedBy = strings.TrimSpace(operator)
|
||||
x.LastChangedAt = &now
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var x models.SYBProductFilter
|
||||
if err := tx.First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
if x.Kind == "char" {
|
||||
return &ServiceError{Code: CodeForbidden, Message: "结构过滤不可删除"}
|
||||
}
|
||||
return tx.Model(&x).Updates(map[string]any{"deleted_flag": x.ID, "deleted_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type RuleSet struct{ Chars, Keywords []models.SYBProductFilter }
|
||||
|
||||
func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.WithContext(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
r := RuleSet{}
|
||||
for _, x := range rows {
|
||||
if x.Kind == "char" {
|
||||
r.Chars = append(r.Chars, x)
|
||||
} else {
|
||||
r.Keywords = append(r.Keywords, x)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Match reports which enabled rule first matched v, or nil when none did.
|
||||
//
|
||||
// It returns the matching rule rather than a pair of booleans because the
|
||||
// caller has to attribute the hit to one specific rule: every rule keeps its
|
||||
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
|
||||
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
|
||||
// char/keyword booleans would force every rule of a kind to share one
|
||||
// aggregate, and the dialog would then warn with the wrong figure — on real
|
||||
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
|
||||
// reported as the same number.
|
||||
//
|
||||
// Structural rules are checked before keyword rules so a hit is attributed the
|
||||
// way the page presents the two sections, and an empty variationSku never
|
||||
// matches: it is a product with no supplier code yet, not a 档口 item.
|
||||
func (r RuleSet) Match(v string) *models.SYBProductFilter {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n := sybshop.Normalize(v)
|
||||
for i, x := range r.Chars {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Chars[i]
|
||||
}
|
||||
}
|
||||
for i, x := range r.Keywords {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Keywords[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HitCounter accumulates per-rule hits over one sync run, keyed by rule id.
|
||||
type HitCounter map[uint64]int
|
||||
|
||||
// Add records one hit against the rule that matched.
|
||||
func (h HitCounter) Add(rule *models.SYBProductFilter) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
h[rule.ID]++
|
||||
}
|
||||
|
||||
// UpdateHits writes each rule's own hit count back to its row.
|
||||
//
|
||||
// Every rule in the set is written, including those with zero hits: leaving a
|
||||
// previous run's number in place would make the confirmation dialog quote a
|
||||
// figure that no longer describes the current data.
|
||||
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error {
|
||||
for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) {
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).
|
||||
Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMatchORAndNormalization(t *testing.T) {
|
||||
r := RuleSet{
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
|
||||
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
|
||||
}
|
||||
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
|
||||
// production data that difference is 2118 rows (#269).
|
||||
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
|
||||
hit := r.Match(v)
|
||||
if hit == nil || hit.Kind != "char" {
|
||||
t.Fatalf("%q should match a structural rule, got %v", v, hit)
|
||||
}
|
||||
if hit.ID != 2 {
|
||||
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
|
||||
}
|
||||
}
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(""); hit != nil {
|
||||
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
|
||||
}
|
||||
if hit := r.Match("PDD0582"); hit != nil {
|
||||
t.Fatalf("own product code must not match, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("纯棉"); hit != nil {
|
||||
t.Fatal("normalization must not convert traditional to simplified Chinese")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
|
||||
// aggregate per kind would make the disable-confirmation dialog quote the same
|
||||
// figure for "#" and "-" even though they match very different numbers of rows.
|
||||
func TestUpdateHitsIsPerRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(set.Chars) != 2 {
|
||||
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
|
||||
}
|
||||
byKeyword := map[string]uint64{}
|
||||
for _, x := range set.Chars {
|
||||
byKeyword[x.Keyword] = x.ID
|
||||
}
|
||||
hits := HitCounter{}
|
||||
for i := 0; i < 8498; i++ {
|
||||
hits[byKeyword["#"]]++
|
||||
}
|
||||
for i := 0; i < 6392; i++ {
|
||||
hits[byKeyword["-"]]++
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]int{"#": 8498, "-": 6392}
|
||||
for _, x := range rows {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword)
|
||||
}
|
||||
if w, ok := want[x.Keyword]; ok {
|
||||
if *x.LastHitCount != w {
|
||||
t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *x.LastHitCount != 0 {
|
||||
t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount)
|
||||
}
|
||||
if x.LastHitAt == nil {
|
||||
t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharCreateDeleteRejectedAndKeywordDuplicate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
ctx := context.Background()
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "char", Keyword: "@"}); e == nil {
|
||||
t.Fatal("char create must be rejected")
|
||||
}
|
||||
if e := s.Delete(ctx, 1); e == nil {
|
||||
t.Fatal("seed char delete must be rejected")
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: " CVC "}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: "cvc"}); e == nil {
|
||||
t.Fatal("normalized duplicate must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,25 @@ func (handler Handler) AdminBatchCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminBatchCreateImageSearch(c *gin.Context) {
|
||||
var request ImageSearchBatchRequest
|
||||
if err := decodeStrict(c, &request); err != nil {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, _, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminDetail(c *gin.Context) {
|
||||
id, err := taskID(c)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -211,7 +211,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return CurrentPageIdentifyResponse{}, internalError(err)
|
||||
}
|
||||
if existing.Source != models.CollectionTaskSourceAgentCurrentPage || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
if (existing.Source != models.CollectionTaskSourceAgentCurrentPage && existing.Source != models.CollectionTaskSourceImageSearch) || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if existing.IdentifyRequestID != nil && *existing.IdentifyRequestID == request.RequestID && existing.PDDProductID != nil {
|
||||
@@ -240,7 +240,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceAgentCurrentPage || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
if (record.Source != models.CollectionTaskSourceAgentCurrentPage && record.Source != models.CollectionTaskSourceImageSearch) || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
return serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if record.IdentifyRequestID != nil && *record.IdentifyRequestID == request.RequestID && record.PDDProductID != nil {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const ImageSearchCapability = "pdd.image-search.v1"
|
||||
const imageSearchMaxBytes int64 = 10 << 20
|
||||
|
||||
func validImageMetadata(value ImageSearchImage) bool {
|
||||
if value.SizeBytes <= 0 || value.SizeBytes > imageSearchMaxBytes || len(value.SHA256) != 64 || value.SHA256 != strings.ToLower(value.SHA256) || value.ImageURL == "" {
|
||||
return false
|
||||
}
|
||||
return value.MediaType == "image/jpeg" || value.MediaType == "image/png"
|
||||
}
|
||||
|
||||
func fetchImageSearchImage(ctx context.Context, rawURL string) (ImageSearchImage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return ImageSearchImage{}, fmt.Errorf("reference image status %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, imageSearchMaxBytes+1))
|
||||
if err != nil || int64(len(data)) > imageSearchMaxBytes {
|
||||
return ImageSearchImage{}, errors.New("reference image exceeds limit")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || config.Width < 1 || config.Height < 1 {
|
||||
return ImageSearchImage{}, errors.New("unsupported image format")
|
||||
}
|
||||
media := "image/" + format
|
||||
if format == "jpeg" {
|
||||
media = "image/jpeg"
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return ImageSearchImage{ImageURL: rawURL, MediaType: media, SizeBytes: int64(len(data)), SHA256: hex.EncodeToString(hash[:])}, nil
|
||||
}
|
||||
|
||||
// Only this subset is sent to Agent. Association and price snapshots stay on Server.
|
||||
type ImageSearchImage struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
MediaType string `json:"mediaType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type ImageSearchSnapshot struct {
|
||||
ImageSearchImage
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RepresentativeSYBProductID uint64 `json:"representativeSybProductId"`
|
||||
OriginalPDDProductID *uint64 `json:"originalPddProductId"`
|
||||
ReferencePriceCent int64 `json:"referencePriceCent"`
|
||||
ReferenceCurrency string `json:"referenceCurrency"`
|
||||
MaxPriceRatio int64 `json:"maxPriceRatio"`
|
||||
PriceGuardSkipped bool `json:"priceGuardSkipped"`
|
||||
PriceGuardSkipReason string `json:"priceGuardSkipReason,omitempty"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
DeviceID *uint64 `json:"deviceId"`
|
||||
OverwriteLinked bool `json:"overwriteLinked"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchItem struct {
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
Success bool `json:"success"`
|
||||
TaskID uint64 `json:"taskId,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchResponse struct {
|
||||
Items []ImageSearchBatchItem `json:"items"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailureCount int `json:"failureCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
}
|
||||
|
||||
func (service *Service) BatchCreateImageSearch(ctx context.Context, request ImageSearchBatchRequest) (ImageSearchBatchResponse, error) {
|
||||
batchID, err := uuid.Parse(strings.TrimSpace(request.RequestID))
|
||||
if err != nil || request.RuleID == 0 || len(request.SYBProductIDs) == 0 || len(request.SYBProductIDs) > 100 || request.DeviceID != nil && *request.DeviceID == 0 {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "批次、SYB 商品、规则和设备参数无效(最多 100 条)")
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
if id == 0 || seen[id] {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "SYB 商品列表包含无效或重复 ID")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
// Bound the complete synchronous batch, not just each individual download.
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
var rows []models.SYBProduct
|
||||
if err := service.DB.WithContext(ctx).Where("id IN ?", request.SYBProductIDs).Find(&rows).Error; err != nil {
|
||||
return ImageSearchBatchResponse{}, internalError(err)
|
||||
}
|
||||
byID := map[uint64]models.SYBProduct{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
response := ImageSearchBatchResponse{Items: []ImageSearchBatchItem{}}
|
||||
groups := map[uint64]int{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
row, found := byID[id]
|
||||
if !found || row.ShopeeProductID == nil || *row.ShopeeProductID == 0 {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
|
||||
continue
|
||||
}
|
||||
if index, ok := groups[*row.ShopeeProductID]; ok {
|
||||
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
|
||||
} else {
|
||||
groups[*row.ShopeeProductID] = len(response.Items)
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{ShopeeProductID: *row.ShopeeProductID, SYBProductIDs: []uint64{id}})
|
||||
}
|
||||
}
|
||||
for index := range response.Items {
|
||||
item := &response.Items[index]
|
||||
if item.Code == "" {
|
||||
itemRequestID := uuid.NewSHA1(batchID, []byte(strconv.FormatUint(item.ShopeeProductID, 10))).String()
|
||||
id, replayed, createErr := service.createImageSearch(ctx, request, *item, itemRequestID)
|
||||
if createErr == nil {
|
||||
item.Success, item.TaskID, item.Replayed = true, id, replayed
|
||||
} else {
|
||||
var target *ServiceError
|
||||
if !errors.As(createErr, &target) {
|
||||
target = internalError(createErr).(*ServiceError)
|
||||
}
|
||||
item.Code, item.Message = target.Code, target.Message
|
||||
}
|
||||
}
|
||||
if item.Success {
|
||||
response.SuccessCount++
|
||||
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" {
|
||||
response.SkippedCount++
|
||||
} else {
|
||||
response.FailureCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func imageRequestFingerprint(request ImageSearchBatchRequest, item ImageSearchBatchItem) string {
|
||||
// Include group membership so a reused request ID cannot silently change intent.
|
||||
request.RequestID = ""
|
||||
request.SYBProductIDs = item.SYBProductIDs
|
||||
raw, _ := json.Marshal(request)
|
||||
hash := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot, error) {
|
||||
var snapshot ImageSearchSnapshot
|
||||
if record.ImageSearchSnapshot == nil || json.Unmarshal([]byte(*record.ImageSearchSnapshot), &snapshot) != nil || snapshot.ShopeeProductID == 0 || snapshot.RepresentativeSYBProductID == 0 || snapshot.MaxPriceRatio < 1 || !validImageMetadata(snapshot.ImageSearchImage) {
|
||||
return snapshot, serviceError("IMAGE_SEARCH_SNAPSHOT_INVALID", "图搜任务快照无效")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// ImageSearchPriceAllowed deliberately refuses to compare unlike currencies.
|
||||
// A skipped check is returned as comparable=false so callers must require
|
||||
// manual confirmation before automatic association.
|
||||
func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) {
|
||||
if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 {
|
||||
return false, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(snapshot.ReferenceCurrency)) != strings.ToUpper(strings.TrimSpace(pddCurrency)) {
|
||||
return false, false
|
||||
}
|
||||
return priceCent <= snapshot.ReferencePriceCent*snapshot.MaxPriceRatio, true
|
||||
}
|
||||
|
||||
func (service *Service) createImageSearch(ctx context.Context, request ImageSearchBatchRequest, item ImageSearchBatchItem, requestID string) (uint64, bool, error) {
|
||||
fingerprint := imageRequestFingerprint(request, item)
|
||||
replay := func(db *gorm.DB) (uint64, bool, error) {
|
||||
var record models.CollectionTask
|
||||
err := db.Where("create_request_id = ?", requestID).First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(record)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceImageSearch || snapshot.RequestFingerprint != fingerprint {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_REQUEST_CONFLICT", "同一请求 ID 的参数已变化")
|
||||
}
|
||||
return record.ID, true, nil
|
||||
}
|
||||
db := service.DB.WithContext(ctx)
|
||||
if id, found, err := replay(db); found || err != nil {
|
||||
return id, found, err
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, item.ShopeeProductID).Error; err != nil {
|
||||
return 0, false, serviceError("SHOPEE_PRODUCT_NOT_FOUND", "蝦皮商品不存在")
|
||||
}
|
||||
if shopee.PDDProductID != nil && !request.OverwriteLinked {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_ALREADY_LINKED", "商品已关联 PDD,已跳过")
|
||||
}
|
||||
var rule models.CollectionRule
|
||||
if err := db.First(&rule, request.RuleID).Error; err != nil {
|
||||
return 0, false, serviceError(CodeRuleNotFound, "采集规则不存在")
|
||||
}
|
||||
if err := ensureCurrentPageRule(rule.ContentJSON); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if request.DeviceID != nil {
|
||||
var target models.AgentDevice
|
||||
if err := db.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled {
|
||||
return 0, false, serviceError(CodeDeviceNotFound, "设备不存在或已停用")
|
||||
}
|
||||
if err := ensureTaskCompatible(target, models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: rule.ContentJSON}); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
ratio := int64(3)
|
||||
if value := strings.TrimSpace(os.Getenv("GOAUTO_IMAGE_SEARCH_MAX_PRICE_RATIO")); value != "" {
|
||||
var err error
|
||||
ratio, err = strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ratio < 1 || ratio > 100 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_CONFIG_INVALID", "图搜价格倍数配置须为 1~100 的整数")
|
||||
}
|
||||
}
|
||||
var representative models.SYBProduct
|
||||
var image ImageSearchImage
|
||||
fetch := service.FetchImageSearchImage
|
||||
if fetch == nil {
|
||||
fetch = fetchImageSearchImage
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_INPUT_CHANGED", "SYB 商品关联已变化,请刷新后重试")
|
||||
}
|
||||
if strings.TrimSpace(row.ImageURL) == "" {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
image, err = fetch(ctx, row.ImageURL)
|
||||
if err == nil && validImageMetadata(image) {
|
||||
representative = row
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if representative.ID == 0 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_IMAGE_INVALID", "参考图下载失败、格式不支持或超过限制")
|
||||
}
|
||||
referenceCurrency := strings.ToUpper(strings.TrimSpace(shopee.Currency))
|
||||
priceSkipped := referenceCurrency != "CNY"
|
||||
snapshot := ImageSearchSnapshot{ImageSearchImage: image, ShopeeProductID: shopee.ID, SYBProductIDs: item.SYBProductIDs, RepresentativeSYBProductID: representative.ID,
|
||||
OriginalPDDProductID: shopee.PDDProductID, ReferencePriceCent: representative.UnitPriceCent, ReferenceCurrency: referenceCurrency, MaxPriceRatio: ratio, RequestFingerprint: fingerprint,
|
||||
PriceGuardSkipped: priceSkipped}
|
||||
if priceSkipped {
|
||||
snapshot.PriceGuardSkipReason = "CROSS_CURRENCY_TWD_PDD_CNY"
|
||||
}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
stored := string(raw)
|
||||
var taskID uint64
|
||||
var replayed bool
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, shopee.ID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if id, found, err := replay(tx); found || err != nil {
|
||||
taskID, replayed = id, found
|
||||
return err
|
||||
}
|
||||
if !sameImageSearchLink(current.PDDProductID, shopee.PDDProductID) || current.Currency != shopee.Currency {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "蝦皮商品关联或币种已变化,请刷新后重试")
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID || id == representative.ID && (row.ImageURL != representative.ImageURL || row.UnitPriceCent != representative.UnitPriceCent) {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "参考图片或价格已变化,请刷新后重试")
|
||||
}
|
||||
}
|
||||
var active []models.CollectionTask
|
||||
if err := tx.Where("source = ? AND status IN ?", models.CollectionTaskSourceImageSearch, []string{models.TaskStatusPending, models.TaskStatusRunning}).Find(&active).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
for _, candidate := range active {
|
||||
other, err := loadImageSearchSnapshot(candidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if other.ShopeeProductID == shopee.ID {
|
||||
return serviceError("IMAGE_SEARCH_TASK_ACTIVE", "该蝦皮商品已有未结束的图搜任务")
|
||||
}
|
||||
}
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusPending, RuleID: rule.ID, RuleSnapshot: rule.ContentJSON, DeviceID: request.DeviceID, CreateRequestID: &requestID, ImageSearchSnapshot: &stored}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
taskID = record.ID
|
||||
return nil
|
||||
})
|
||||
return taskID, replayed, err
|
||||
}
|
||||
|
||||
func sameImageSearchLink(left, right *uint64) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func taskCompatible(record models.AgentDevice, task models.CollectionTask) (bool, error) {
|
||||
if task.Source == models.CollectionTaskSourceImageSearch {
|
||||
ok, err := device.Supports(record, []string{ImageSearchCapability})
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return ruleCompatible(record, task.RuleSnapshot)
|
||||
}
|
||||
|
||||
func ensureTaskCompatible(record models.AgentDevice, task models.CollectionTask) error {
|
||||
ok, err := taskCompatible(record, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务所需能力,请更新 Agent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// writes an association when the task was a complete collection, the frozen
|
||||
// original association is unchanged, and the target product is active. Manual
|
||||
// changes made while the Agent was running win by the CAS predicate.
|
||||
func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) error {
|
||||
var task models.CollectionTask
|
||||
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.PriceGuardSkipped {
|
||||
return nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
// PDD products are RMB in the current contract; unlike-currency snapshots
|
||||
// must remain available for manual review and can never auto-link.
|
||||
if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" {
|
||||
return nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).
|
||||
Where("id = ? AND pdd_product_id IS NULL AND image_search_linked = ?", product.ID, false).
|
||||
Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestImageSearchPriceAllowedRefusesCrossCurrency(t *testing.T) {
|
||||
snapshot := ImageSearchSnapshot{ReferencePriceCent: 1000, ReferenceCurrency: "TWD", MaxPriceRatio: 3}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "CNY", 2000); allowed || comparable {
|
||||
t.Fatalf("cross-currency price must not be compared: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3000); !allowed || !comparable {
|
||||
t.Fatalf("same-currency price within ratio should pass: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3001); allowed || !comparable {
|
||||
t.Fatalf("same-currency price over ratio should fail: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSearchPayloadOnlyContainsImageMetadata(t *testing.T) {
|
||||
secret := `{"shopeeProductId":99,"representativeSybProductId":1,"maxPriceRatio":3,"referenceCurrency":"TWD","imageUrl":"https://example.invalid/ref.jpg","mediaType":"image/jpeg","sizeBytes":10,"sha256":"0123456789012345678901234567890101234567890123456789012345678901"}`
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: `{}`, ImageSearchSnapshot: &secret}
|
||||
payload, err := NewService(nil).payload(record, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := string(payload.ImageSearch)
|
||||
if raw == "" || containsAny(raw, "shopeeProductId", "referenceCurrency") {
|
||||
t.Fatalf("internal snapshot fields leaked in Agent payload: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
manualPDD := models.PDDProduct{GoodsID: "manual-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=manual-pdd", Status: "active"}
|
||||
autoPDD := models.PDDProduct{GoodsID: "auto-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=auto-pdd", Status: "active"}
|
||||
if err := db.Create(&manualPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&autoPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "auto-link-test", Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: &manualPDD.ID}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "image-search-test", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := ImageSearchSnapshot{ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
task := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, PDDProductID: &autoPDD.ID, RuleID: rule.ID, RuleSnapshot: "{}", ImageSearchSnapshot: func() *string { v := string(raw); return &v }()}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewService(db).autoLinkImageSearch(context.Background(), task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.ShopeeProduct
|
||||
if err := db.First(&saved, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.PDDProductID == nil || *saved.PDDProductID != manualPDD.ID || saved.ImageSearchLinked {
|
||||
t.Fatalf("manual association was overwritten: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
|
||||
if err := service.activatePersistedReplacement(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
if err := service.autoLinkImageSearch(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
detail, err := service.Detail(ctx, taskID)
|
||||
detail.Replayed = replayed
|
||||
return detail, err
|
||||
|
||||
@@ -31,6 +31,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.GET("", handler.AdminList)
|
||||
admin.POST("", handler.AdminCreate)
|
||||
admin.POST("/batch", handler.AdminBatchCreate)
|
||||
admin.POST("/image-search/batch", handler.AdminBatchCreateImageSearch)
|
||||
admin.GET("/:taskId", handler.AdminDetail)
|
||||
admin.POST("/:taskId/reset", handler.AdminReset)
|
||||
admin.DELETE("/:taskId", handler.AdminDelete)
|
||||
|
||||
@@ -60,6 +60,7 @@ type TaskPayload struct {
|
||||
ReplacementOriginType string `json:"replacementOriginType,omitempty"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
|
||||
ImageSearch json.RawMessage `json:"imageSearch,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
LeaseVersion uint64 `json:"leaseVersion"`
|
||||
@@ -74,6 +75,7 @@ type Service struct {
|
||||
TaskTimeout int
|
||||
ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error)
|
||||
ColorImageDirectory string
|
||||
FetchImageSearchImage func(context.Context, string) (ImageSearchImage, error)
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
@@ -107,7 +109,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now).
|
||||
Order("created_at ASC, id ASC").First(&record).Error
|
||||
if err == nil {
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, payloadErr := service.payload(record, false)
|
||||
@@ -124,7 +126,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
return nil, internalError(err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot)
|
||||
compatible, compatibilityErr := taskCompatible(deviceRecord, candidate)
|
||||
if compatibilityErr != nil {
|
||||
return nil, compatibilityErr
|
||||
}
|
||||
@@ -167,7 +169,7 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
now := service.Now()
|
||||
@@ -235,7 +237,7 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "任务当前状态不能开始")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
@@ -286,6 +288,17 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
if !json.Valid(rule) {
|
||||
return TaskPayload{}, internalError(errors.New("stored rule snapshot is invalid JSON"))
|
||||
}
|
||||
var imageSearch json.RawMessage
|
||||
if record.ImageSearchSnapshot != nil && *record.ImageSearchSnapshot != "" {
|
||||
snapshot, snapshotErr := loadImageSearchSnapshot(record)
|
||||
if snapshotErr != nil {
|
||||
return TaskPayload{}, snapshotErr
|
||||
}
|
||||
imageSearch, _ = json.Marshal(snapshot.ImageSearchImage)
|
||||
if !json.Valid(imageSearch) {
|
||||
return TaskPayload{}, internalError(errors.New("stored image search snapshot is invalid JSON"))
|
||||
}
|
||||
}
|
||||
timeout := service.TaskTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTaskTimeout
|
||||
@@ -295,6 +308,7 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot,
|
||||
Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType),
|
||||
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
|
||||
ImageSearch: imageSearch,
|
||||
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
|
||||
Status: record.Status, Replayed: replayed,
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterTable)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterTable creates syb_product_filter and seeds its rules
|
||||
// for #269.
|
||||
//
|
||||
// `[必须]` Adding the model to migrations.Migrate only covers databases created
|
||||
// from scratch. Every existing database already has the older version numbers
|
||||
// recorded in sys_migration, so that list never runs again there — a new model
|
||||
// needs its own version file, which is what the post-migration table check in
|
||||
// cmd/migrate enforces.
|
||||
//
|
||||
// The seed is deliberately the same set the fresh-database path writes, created
|
||||
// with FirstOrCreate on the normalized key so running both paths against one
|
||||
// database cannot produce duplicates.
|
||||
func migrateSYBProductFilterTable(db *gorm.DB, version string) error {
|
||||
if err := db.AutoMigrate(&models.SYBProductFilter{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 结构判据 is what defines a 档口 product; the keywords are the forward
|
||||
// safety net for the day those codes stop carrying "#" (#269).
|
||||
seed := []struct{ kind, keyword string }{
|
||||
{"char", "-"},
|
||||
{"char", "#"},
|
||||
{"keyword", "CVC短袖220斤"},
|
||||
{"keyword", "180克純棉圓領短袖"},
|
||||
{"keyword", "拼色純棉短袖"},
|
||||
{"keyword", "300斤牛奶絲圓領"},
|
||||
{"keyword", "印花大學T"},
|
||||
{"keyword", "印花帽T"},
|
||||
}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{
|
||||
Kind: item.kind,
|
||||
Keyword: item.keyword,
|
||||
NormalizedKeyword: norm,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := tx.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).
|
||||
FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterMenu)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterMenu adds the 「SYB 商品过滤」 menu entry for #269.
|
||||
//
|
||||
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
|
||||
// page reachable. Menus are written by the versioned migration that first
|
||||
// created them, and that version is already recorded in sys_migration on every
|
||||
// existing database — it never runs again. Startup only reconciles the casbin
|
||||
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
|
||||
// migration the page exists, the API answers and the route resolves, yet nobody
|
||||
// can navigate to it on an already-migrated database.
|
||||
func migrateSYBProductFilterMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
module, ok := goAutoModuleByKey(access.ModuleSYBProductFilters)
|
||||
if !ok {
|
||||
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", access.ModuleSYBProductFilters)
|
||||
}
|
||||
|
||||
// The page belongs beside 「SYB 店铺」 under 采采管理, which is where the
|
||||
// admin already manages which SYB data enters the system.
|
||||
var parent migrationmodels.SysMenu
|
||||
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
|
||||
MenuName: module.RouteName,
|
||||
Title: module.Title,
|
||||
Icon: module.Icon,
|
||||
Path: module.Path + "/index",
|
||||
MenuType: "C",
|
||||
Action: "无",
|
||||
ParentId: parent.MenuId,
|
||||
Component: module.Component,
|
||||
Sort: module.Sort,
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind to both roles the module declares. 采购员 needs it because the
|
||||
// module is PurchaserDefault; 管理员 needs it because only an admin may
|
||||
// change the rules (the write routes are RequireRoleKey("admin")).
|
||||
roleKeys := []string{"admin"}
|
||||
if module.PurchaserDefault {
|
||||
roleKeys = append(roleKeys, access.RolePurchaser)
|
||||
}
|
||||
for _, key := range roleKeys {
|
||||
var role migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", key).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func goAutoModuleByKey(key string) (access.ModuleDefinition, bool) {
|
||||
for _, module := range access.GoAutoModules() {
|
||||
if module.Key == key {
|
||||
return module, true
|
||||
}
|
||||
}
|
||||
return access.ModuleDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() { migration.Migrate.SetVersion("1789200000000", migrateSYBParseStatusBinary) }
|
||||
|
||||
// migrateSYBParseStatusBinary folds legacy uncertain rows into failed and
|
||||
// narrows the check constraint to the two supported states (#272).
|
||||
func migrateSYBParseStatusBinary(db *gorm.DB, version string) error {
|
||||
if err := db.Model(&models.SYBProduct{}).Where("parse_status = ?", "uncertain").Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if err := db.Exec("ALTER TABLE syb_product DROP CHECK ck_syb_product_parse_status").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE syb_product ADD CONSTRAINT ck_syb_product_parse_status CHECK (parse_status IN ('success','failed'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchCollection)
|
||||
}
|
||||
|
||||
// #278 permits image-search tasks while preserving existing task rows.
|
||||
func migrateImageSearchCollection(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&models.CollectionTask{}, "ImageSearchSnapshot") {
|
||||
if err := tx.Migrator().AddColumn(&models.CollectionTask{}, "ImageSearchSnapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() == "mysql" {
|
||||
// MySQL does not support ALTER CHECK in place. The migration runner
|
||||
// records this version once, so dropping/recreating the named check is
|
||||
// deterministic and does not trigger a whole-schema AutoMigrate.
|
||||
_ = tx.Exec("ALTER TABLE collection_task DROP CHECK ck_collection_task_source").Error
|
||||
if err := tx.Exec("ALTER TABLE collection_task ADD CONSTRAINT ck_collection_task_source CHECK (source IN ('admin','agent_current_page','image_search'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchLinkMarker)
|
||||
}
|
||||
|
||||
// #279 stores whether the current Shopee→PDD link was created by image search.
|
||||
func migrateImageSearchLinkMarker(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import request from '@/utils/request'
|
||||
export function listCollectionTasks(params) { return request({ url: '/api/admin/v1/collection-tasks', method: 'get', params }) }
|
||||
export function createCollectionTask(data) { return request({ url: '/api/admin/v1/collection-tasks', method: 'post', data }) }
|
||||
export function batchCreateCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch', method: 'post', data }) }
|
||||
export function batchCreateImageSearchCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/image-search/batch', method: 'post', data }) }
|
||||
export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) }
|
||||
export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) }
|
||||
export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
const base = '/api/admin/v1/syb-product-filters'
|
||||
export function listSybProductFilters(params) { return request({ url: base, method: 'get', params }) }
|
||||
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
|
||||
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
|
||||
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
|
||||
@@ -13,7 +13,7 @@
|
||||
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
|
||||
<div class="drawer-actions"><div class="drawer-status"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-tag v-if="detail.product.imageSearchLinked" type="success" size="small">图搜自动关联</el-tag></div><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border :inert="detail.aiMatching">
|
||||
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
|
||||
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
|
||||
<div class="image-search-filter"><span class="muted">图搜关联</span><el-select v-model="query.imageSearchLinked" size="small" style="width:150px" @change="search"><el-option label="全部" :value="null" /><el-option label="仅图搜关联" :value="true" /><el-option label="非图搜关联" :value="false" /></el-select></div>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="图搜" width="90"><template #default="{ row }"><el-tag v-if="row.imageSearchLinked" type="success" size="small">已自动关联</el-tag><span v-else class="muted">—</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
|
||||
</el-card>
|
||||
|
||||
@@ -29,7 +30,7 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '', imageSearchLinked: null }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
|
||||
@@ -53,3 +54,4 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="load">
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="query.keyword" clearable placeholder="字符或关键词" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!isAdmin"
|
||||
title="当前账号只有查看权限。新增、启用、停用和删除关键词需要管理员操作。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<p class="muted">这两个字符是识别档口商品的判据,命中即不入库。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
|
||||
<el-table-column label="字符" prop="keyword" width="180" />
|
||||
<el-table-column label="状态" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}结构过滤状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近变更" min-width="240">
|
||||
<template #default="{ row }">{{ changeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<!-- 结构规则不可增删(#269):这里必须是纯文字,不能是看起来可点的按钮。 -->
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default><span class="disabled-text">不可删除</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="keyword-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>关键词过滤</h3>
|
||||
<p class="muted">在结构过滤之后执行,命中数为结构过滤未覆盖的部分。</p>
|
||||
</div>
|
||||
<el-tooltip :disabled="isAdmin" content="只有管理员可以配置关键词" placement="bottom">
|
||||
<span>
|
||||
<el-button type="primary" :disabled="!isAdmin" @click="openEditor">新增关键词</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="keywords"
|
||||
row-key="id"
|
||||
border
|
||||
stripe
|
||||
empty-text="还没有配置关键词过滤。上方结构过滤仍然生效。"
|
||||
>
|
||||
<el-table-column label="关键词" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="primary">{{ row.keyword }}</div>
|
||||
<div class="muted">匹配时忽略首尾空白、全半角和大小写</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template #default="{ row }">{{ row.note || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isAdmin" type="danger" link @click="remove(row)">删除</el-button>
|
||||
<span v-else class="muted">只读</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。系统会去掉首尾空白、统一全半角并忽略大小写;「CVC短袖220斤」「CVC短袖220斤」「cvc短袖220斤」视为同一条,重复添加会被拒绝。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="editor.keyword" maxlength="200" show-word-limit placeholder="请输入关键词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注(可空)">
|
||||
<el-input v-model="editor.note" maxlength="200" placeholder="添加理由,可空" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="editor.saving" @click="editor.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
listSybProductFilters,
|
||||
createSybProductFilter,
|
||||
setSybProductFilterEnabled,
|
||||
deleteSybProductFilter
|
||||
} from '@/api/goauto/syb-product-filters'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProductFilters',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
chars: [],
|
||||
keywords: [],
|
||||
query: { keyword: '' },
|
||||
editor: { open: false, saving: false, keyword: '', note: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return (this.$store.getters.roles || []).includes('admin')
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await listSybProductFilters({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
keyword: this.query.keyword || undefined
|
||||
})
|
||||
const rows = (res.data.items || []).map(row => ({ ...row, updating: false }))
|
||||
this.chars = rows.filter(row => row.kind === 'char')
|
||||
this.keywords = rows.filter(row => row.kind === 'keyword')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.query = { keyword: '' }
|
||||
this.load()
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
},
|
||||
// 命中数由同步回写;从未同步过时不要显示 0,那会被读成「一条都没命中」。
|
||||
hitText(row) {
|
||||
return row.lastHitCount == null ? '等待同步记录' : `${row.lastHitCount} 条`
|
||||
},
|
||||
changeText(row) {
|
||||
if (!row.lastChangedAt) return '尚未记录'
|
||||
const who = row.lastChangedBy || '-'
|
||||
return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}`
|
||||
},
|
||||
// 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。
|
||||
// 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。
|
||||
async confirmDisableChar(row) {
|
||||
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
|
||||
await ElMessageBox.confirm(
|
||||
`停用「${row.keyword}」后,SYB 同步将不再过滤含 ${row.keyword} 的档口商品。按最近一次同步数据估算,${scale}明细会恢复入库。`,
|
||||
`确认停用结构过滤「${row.keyword}」?`,
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
},
|
||||
async toggle(row) {
|
||||
const target = row.enabled
|
||||
if (row.kind === 'char' && !target) {
|
||||
try {
|
||||
await this.confirmDisableChar(row)
|
||||
} catch (e) {
|
||||
row.enabled = true
|
||||
return
|
||||
}
|
||||
}
|
||||
row.updating = true
|
||||
try {
|
||||
await setSybProductFilterEnabled(row.id, { enabled: target })
|
||||
ElMessage.success(target ? '规则已启用' : '规则已停用')
|
||||
} catch (e) {
|
||||
row.enabled = !target
|
||||
throw e
|
||||
} finally {
|
||||
row.updating = false
|
||||
}
|
||||
},
|
||||
openEditor() {
|
||||
this.editor.open = true
|
||||
},
|
||||
resetEditor() {
|
||||
this.editor = { open: false, saving: false, keyword: '', note: '' }
|
||||
},
|
||||
async save() {
|
||||
const keyword = this.editor.keyword.trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请输入关键词')
|
||||
return
|
||||
}
|
||||
this.editor.saving = true
|
||||
try {
|
||||
await createSybProductFilter({ kind: 'keyword', keyword, note: this.editor.note || undefined })
|
||||
ElMessage.success('关键词已添加')
|
||||
this.editor.open = false
|
||||
this.load()
|
||||
} finally {
|
||||
this.editor.saving = false
|
||||
}
|
||||
},
|
||||
async remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除关键词「${row.keyword}」?`, '删除关键词', { type: 'warning' })
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
await deleteSybProductFilter(row.id)
|
||||
ElMessage.success('关键词已删除')
|
||||
this.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card {
|
||||
min-height: calc(100vh - 124px);
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.primary {
|
||||
color: #303133;
|
||||
}
|
||||
.muted,
|
||||
.scope-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.disabled-text {
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.keyword-section {
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.scope-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,11 +10,12 @@
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集 <span class="action-count">{{ imageSearchRows.length }}</span></el-button>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -115,6 +116,12 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建 PDD 采集任务 -->
|
||||
<el-dialog v-model="imageSearchBatch.open" :title="imageSearchBatch.step === 'confirm' ? '批量图搜采集' : '图搜采集结果'" width="760px" :close-on-click-modal="false" @closed="resetImageSearchBatch">
|
||||
<template v-if="imageSearchBatch.step === 'confirm'"><el-alert title="每个蝦皮商品只创建一个图搜采集任务;参考图由服务端校验,已关联商品默认跳过。" type="info" :closable="false" show-icon class="notice" /><el-form label-position="top"><el-form-item label="采集规则"><el-select v-model="imageSearchBatch.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in imageSearchBatch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="Android 设备"><el-select v-model="imageSearchBatch.deviceId" clearable style="width:100%" placeholder="不指定,由支持图搜的空闲设备领取"><el-option v-for="item in imageSearchBatch.options.devices" :key="item.id" :label="`${item.name} · ${item.model}`" :value="item.id" /></el-select></el-form-item><el-checkbox v-model="imageSearchBatch.overwriteLinked">覆盖已有关联</el-checkbox></el-form><el-table :data="imageSearchBatch.rows" border size="small" max-height="300"><el-table-column label="订单号" prop="orderCode" width="160" /><el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="180" /><el-table-column label="参考图" min-width="260"><template #default="{ row }"><el-image :src="row.imageUrl" fit="cover" style="width:48px;height:48px" /><span class="muted">{{ row.imageUrl }}</span></template></el-table-column></el-table></template>
|
||||
<template v-else><el-alert :title="`完成:成功 ${imageSearchBatch.successCount},失败 ${imageSearchBatch.failureCount},跳过 ${imageSearchBatch.skippedCount}`" :type="imageSearchBatch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" /><el-table :data="imageSearchBatch.results" border size="small" max-height="340"><el-table-column label="蝦皮商品" prop="shopeeProductId" width="130" /><el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.success ? 'success' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? 'info' : 'danger'">{{ row.success ? '已创建' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? '已跳过' : '失败' }}</el-tag></template></el-table-column><el-table-column label="任务 / 原因" min-width="260"><template #default="{ row }">{{ row.success ? `任务 #${row.taskId}` : row.message }}</template></el-table-column></el-table></template>
|
||||
<template #footer><el-button @click="imageSearchBatch.open = false">{{ imageSearchBatch.step === 'confirm' ? '取消' : '关闭' }}</el-button><el-button v-if="imageSearchBatch.step === 'confirm'" type="primary" :loading="imageSearchBatch.saving" :disabled="!imageSearchBatch.ruleId || !imageSearchBatch.rows.length" @click="submitImageSearchBatch">创建图搜任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="collectionBatch.open" :title="collectionBatch.step === 'confirm' ? '批量创建 PDD 采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetCollectionBatch">
|
||||
<template v-if="collectionBatch.step === 'confirm'">
|
||||
<el-alert title="每个去重后的 PDD 商品创建一个独立采集任务,不会创建采购任务或订单。" type="info" :closable="false" show-icon class="notice" />
|
||||
@@ -160,8 +167,8 @@
|
||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.item">
|
||||
<el-alert v-if="detail.item.parseStatus !== 'success'" :title="detail.item.parseStatus === 'failed' ? '解析失败:' + detail.item.parseNote : '解析存疑:' + detail.item.parseNote" :type="detail.item.parseStatus === 'failed' ? 'error' : 'warning'" :closable="false" show-icon class="notice" />
|
||||
<p class="muted purchase-note">存疑不阻断采购:采购时由服务端 AI 拿 SYB 原始颜色尺码匹配 PDD 实际规格,不经过虾皮档案映射表。人工修正可提高匹配准确度,但不是采购的前置条件;失败必须处理,否则采购会明确失败。</p>
|
||||
<el-alert v-if="detail.item.parseStatus !== 'success'" :title="'解析失败:' + detail.item.parseNote" type="error" :closable="false" show-icon class="notice" />
|
||||
<p class="muted purchase-note">解析失败时由服务端 AI 根据 SYB 原始颜色尺码匹配 PDD 实际规格;解析失败不会自动写入虾皮档案映射。</p>
|
||||
|
||||
<div class="split">
|
||||
<div class="split-col">
|
||||
@@ -219,7 +226,7 @@ import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct }
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
@@ -242,6 +249,7 @@ export default {
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
|
||||
collectionBatch: this.emptyCollectionBatch(),
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
@@ -266,6 +274,7 @@ export default {
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
|
||||
formattedRaw() {
|
||||
if (!this.detail.item) return ''
|
||||
@@ -358,7 +367,7 @@ export default {
|
||||
}
|
||||
},
|
||||
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
openShopeeDetail(shopeeProductId, targetColor = '', action = '') {
|
||||
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
|
||||
this.shopeeDetail = { open: true, productId: Number(shopeeProductId), targetColor: String(targetColor || '').trim(), action: String(action || '') }
|
||||
@@ -459,6 +468,28 @@ export default {
|
||||
|
||||
// ---------------- 批量创建 PDD 采集任务 ----------------
|
||||
emptyCollectionBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } },
|
||||
emptyImageSearchBatch() { return { open: false, saving: false, step: 'confirm', rows: [], options: { rules: [], devices: [] }, ruleId: null, deviceId: null, overwriteLinked: false, results: [], successCount: 0, failureCount: 0, skippedCount: 0 } },
|
||||
async openImageSearchBatch() {
|
||||
if (!this.imageSearchRows.length) return
|
||||
this.imageSearchBatch = { ...this.emptyImageSearchBatch(), open: true, rows: [...this.imageSearchRows], deviceId: readPurchaseDevice(this.$store.getters.userId) }
|
||||
const [rules, devices] = await Promise.all([listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })])
|
||||
this.imageSearchBatch.options = { rules: rules.data.items || [], devices: devices.data.items || [] }
|
||||
},
|
||||
resetImageSearchBatch() { this.imageSearchBatch = this.emptyImageSearchBatch() },
|
||||
async submitImageSearchBatch() {
|
||||
if (this.imageSearchBatch.saving || !this.imageSearchBatch.ruleId) return
|
||||
this.imageSearchBatch.saving = true
|
||||
try {
|
||||
if (this.imageSearchBatch.deviceId) rememberPurchaseDevice(this.$store.getters.userId, this.imageSearchBatch.deviceId)
|
||||
const response = await batchCreateImageSearchCollectionTasks({ requestId: createRequestId(), sybProductIds: this.imageSearchBatch.rows.map(row => row.id), ruleId: this.imageSearchBatch.ruleId, deviceId: this.imageSearchBatch.deviceId || null, overwriteLinked: this.imageSearchBatch.overwriteLinked })
|
||||
this.imageSearchBatch.results = response.data.items || []
|
||||
this.imageSearchBatch.successCount = response.data.successCount || 0
|
||||
this.imageSearchBatch.failureCount = response.data.failureCount || 0
|
||||
this.imageSearchBatch.skippedCount = response.data.skippedCount || 0
|
||||
this.imageSearchBatch.step = 'result'
|
||||
await this.load()
|
||||
} finally { this.imageSearchBatch.saving = false }
|
||||
},
|
||||
collectionBatchProducts() {
|
||||
const products = new Map()
|
||||
for (const row of this.collectionCandidateRows) {
|
||||
@@ -575,3 +606,4 @@ export default {
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="符合店铺">{{ detail.item.acceptedCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过店铺">{{ detail.item.shopSkipped }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="结构过滤命中">{{ detail.item.charFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="关键词过滤命中">{{ detail.item.keywordFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="新增 / 覆盖">{{ detail.item.created }} / {{ detail.item.updated }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ detail.item.operatorName || `用户 ${detail.item.operatorId || '-'}` }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ formatTime(detail.item.startedAt) }}</el-descriptions-item>
|
||||
|
||||
Reference in New Issue
Block a user