Compare commits

..
Author SHA1 Message Date
QiuSW a16e6920b7 docs(#277): record sanitized pdd home evidence 2026-09-15 10:07:27 +08:00
QiuSW def0a86f63 fix(#277): fail image search tasks explicitly until automation is available 2026-09-15 09:59:56 +08:00
QiuSW ba7d2a6b72 feat(#280): remember image search device 2026-09-15 09:57:17 +08:00
QiuSW a3cf98940a fix(#279): only auto-link active pdd products 2026-09-15 09:51:39 +08:00
QiuSW 56706a0b45 fix(#280): show image search association marker 2026-09-15 09:45:50 +08:00
QiuSW a4874091a8 feat: prepare validated image search media asset (#277) 2026-09-15 09:42:25 +08:00
QiuSW 9e0dcc254e feat: parse image search task metadata in agent (#277) 2026-09-15 09:40:51 +08:00
QiuSW cda1978bc6 test: prevent image snapshot payload leakage (#278) 2026-09-15 09:37:01 +08:00
QiuSW c80d0c91d5 feat: add image search batch dialog to syb products (#280) 2026-09-15 09:07:45 +08:00
QiuSW be491c7692 feat: expose image search batch API to web (#280) 2026-09-15 09:02:08 +08:00
QiuSW 90c1668fe3 test: cover image search auto-link guards (#279) 2026-09-15 09:01:10 +08:00
QiuSW 804448ede0 feat: show image search link marker in shopee detail (#279) 2026-09-15 08:59:37 +08:00
QiuSW 5a7e219230 feat: add image-search linked filter to shopee list (#279) 2026-09-15 08:58:35 +08:00
QiuSW 36d1f82ce2 feat: filter image-search linked shopee products (#279) 2026-09-15 08:53:37 +08:00
QiuSW 1c998911be feat: auto link completed image search results (#279) 2026-09-15 08:50:19 +08:00
QiuSW 45437a9214 feat: record cross-currency image search price guard (#278) 2026-09-15 08:44:39 +08:00
QiuSW 42b5312198 feat: support image search identity handoff and safe payload (#278) 2026-09-15 08:42:11 +08:00
QiuSW 7b540f3163 feat: create and dispatch image search tasks (#278) 2026-09-15 08:41:42 +08:00
QiuSW ffa5fc0710 fix: add idempotent image snapshot column migration (#278) 2026-09-14 18:02:22 +08:00
QiuSW 66ab36c88c feat: expose image search snapshot in task payload (#278) 2026-09-14 16:49:35 +08:00
QiuSW 35b69fc25a fix: narrow image search migration (#278) 2026-09-14 16:31:07 +08:00
QiuSW b4dc7fc9ae feat: track image search links and preserve manual overrides (#279) 2026-09-14 16:08:55 +08:00
QiuSW baa9408a36 feat: allow image search collection task source (#278) 2026-09-14 16:05:01 +08:00
QiuSW e01fcd9860 feat: request media permission for image search (#276) 2026-09-14 16:03:21 +08:00
QiuSW be03972779 merge: #283 purchase readiness consistency 2026-09-14 14:48:13 +08:00
QiuSW c137f5072f fix: keep purchase readiness consistent with confirmed mappings (#283) 2026-09-14 14:42:11 +08:00
QiuSW 82b4ebd406 feat(ops): add batch SYB failed reparse script (#282) 2026-09-14 11:34:36 +08:00
QiuSW b83f20c6b7 fix(syb): accept descriptive color specs (#274) 2026-09-14 10:52:02 +08:00
QiuSW e0ed05d15a fix(syb): accept reliable single spec dimension (#274) 2026-09-12 12:01:15 +08:00
QiuSW 8163dd10eb fix(ai): match size by standard token (#273) 2026-09-12 11:46:46 +08:00
QiuSW aed295c8c1 fix(syb): reduce parse status to success or failed (#272) 2026-09-12 11:36:43 +08:00
QiuSWandClaude Opus 5 c14f5f528b fix(server): keep agent diagnostics on order-check failures #271
normalizeOrderUnknownFailure replaced the agent's message with a canonical
sentence, so the diagnostic #210 added never reached the database. Across the
61 PURCHASE_ORDER_PAYMENT_REPEATED failures on production between 2026-09-07
and 09-11, not one carried paymentBackAttempts or
consecutivePaymentSamplesAfterBack, which left three very different causes
indistinguishable: the Back never left the payment activity, the page rendered
slower than the ~1.1s window the agent allows, or the page was already the
order page and no marker matched. Those need different fixes, and there was no
way to tell which one to make.

The canonical sentence stays authoritative and leads the message — agent text
is free-form and the wording per error code has to stay stable — but the agent
evidence is now appended behind it, flattened to one line (control characters
and U+FEFF removed, whitespace collapsed) and truncated to the column's 1000
runes with the canonical part kept whole. The sibling "failed" branch already
stored agent text verbatim, so this adds no new trust assumption.

That branch also only wrote the failure onto the task, never the attempt, so
every attempt it produced carried a NULL error_code and was invisible to
attempt-level statistics — the spec-panel failures on production are exactly
that. It now writes both.

This restores observability only; it does not reduce how often
PAYMENT_REPEATED happens. Tuning the agent's sampling window is deliberately
left out until the next production failure shows real values.

Wiki updated first: Android-Agent-API-Contract@ba259178.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-12 11:13:02 +08:00
QiuSW 8293b27c19 fix(syb): retry drifting sync page once (#270) 2026-09-12 09:34:44 +08:00
QiuSWandClaude Opus 5 c24e8a4439 docs: refresh stale wiki mirrors picked up by the same sync run
Unrelated to #269. These three pages were changed online by other work and
the local mirror had drifted (02 and 03 from 2026-09-10, 06 from 2026-09-04).
The sync that mirrored the #269 pages regenerates every mapping, so it brought
these along; they are committed separately to keep the ticket's own commit
limited to its files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 40a254dc41 docs: mirror SYB product filter rules and admin API #269
Wiki updated online first, then mirrored:
- SYB-ERP-Interface-Contract@f1792b7f — filter order after shop admission,
  OR-not-AND with the 8498/6392/6386/8504 figures from the 2026-09-11
  production check, empty variationSku kept, per-rule hit counts, and the
  no-add/no-delete rule for structural criteria.
- Android-Agent-API-Contract@38869285 — the syb-product-filters endpoints,
  the server-side refusal of char add/delete, and the live lastHitCount
  requirement for the disable confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 503b3cbbc5 fix(goauto): create syb_product_filter on existing databases #269
Three defects that only a real MySQL run could show.

The table had no version-local migration. Adding the model to
migrations.Migrate covers databases built from scratch, but every existing
one already has the older versions recorded in sys_migration, so that list
never runs again there. cmd/migrate's post-migration table check caught it:
"迁移后仍缺少表:syb_product_filter". The new version file creates the table
and seeds the same eight rules with FirstOrCreate on the normalized key, so
running both paths against one database cannot duplicate them.

Keyword and NormalizedKeyword were typed text, which MySQL refuses to put in
a key specification without a prefix length (Error 1170), so the unique index
could not be built at all. They are sized 200 now, matching how SYBShop sizes
its own matching key. SQLite accepts text in an index, which is why the whole
test suite passed while the real migration failed.

The 只看启用 filter is removed rather than kept: hiding a disabled structural
rule would conceal the one state on this page that matters most, since a
disabled "#" means roughly 65% of 档口 rows start entering the system again.
Keyword search stays.

Verified against the local MySQL database: migration applies, eight rules
present with traditional Chinese preserved (印花大學T normalizes to 印花大學t),
menu row created under 采采管理.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:00:11 +08:00
QiuSWandClaude Opus 5 cad9f6d185 fix(goauto): add the missing menu migration for SYB product filters #269
Registering a module in access.GoAutoModules() does not make its page
reachable on an existing database. sys_menu rows are written by the
versioned migration that first created them, and that version is already
recorded in sys_migration everywhere — it never runs again. Startup only
reconciles the casbin API matrix, not menus.

So on a fresh database the page appeared and on every real one it did not:
the table, the API and the route were all present with no way to navigate
there. This adds a migration that upserts the entry under 采采管理 beside
「SYB 店铺」 and binds it to both 管理员 and 采购员.

Verified by deleting the row from an already-migrated database and
re-running migrate: 新执行 1 个,跳过 42 个, row restored with both roles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:49:56 +08:00
QiuSWandClaude Opus 5 78df997fc3 test(goauto): pin SYB filter behaviour against real variationSku samples #269
The samples are the ones the production database actually holds: 档口 codes
like "新寮-雅伊阁大码女装#301" and "DD#004" must be filtered, while own
codes like "PDD0582", "my238", "1" and "引流款" must not, and an empty
variationSku is a product without a supplier code rather than a 档口 item.

The test also asserts the two structural rules record different hit counts.
That is the property the previous aggregate-per-kind write could not hold,
and the disable-confirmation dialog depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:37:43 +08:00
QiuSWandClaude Opus 5 20f59b68bd fix(goauto): record SYB filter hits per rule, not per kind #269
UpdateHits wrote one aggregate per kind, so every structural rule stored
the same number. The disable-confirmation dialog quotes that number
("停用「#」后…约 N 条明细会恢复入库"), and on real data "#" matches 8498
rows while "-" matches 6392 — the dialog would have warned with a figure
2100 rows off for one of them.

Match now returns the rule that matched instead of a char/keyword pair,
so the caller can attribute each hit to one rule, and UpdateHits writes
every rule's own count. Rules that matched nothing are written as 0 so a
previous run's number is never left behind.

The management page is reformatted to the repo's usual Vue layout and
gains the search / 只看启用 / 重置 controls the approved prototype shows.
Cancelling the disable confirmation now just reverts the switch instead
of throwing an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:27:13 +08:00
QiuSW 652bf30f5a feat(goauto): add SYB product variation filters (#269) 2026-09-11 16:20:26 +08:00
QiuSW 79929036bb docs: clarify metadata-only partial collection eligibility #259 2026-09-10 16:10:13 +08:00
QiuSW e4677069d7 fix(syb): allow metadata-only partial collection links #259 2026-09-10 16:07:58 +08:00
QiuSW 8cfd32babe docs: record one-click replacement contract and boundaries #258 2026-09-10 15:52:03 +08:00
QiuSW 3181302c16 test: capture stable replacement confirmation states #258 2026-09-10 15:48:17 +08:00
QiuSW 2b01974fe5 feat(syb): confirm one-click replacement from latest collection #258 2026-09-10 15:47:27 +08:00
QiuSW 305062dba0 docs: record normalized color matching rule #257 2026-09-10 14:56:01 +08:00
QiuSW ea38ced58c fix(shopee): normalize colors before AI matching #257 2026-09-10 14:54:56 +08:00
QiuSW 2cb78ad8cd fix(collection): preserve task state during current-page reidentify #255 2026-09-10 14:26:39 +08:00
QiuSW ca997f6108 docs: record SYB quick-link behavior and API #253 2026-09-10 11:39:34 +08:00
QiuSW b1594dc7bd feat(syb): link latest device collection and match specs #253 2026-09-10 11:34:22 +08:00
QiuSW 08d939a9b8 docs: sync dynamic match timeout contract #254 2026-09-10 09:43:53 +08:00
QiuSW 9088e6b734 fix(shopee): bound matching waits and recover drawer loading #254 2026-09-10 09:37:02 +08:00
105 changed files with 4776 additions and 1943 deletions
+2
View File
@@ -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
}
}
@@ -194,39 +194,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(node, direction)
}
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
fun clickBackfill(target: SnapshotNode): Boolean {
val page = capture()
BackfillPagePolicy.validate(page)
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
it.bounds == target.bounds && it.className == target.className } ?: return false
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
val root = rootInActiveWindow ?: return false
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
var node = root
for (index in fresh.path.split('/').drop(1)) node = node.getChild(index.toInt()) ?: return false
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
fun scrollBackfill(): Boolean {
BackfillPagePolicy.validate(capture())
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { if (it.isScrollable && it.isVisibleToUser && it.isEnabled) candidates += it }
// Prefer the unique largest vertical viewport. Ambiguous panes fail closed.
val areas = candidates.map { it to Rect().also(it::getBoundsInScreen) }
val maxArea = areas.maxOfOrNull { it.second.width().toLong() * it.second.height() } ?: return false
val target = areas.filter { it.second.width().toLong() * it.second.height() == maxArea }.singleOrNull() ?: return false
return target.first.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}
override fun capture(): UiSnapshot {
val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList())
val rootPackage = root.packageName?.toString()
@@ -1,87 +0,0 @@
package cn.ilapage.goauto.agent.automation
import java.math.BigInteger
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/** Runtime evidence only: a sorted prefix cannot prove the unvisited tail is sorted. */
class OrderBackfillWindow(days: String, val confirmedAt: Long) {
val cutoff: Long
var checked = 0
private set
var nonDescending = false
private set
private var previous: Long? = null
private var missingTime = false
private var timed = 0
init {
require(days.matches(Regex("[0-9]+")) && BigInteger(days) > BigInteger.ZERO)
cutoff = BigInteger.valueOf(confirmedAt).subtract(BigInteger(days).multiply(BigInteger.valueOf(86_400_000)))
.max(BigInteger.valueOf(Long.MIN_VALUE)).toLong()
}
fun observe(time: Long?): Boolean {
checked++
if (time == null) missingTime = true else {
if (previous != null && time > previous!!) nonDescending = true
previous = time
timed++
}
// Check at least five details before using a sampled ordering assumption.
return !nonDescending && !missingTime && timed >= ORDERING_SAMPLE && time != null && time < cutoff
}
fun includes(time: Long?): Boolean = time == null || time in cutoff..confirmedAt
companion object {
const val ORDERING_SAMPLE = 5
const val MAX_ORDERS = 200
const val MAX_DURATION_MS = 10 * 60_000L
const val UNORDERED = "列表非严格倒序,已改为有界扫描,可能未覆盖全部"
}
}
data class BackfillItem(val addressSuffix: String, val pddOrderNo: String, val orderSubmittedAt: String?)
data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?)
/** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */
class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) {
private val suffixes = mutableSetOf<String>()
private val orders = mutableSetOf<String>()
private val times = mutableSetOf<Long>()
fun accept(text: String) {
SUFFIX.findAll(text).forEach { suffixes += it.value }
ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] }
ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) }
}
fun finish(): BackfillDetail {
val time = times.singleOrNull()
val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) {
BackfillItem(suffixes.single(), orders.single(), time?.let {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it)
})
} else null
suffixes.clear()
orders.clear()
times.clear()
return BackfillDetail(item, time)
}
private fun parseTime(raw: String): Long? {
val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm"
val position = java.text.ParsePosition(0)
val date = SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = zone }.parse(normalized, position)
return date?.time?.takeIf { position.index == normalized.length }
}
companion object {
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)")
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
}
}
@@ -1,140 +0,0 @@
package cn.ilapage.goauto.agent.automation
import java.security.MessageDigest
object BackfillPagePolicy {
const val PDD = "com.xunmeng.pinduoduo"
val forbidden = listOf("确认收货", "申请退款", "催发货", "去支付", "立即支付", "提交订单", "付款", "退款", "取消订单", "再次购买", "删除订单")
private val risk = listOf("验证码", "安全验证", "人机验证", "登录", "账号异常", "风险验证", "拖动滑块")
fun validate(page: UiSnapshot) {
require(page.packageName == PDD && page.activityName?.startsWith(PDD) == true) { "PDD 页面身份不符" }
require(page.nodes.none { it.visible && risk.any { word -> it.label.contains(word) } }) { "遇到登录或安全验证,已停止" }
}
private fun subtree(page: UiSnapshot, node: SnapshotNode) = page.nodes.filter { it.path == node.path || it.path.startsWith(node.path + "/") }
fun safe(page: UiSnapshot, node: SnapshotNode): Boolean = node.visible && node.enabled && node.clickable &&
node.bounds.width > 0 && node.bounds.height > 0 &&
subtree(page, node).none { child -> forbidden.any { child.label.contains(it) } } &&
page.nodes.none { other -> other.visible && forbidden.any { other.label.contains(it) } &&
other.bounds.left < node.bounds.right && other.bounds.right > node.bounds.left &&
other.bounds.top < node.bounds.bottom && other.bounds.bottom > node.bounds.top }
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
fun expansion(page: UiSnapshot): SnapshotNode? {
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
val snapshot = page.nodes.filter { it.visible && it.label == "商品快照" }.singleOrNull() ?: return null
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
}
fun cards(page: UiSnapshot): List<SnapshotNode> {
if (!list(page)) return emptyList()
return page.nodes.filter { node ->
if (!safe(page, node)) return@filter false
if (node.label in setOf("订单详情", "查看详情")) return@filter true
val children = subtree(page, node)
val product = children.any { it.className?.endsWith("ImageView") == true } && children.any { it.label.length >= 4 }
val parent = page.nodes.firstOrNull { it.path == node.parentPath } ?: return@filter false
val context = subtree(page, parent)
product && context.any { it.label in setOf("查看物流", "确认收货", "去支付", "待发货", "待收货", "交易成功", "再次购买") }
}.sortedBy { it.bounds.top }.let { candidates ->
candidates.filter { node -> candidates.none { it !== node && it.path.startsWith(node.path + "/") } }
}
}
fun fingerprint(page: UiSnapshot, card: SnapshotNode): String {
val labels = subtree(page, card).joinToString("|") { it.label }
return MessageDigest.getInstance("SHA-256").digest(labels.toByteArray()).joinToString("") { "%02x".format(it) }
}
}
interface BackfillDriver {
fun openOrders()
fun capture(): UiSnapshot
fun click(node: SnapshotNode): Boolean
fun scroll(): Boolean
fun back(): Boolean
fun pause()
}
class OrderBackfillScanner(
private val driver: BackfillDriver,
private val window: OrderBackfillWindow,
private val checkActive: () -> Unit,
private val submit: (BackfillItem) -> Unit,
private val progress: (Int) -> Unit,
) {
fun scan(): String {
checkActive()
driver.openOrders()
driver.pause()
var page = read()
if (!BackfillPagePolicy.list(page)) {
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
act { driver.click(tab) }
page = read()
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
}
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
val seenCards = mutableSetOf<String>()
val seenOrders = mutableSetOf<String>()
var noProgress = 0
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
checkActive()
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
if (card == null) {
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
checkActive()
if (!driver.scroll()) return finish("列表滚动结束或不可滚动,未完整扫描")
driver.pause()
page = read()
continue
}
noProgress = 0
seenCards += BackfillPagePolicy.fingerprint(page, card)
act { driver.click(card) }
var detail = read()
check(BackfillPagePolicy.detail(detail)) { "点击后未识别订单详情" }
val reader = BackfillDetailReader()
var expanded = false
// Limited detail scrolling; only this accumulator associates fields across these frames.
for (step in 0 until 6) {
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val expand = if (expanded) null else BackfillPagePolicy.expansion(detail)
if (expand != null) {
act { driver.click(expand) }
expanded = true
} else {
checkActive()
if (!driver.scroll()) break
driver.pause()
}
detail = read()
check(BackfillPagePolicy.detail(detail)) { "滚动后无法确认订单详情,未完整扫描" }
}
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val found = reader.finish()
val stopForTime = window.observe(found.timeMillis)
progress(window.checked)
found.item?.takeIf { window.includes(found.timeMillis) && seenOrders.add(it.pddOrderNo) }?.let(submit)
if (stopForTime) return "已达指定天数(已读序列倒序);未完整扫描,后续列表时序未经验证"
act { driver.back() }
page = read()
}
return finish("达到 200 单内部上限,未完整扫描")
}
private fun finish(reason: String): String = reason + if (window.nonDescending) "\n${OrderBackfillWindow.UNORDERED}" else ""
private fun read(): UiSnapshot {
checkActive()
return driver.capture().also(BackfillPagePolicy::validate)
}
private fun act(action: () -> Boolean) {
checkActive()
check(action()) { "安全页面操作失败,未完整扫描" }
driver.pause()
}
}
@@ -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(
@@ -203,9 +211,6 @@ class AgentApiException(
) : Exception(message)
class AgentApiClient(private val serverUrl: String) {
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
fun testConnection() {
requireNotNull(request("GET", "/api/v1/health", null, null))
}
@@ -587,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(
@@ -1,92 +0,0 @@
package cn.ilapage.goauto.agent.network
import cn.ilapage.goauto.agent.automation.BackfillItem
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.UUID
data class BackfillResult(
val index: Int, val taskId: Long, val result: String, val code: String,
val status: String, val statusVersion: Long, val pddOrderNo: String?,
val orderSubmittedAt: String?, val timeSource: String,
) {
val success get() = result in setOf("backfilled", "already_backfilled")
val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR"
fun display(): String = if (success) {
val source = when (timeSource) {
"page" -> "页面下单时间"
"irreversible_at" -> "估算时间(提交订单时刻)"
else -> "已有时间(来源未知)"
}
"CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}"
} else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)"
}
internal fun backfillPayload(requestId: String, items: List<BackfillItem>): JSONObject {
require(items.size in 1..50)
return JSONObject().put("requestId", requestId).put("items", JSONArray().apply {
items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply {
item.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
}) }
})
}
internal fun parseBackfillResults(data: JSONObject): List<BackfillResult> {
val items = data.getJSONArray("items")
return (0 until items.length()).map { index -> items.getJSONObject(index).let {
BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"),
it.optString("status"), it.optLong("statusVersion"),
if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"),
if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"))
} }
}
/** Separate from the purchase outbox: only transport failures / INTERNAL_ERROR retry, three attempts total. */
class OrderBackfillUpload(
private val submit: (String, List<BackfillItem>) -> List<BackfillResult>,
private val checkActive: () -> Unit,
private val pause: (Long) -> Unit = Thread::sleep,
) {
fun upload(items: List<BackfillItem>, confirmed: (BackfillResult) -> Unit) {
var pending = items.mapIndexed { index, item -> index to item }
var requestId = UUID.randomUUID().toString()
repeat(3) { attempt ->
checkActive()
val results = try {
submit(requestId, pending.map { it.second }).also { values ->
check(values.size == pending.size && values.map { it.index }.toSet() == pending.indices.toSet())
values.filter { it.success }.forEach { value ->
val input = pending[value.index].second
check(value.taskId.toString() == input.addressSuffix.removePrefix("_cg") && value.pddOrderNo == input.pddOrderNo)
check(value.status.isNotBlank())
}
}
} catch (error: Exception) {
val code = when (error) {
is AgentApiException -> error.code
is IOException -> "NETWORK_ERROR"
else -> throw error
}
if (code in setOf("INTERNAL_ERROR", "NETWORK_ERROR") && attempt < 2) {
pause((attempt + 1) * 1_000L)
return@repeat // same request ID for an unknown transport result
}
pending.forEach { (index, _) -> confirmed(BackfillResult(index, 0, "failed", code, "", 0, null, null, "")) }
return
}
// A completed HTTP response is evidence, even when cancellation arrived while waiting.
val retry = mutableListOf<Pair<Int, BackfillItem>>()
results.forEach { result ->
val original = pending[result.index]
if (!result.success && result.code == "INTERNAL_ERROR" && attempt < 2) retry += original
else confirmed(result.copy(index = original.first))
}
if (retry.isEmpty()) return
pending = retry
requestId = UUID.randomUUID().toString() // payload changed after partial acknowledgement
checkActive()
pause((attempt + 1) * 1_000L)
}
}
}
@@ -11,32 +11,6 @@ import org.json.JSONObject
class TaskHistoryCache(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
/** Only acknowledged server facts may overwrite cached task facts. Never store scanned candidates. */
fun applyBackfill(result: cn.ilapage.goauto.agent.network.BackfillResult, environment: String) = synchronized(BACKFILL_LOCK) {
if (!result.success) {
if (result.needsReview) check(preferences.edit().putString("backfill_review_${result.taskId}_${result.code}",
JSONObject().put("taskId", result.taskId).put("code", result.code).put("environment", environment).toString()).commit())
return@synchronized
}
val values = JSONArray(preferences.getString(PURCHASE, "[]"))
for (index in 0 until values.length()) {
applyBackfillFacts(values.getJSONObject(index), result)
}
// Also retain the small acknowledged summary when this task is absent from downloaded history.
val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status)
.put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo)
.putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource)
.put("environment", environment)
check(preferences.edit().putString(PURCHASE, values.toString())
.putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit())
}
fun backfillTimeSource(taskId: Long, environment: String): String? {
val raw = preferences.getString("backfill_confirmed_$taskId", null) ?: return null
val confirmed = runCatching { JSONObject(raw) }.getOrNull() ?: return null
return confirmed.optString("timeSource").takeIf { confirmed.optString("environment") == environment }
}
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
items.forEach { item -> put(JSONObject()
.put("taskId", item.taskId).put("attemptNumber", item.attemptNumber).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
@@ -116,18 +90,9 @@ class TaskHistoryCache(context: Context) {
private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key)
private companion object {
val BACKFILL_LOCK = Any()
const val PREFERENCES = "goauto_task_history_cache"
const val COLLECTION = "collection"
const val PURCHASE = "purchase"
const val PAGE_SIZE = 20
}
}
internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agent.network.BackfillResult) {
if (!result.success || task.optLong("taskId") != result.taskId) return
task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL)
.put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL)
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
}
@@ -75,7 +75,6 @@ class AgentForegroundService : Service() {
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val taskMutex = TaskExecutionMutex()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
private val manualCheckRequested = AtomicBoolean(false)
@@ -133,14 +132,6 @@ class AgentForegroundService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true)
return START_STICKY
}
if (intent?.action == ACTION_BACKFILL_START) {
requestOrderBackfill(intent.getStringExtra("days").orEmpty(), intent.getLongExtra("confirmedAt", 0))
return START_STICKY
}
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
@@ -156,7 +147,6 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
collectionCooldownFuture.getAndSet(null)?.cancel(false)
@@ -175,7 +165,7 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun triggerSync() {
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
if (!working.compareAndSet(false, true)) return
executor.execute {
val manualCheck = manualCheckRequested.getAndSet(false)
try {
@@ -317,98 +307,6 @@ class AgentForegroundService : Service() {
})
}
private fun publishBackfill(state: OrderBackfillState) {
backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
}
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
// Refuse while polling/dispatch is in flight too: never queue behind another PDD operation.
val acquired = synchronized(taskMutex) {
!working.get() && purchaseStore.activeTaskId() == null && backfillGuard.tryAcquire()
}
if (!acquired) {
android.widget.Toast.makeText(this, "设备忙碌,请稍后操作", android.widget.Toast.LENGTH_SHORT).show()
return
}
try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
val started = SystemClock.elapsedRealtime()
cancelIdleReturn("人工订单回填")
publishBackfill(OrderBackfillState(running = true, message = "正在检查订单列表时序…"))
taskExecutor.execute {
var state = backfillState
fun checkActive() {
check(!backfillGuard.cancelled.get() && !Thread.currentThread().isInterrupted) { "用户停止,未完整扫描" }
check(SystemClock.elapsedRealtime() - started < cn.ilapage.goauto.agent.automation.OrderBackfillWindow.MAX_DURATION_MS) { "达到 10 分钟内部上限,未完整扫描" }
check(settingsStore.serverUrl() == server && identityStore.credentials() == credentials) { "服务器或设备身份已变化,未完整扫描" }
}
try {
acquireTaskWakeLock()
val api = AgentApiClient(server)
val uploader = cn.ilapage.goauto.agent.network.OrderBackfillUpload(
submit = { id, items -> api.backfillOrders(id, items, credentials.token) },
checkActive = ::checkActive,
)
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
override fun openOrders() {
checkActive()
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun capture() = accessibility.capture()
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
override fun back(): Boolean {
checkActive()
cn.ilapage.goauto.agent.automation.BackfillPagePolicy.validate(capture())
return accessibility.back()
}
override fun pause() { repeat(10) { checkActive(); Thread.sleep(100) } }
}
val reason = cn.ilapage.goauto.agent.automation.OrderBackfillScanner(driver, window, ::checkActive,
submit = { item -> uploader.upload(listOf(item)) { result ->
state = state.copy(success = state.success + if (result.result == "backfilled") 1 else 0,
already = state.already + if (result.result == "already_backfilled") 1 else 0,
failed = state.failed + if (!result.success) 1 else 0,
evidence = state.evidence + result.display())
// The frozen API receives all submissions. Never write an old environment's response into a new cache.
if (settingsStore.serverUrl() == server && identityStore.credentials() == credentials) {
cn.ilapage.goauto.agent.persistence.TaskHistoryCache(this).applyBackfill(result, "$server|${credentials.deviceId}")
}
publishBackfill(state)
check(result.success || result.needsReview) { "网络或服务端瞬时错误重试耗尽,未完整扫描" }
} },
progress = { checked ->
state = state.copy(checked = checked, message = if (window.nonDescending) cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED else "正在扫描(已读 $checked 单)…")
publishBackfill(state)
}).scan()
state = state.copy(message = reason)
} catch (error: Exception) {
// Never echo raw page text, HTTP bodies or credentials in UI/logs.
val reason = if (error is IllegalStateException || error is IllegalArgumentException) error.message else null
val message = reason?.takeIf { it.length < 100 } ?: "网络或页面异常"
state = state.copy(message = message + if (message.contains("未完整扫描")) "" else ",未完整扫描")
} finally {
val warning = if (window.nonDescending && !state.message.contains(cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED))
"\n${cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED}" else ""
try {
publishBackfill(state.copy(running = false, message = state.message + warning))
} finally {
try { releaseTaskWakeLock() } finally { backfillGuard.release() }
}
}
}
} catch (error: Exception) {
backfillGuard.release()
publishBackfill(OrderBackfillState(message = "回填未启动,请检查天数、设备连接与无障碍服务"))
}
}
private fun requestCurrentPageCollection(
requestId: String,
replacementOriginType: String?,
@@ -810,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(
@@ -1140,15 +1040,10 @@ class AgentForegroundService : Service() {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
if (backfillState.running) {
val stop = PendingIntent.getService(this, 242, Intent(this, AgentForegroundService::class.java).setAction(ACTION_BACKFILL_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
}
return builder
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(getString(R.string.app_name))
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
.setContentText(content)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
@@ -1174,11 +1069,6 @@ class AgentForegroundService : Service() {
}
companion object {
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@Volatile var backfillState = OrderBackfillState()
private set
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
@@ -1,22 +0,0 @@
package cn.ilapage.goauto.agent.service
import java.util.concurrent.atomic.AtomicBoolean
class OrderBackfillGuard(private val mutex: TaskExecutionMutex) {
private val active = AtomicBoolean(false)
val cancelled = AtomicBoolean(false)
fun tryAcquire(): Boolean {
if (!active.compareAndSet(false, true)) return false
if (!mutex.tryAcquire(RESERVATION)) {
active.set(false)
return false
}
cancelled.set(false)
return true
}
fun release() {
mutex.release(RESERVATION)
active.set(false)
}
companion object { const val RESERVATION = Long.MAX_VALUE - 1 }
}
@@ -1,14 +0,0 @@
package cn.ilapage.goauto.agent.service
data class OrderBackfillState(
val running: Boolean = false,
val checked: Int = 0,
val success: Int = 0,
val already: Int = 0,
val failed: Int = 0,
val message: String = "",
val evidence: List<String> = emptyList(),
) {
fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" +
if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n")
}
@@ -141,16 +141,8 @@ class TaskHistoryFragment : Fragment() {
private val imageLoader = HistoryImageLoader()
private val imageRequests = mutableListOf<HistoryImageRequest>()
private var currentPageReceiverRegistered = false
private var backfillPanel: LinearLayout? = null
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
renderBackfill()
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
}
return
}
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
@@ -190,11 +182,6 @@ class TaskHistoryFragment : Fragment() {
pageColumn = context.column()
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
pageColumn.addView(buildSearch())
if (!collection) {
backfillPanel = context.column(0)
pageColumn.addView(backfillPanel)
renderBackfill()
}
pageColumn.addView(buildFilters())
resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) }
pageColumn.addView(resultColumn, resultColumn.fullWidth())
@@ -220,7 +207,6 @@ class TaskHistoryFragment : Fragment() {
}
override fun onDestroyView() {
backfillPanel = null
requestGeneration++
cancelImageRequests()
super.onDestroyView()
@@ -287,14 +273,6 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "采集当前拼多多商品"
setOnClickListener { confirmCurrentPageCollection() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
} else {
row.addView(MaterialButton(context).apply {
text = "回填"
textSize = 14f
minimumHeight = context.dp(48)
contentDescription = "回填拼多多订单号和下单时间"
setOnClickListener { showBackfillInput() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
}
addView(row, row.fullWidth())
}).apply {
@@ -302,75 +280,6 @@ class TaskHistoryFragment : Fragment() {
}
}
private fun showBackfillInput() {
if (AgentForegroundService.backfillState.running) {
toast("设备忙碌,请稍后操作")
return
}
val context = requireContext()
val input = TextInputEditText(context).apply {
setText("2")
inputType = android.text.InputType.TYPE_CLASS_NUMBER
minimumHeight = context.dp(48)
contentDescription = "回填天数"
selectAll()
}
val field = TextInputLayout(context).apply {
hint = "天数"
helperText = "从确认时刻往前 N×24 小时"
addView(input)
}
val dialog = MaterialAlertDialogBuilder(context).setTitle("回填订单")
.setView(context.cardColumn().apply { addView(field) })
.setNegativeButton("取消", null).setPositiveButton("确认", null).create()
dialog.setOnShowListener {
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val days = input.text.toString().trim()
val now = System.currentTimeMillis()
if (runCatching { cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, now) }.isFailure) {
field.error = "请输入正整数天数"
return@setOnClickListener
}
val intent = Intent(context, AgentForegroundService::class.java)
.setAction(AgentForegroundService.ACTION_BACKFILL_START).putExtra("days", days).putExtra("confirmedAt", now)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
dialog.dismiss()
}
}
dialog.show()
}
private fun renderBackfill() {
val panel = backfillPanel ?: return
val context = context ?: return
val state = AgentForegroundService.backfillState
panel.removeAllViews()
if (state.message.isBlank()) return
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(if (state.running) "正在回填订单" else "回填结果", 16f))
addView(context.label(state.copy(evidence = emptyList()).text(), 14f))
if (state.running) {
addView(MaterialButton(context).apply {
text = "停止"
minimumHeight = context.dp(48)
setOnClickListener {
context.startService(Intent(context, AgentForegroundService::class.java).setAction(AgentForegroundService.ACTION_BACKFILL_STOP))
isEnabled = false
text = "正在停止…"
}
})
}
if (state.evidence.isNotEmpty()) addView(MaterialButton(context).apply {
text = "查看逐条结果"
minimumHeight = context.dp(48)
setOnClickListener {
MaterialAlertDialogBuilder(context).setTitle("回填明细")
.setMessage(state.evidence.joinToString("\n")).setPositiveButton("关闭", null).show()
}
})
}))
}
private fun buildFilters(): View {
val context = requireContext()
val statuses = if (collection) {
@@ -723,7 +632,6 @@ class TaskHistoryFragment : Fragment() {
private fun registerCurrentPageReceiver() {
if (currentPageReceiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -816,12 +724,6 @@ class TaskHistoryFragment : Fragment() {
append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n")
append("PDD 订单号:${task.pddOrderNo ?: "—"}\n")
append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}")
val environment = "${AgentSettingsStore(context).serverUrl()}|${runCatching { SecureDeviceStore(context).credentials()?.deviceId }.getOrNull()}"
when (TaskHistoryCache(context).backfillTimeSource(task.taskId, environment)) {
"page" -> append("(页面读取)")
"irreversible_at" -> append("(估算:提交订单时刻)")
"existing_unknown" -> append("(已有值,来源未知)")
}
}
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
@@ -1,245 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.network.*
import cn.ilapage.goauto.agent.service.*
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.IOException
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
class OrderBackfillTest {
private val zone = TimeZone.getTimeZone("Asia/Shanghai")
private fun time(raw: String): Long = BackfillDetailReader(zone).apply { accept("下单时间:$raw") }.finish().timeMillis!!
private val now = time("2026-09-08 12:00:00")
@Test fun `expanded detail reads order time not group time across frames`() {
val reader = BackfillDetailReader(zone)
reader.accept("测试地址_cg7\n订单编号:TEST-000007\n展开")
reader.accept("下单时间:2026-09-08 10:50:29\n拼单时间:2026-09-08 11:20:35")
val detail = reader.finish()
assertEquals("2026-09-08T10:50:29+08:00", detail.item!!.orderSubmittedAt)
assertEquals("_cg7", detail.item!!.addressSuffix)
assertNull(reader.finish().item)
}
@Test fun `untagged order never becomes retained or uploaded candidate`() {
val reader = BackfillDetailReader(zone)
reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00")
assertNull(reader.finish().item)
reader.accept("测试地址_cg7")
assertNull(reader.finish().item) // previous order number has been cleared
val uploaded = mutableListOf<BackfillItem>()
scanner(FakeDriver(tagged = false), uploaded).scan()
assertTrue(uploaded.isEmpty())
}
@Test fun `ambiguous and noncanonical suffixes are skipped and missing time remains optional`() {
for (suffix in listOf("_cg07", "_cg+7", "_cg7", "_cg7abc", "_cg7 _cg8")) {
assertNull(BackfillDetailReader(zone).apply { accept("$suffix\n订单号:TEST-000007") }.finish().item)
}
assertNull(BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n订单号:TEST-000008") }.finish().item)
val missing = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n拼单时间:2026-09-08 10:00:00") }.finish()
assertNotNull(missing.item)
assertNull(missing.item!!.orderSubmittedAt)
}
@Test fun `rolling hours boundary and sampled descending cutoff`() {
val window = OrderBackfillWindow("2", now)
assertEquals(now - 48 * 3_600_000L, window.cutoff)
assertTrue(window.includes(window.cutoff))
assertFalse(window.includes(window.cutoff - 1))
repeat(4) { assertFalse(window.observe(window.cutoff + 10 - it)) }
assertTrue(window.observe(window.cutoff - 1))
assertEquals(Long.MIN_VALUE, OrderBackfillWindow("99999999999999999999999999", now).cutoff)
}
@Test fun `time reversal and missing times disable early stop`() {
val window = OrderBackfillWindow("2", now)
assertFalse(window.observe(now - 100))
assertFalse(window.observe(now))
repeat(10) { assertFalse(window.observe(window.cutoff - it - 1)) }
assertTrue(window.nonDescending)
val unknown = OrderBackfillWindow("2", now)
unknown.observe(null)
repeat(10) { assertFalse(unknown.observe(unknown.cutoff - it - 1)) }
}
@Test fun `scanner expands and stops after validated prefix passes rolling cutoff`() {
val driver = FakeDriver()
val items = mutableListOf<BackfillItem>()
val result = scanner(driver, items).scan()
assertEquals(5, driver.opened)
assertEquals(5, driver.expansions)
assertTrue(result.contains("已达指定天数"))
assertTrue(result.contains("未完整扫描"))
assertTrue(items.all { !it.orderSubmittedAt.orEmpty().contains("11:20") })
}
@Test fun `unordered list scans to internal cap and reports incomplete`() {
val window = OrderBackfillWindow("2", now)
val driver = FakeDriver(unordered = true)
val result = OrderBackfillScanner(driver, window, {}, {}, {}).scan()
assertTrue(window.nonDescending)
assertEquals(200, driver.opened)
assertTrue(result.contains("未完整扫描"))
assertTrue(result.contains(OrderBackfillWindow.UNORDERED))
}
@Test fun `mutex occupied rejects and simultaneous double tap has one winner and finally releases`() {
val mutex = TaskExecutionMutex()
val guard = OrderBackfillGuard(mutex)
mutex.tryAcquire(7)
assertFalse(guard.tryAcquire())
mutex.release(7)
val start = CountDownLatch(1)
val complete = CountDownLatch(2)
val won = AtomicInteger()
val pool = Executors.newFixedThreadPool(2)
repeat(2) { pool.execute { start.await(); if (guard.tryAcquire()) won.incrementAndGet(); complete.countDown() } }
start.countDown()
complete.await()
pool.shutdownNow()
assertEquals(1, won.get())
assertFalse(mutex.tryAcquire(8))
try { guard.cancelled.set(true); throw IllegalStateException("cancelled") } catch (_: IllegalStateException) { } finally { guard.release() }
assertNull(mutex.currentTaskId())
assertTrue(guard.tryAcquire())
assertFalse(guard.cancelled.get())
guard.release()
}
@Test fun `permanent business errors never retry regardless of server retryable flag`() {
val codes = listOf("PURCHASE_BACKFILL_SUFFIX_INVALID", "PURCHASE_TASK_NOT_FOUND", "PURCHASE_BACKFILL_DEVICE_MISMATCH",
"PURCHASE_STATE_CONFLICT", "PURCHASE_INVALID_REQUEST", "PURCHASE_ORDER_TIME_INVALID", "PURCHASE_ORDER_TIME_MISSING",
"PURCHASE_BACKFILL_ORDER_CONFLICT", "PURCHASE_BACKFILL_BATCH_CONFLICT", "PURCHASE_BACKFILL_ORDER_ALREADY_USED")
codes.forEach { code ->
var calls = 0
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ _, _ -> calls++; listOf(failure(code)) }, {}, {}).upload(listOf(item), results::add)
assertEquals(1, calls)
assertTrue(results.single().display().contains("需人工检查"))
}
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; throw AgentApiException(409, codes.first(), "hidden", true) }, {}, {})
.upload(listOf(item)) { assertTrue(it.needsReview) }
assertEquals(1, calls)
}
@Test fun `network and internal errors retry bounded with stable transport request id`() {
val ids = mutableListOf<String>()
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ id, _ -> ids += id; throw IOException("private body") }, {}, {})
.upload(listOf(item), results::add)
assertEquals(3, ids.size)
assertEquals(1, ids.toSet().size)
assertFalse(results.single().needsReview)
assertFalse(results.single().display().contains("private"))
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; listOf(failure("INTERNAL_ERROR")) }, {}, {})
.upload(listOf(item)) { assertTrue(it.display().contains("重试耗尽")) }
assertEquals(3, calls)
}
@Test fun `partial response only retries transient items and preserves acknowledged success`() {
val sizes = mutableListOf<Int>()
val results = mutableListOf<BackfillResult>()
val second = item.copy(addressSuffix = "_cg8", pddOrderNo = "TEST-000008")
OrderBackfillUpload({ _, items ->
sizes += items.size
if (items.size == 2) listOf(success(), failure("INTERNAL_ERROR").copy(index = 1))
else listOf(failure("PURCHASE_STATE_CONFLICT"))
}, {}, {}).upload(listOf(item, second), results::add)
assertEquals(listOf(2, 1), sizes)
assertTrue(results.first().success)
assertEquals(1, results.last().index)
}
@Test fun `cancellation prevents upload and invalid response cannot confirm cache`() {
var calls = 0
try {
OrderBackfillUpload({ _, _ -> calls++; listOf(success()) }, { error("stopped") }, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
assertEquals(0, calls)
try {
OrderBackfillUpload({ _, _ -> listOf(success().copy(taskId = 99)) }, {}, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
}
@Test fun `payload contains only suffix order and optional RFC3339 time`() {
val payload = backfillPayload("test", listOf(item))
assertEquals(setOf("requestId", "items"), payload.keySet())
assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet())
val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}"""))
assertTrue(parsed.single().display().contains("估算"))
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
}
@Test fun `cache updates confirmed server facts only and clears old errors`() {
val task = JSONObject().put("taskId", 7).put("status", "order_result_unknown").put("errorCode", "OLD").put("errorMessage", "old")
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, failure("PURCHASE_BACKFILL_ORDER_CONFLICT"))
assertEquals("order_result_unknown", task.getString("status"))
assertEquals("OLD", task.getString("errorCode"))
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, success())
assertEquals("order_created", task.getString("status"))
assertEquals(item.pddOrderNo, task.getString("pddOrderNo"))
assertTrue(task.isNull("errorCode"))
assertTrue(task.isNull("errorMessage"))
assertFalse(task.getBoolean("retryable"))
}
@Test fun `expansion requires order information and product snapshot row`() {
val expand = node("0/1", "展开")
assertNull(BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand)))
assertEquals(expand, BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand, node("0/2", "商品快照"))))
}
@Test fun `dangerous nodes ancestors and overlapping actions never become click targets`() {
BackfillPagePolicy.forbidden.forEach { label ->
val dangerous = node("0/0", label)
assertFalse(BackfillPagePolicy.safe(page(dangerous), dangerous))
val parent = node("0", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(parent, dangerous), parent))
val adjacent = node("0/1", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(adjacent, dangerous), adjacent))
}
}
private val item = BackfillItem("_cg7", "TEST-000007", null)
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
private fun node(path: String, label: String, selected: Boolean = false) = SnapshotNode(path, path.substringBeforeLast('/'), label, null, null, "View",
NodeBounds(0, 0, 200, 60), true, false, selected, false, true, true)
private fun page(vararg nodes: SnapshotNode) = UiSnapshot(BackfillPagePolicy.PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", nodes.toList())
private inner class FakeDriver(val tagged: Boolean = true, val unordered: Boolean = false) : BackfillDriver {
var opened = 0
var expansions = 0
var index = 0
var inDetail = false
var expanded = false
override fun openOrders() = Unit
override fun pause() = Unit
override fun capture(): UiSnapshot {
if (!inDetail) return page(node("0/0", "我的订单"), node("0/1", "全部", true), node("0/2", "查看详情"), node("0/2/0", "合成卡片 $index"))
val date = if (unordered && index == 1) "2026-09-08 11:00:00" else if (index < 4) "2026-09-08 10:00:00" else "2026-09-05 10:00:00"
return page(node("0/0", "订单详情"), node("0/1", "订单编号:TEST-${100000 + index}"),
node("0/2", if (tagged) "合成地址_cg${index + 1}" else "无后缀合成地址"),
node("0/4", "商品快照"),
node("0/3", if (expanded) "下单时间:$date\n拼单时间:2026-09-08 11:20:00" else "展开"))
}
override fun click(node: SnapshotNode): Boolean {
if (node.label == "展开") { expanded = true; expansions++ } else { inDetail = true; opened++ }
return true
}
override fun scroll() = false
override fun back(): Boolean { inDetail = false; expanded = false; index++; return true }
}
}
+96 -2
View File
@@ -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: d547c17924ac53422232ad9d6c55a34c8cd8d63c
synchronized_at: 2026-09-08T07:16:20Z
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
synchronized_at: 2026-09-11T09:09:07Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -366,3 +366,97 @@ Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
## Android 连续临时采集收尾(#250)
当前实现绑定 `37e8b56`(Agent 0.9.72 / 85),用户确认 v5 按次数返回后切回 Agent。仅 Android,无 Admin/API/权限/数据库迁移;取代 v1~v4 首页导航。
- `AgentSettingsFragment` 在采集间隔后复用卡片和 SwitchMaterial;仅开启显示“返回次数”按钮,Material 单选框选择 1~5 次(默认 3),立即保存或取消。成功无常驻反馈,错误/忙碌显示原因。任务忙碌禁用,弹窗选定时再检查。
- `AgentSettingsStore` 在私有 `goauto_agent_settings` 保存 `continuous_collection`(false)与 `continuous_collection_return_count`(3);次数越界或读取错误回落 3,写入失败恢复旧值,无网络加载。
- `requestCurrentPageCollection` 保留原范围判断;`executeTask` 在采集开始固定次数。`executeTaskWhileAwake` 提交结果、结束图片上传后返回 resultStatus;`ContinuousCollectionPolicy.finish` 仅对完整确认成功调用新 `PddCollectionReturnNavigator`。失败/部分成功不导航。冷却间隔不变,连续模式不启动旧 idle-return。
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
## SYB 存疑规格候选解析(#251)
实现绑定 `64b4205`,仅 Server `app/goauto/sybimport`;无 API 字段、数据库迁移、权限、并发模式或 UI 变化。
- `parse_candidates.go: ParseWithCandidates` 先运行原 `Parse`,仅 uncertain 时解码并校验已存 SpecsJSON;复用 `closedShopeeCandidates` 提取颜色/尺码封闭候选,拒绝未知有值角色/重复角色。完整候选组合与原文归一比较唯一时确认,其他情况返回原 ParseResult。
- `ApplyDetail` 在加载既有蝦皮商品后、写入规格前调用;`Reparse` 在保留人工/AI 确认保护后加载关联候选调用。新增 ParseResult 私有 matchedCandidates 标记,使 `mergeParsedSpec` 对已经存在的候选直接返回,避免按维度名重复合并或改变映射。
- #198 `ProcessSpecAIParseRun → Reparse` 自然接入确定性优化,仍需 AI 的记录沿用原后续步骤,不新增异步任务/Provider 调用/接口。
- 验证 `go test ./app/goauto/sybimport ./app/goauto/aimatching ./app/goauto/shopeeproduct ./app/goauto/purchase` 和 `go build ./...` 通过;新增候选正反/格式/组合色/单维度、歧义/空值、同步重解析一致、人工保护及无需调用 AI 测试。AI 原有置信度/失败重试测试改用仍需语义理解的合成输入,继续验证原分支。
- 本轮只使用本地测试数据与 httptest AI,未触发真实 SYB 同步、外部 AI、采购、数据库迁移或服务重启;线上及历史数据效果尚待授权验证。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- Server `shopeeproduct.Service.DetailWithAutoMatchBudget` 为详情 GET 附加派生的整次匹配预算,不返回 Provider、Key 等配置。`AutoMatchMappings` 读取 AI 设置 T,使用 2T+10 秒的单个父 context;颜色、尺码、保存及结果回读共用剩余时间。初始配置读取也受有界 context 约束,读取耗时从后续预算扣除。
- 批量定时匹配调用内部 `autoMatchMappings` 并继续使用原 MaxProviderTimeout=600 秒,不随本次详情入口调整;候选建议与其他 AI 功能不改。
- Web `ShopeeProductDetailDrawer` 在提交前以普通 GET 刷新商品与预算,仅 auto-match POST 使用 (2T+20) 秒超时。准备失败/缺少预算时不发 POST;旧 Server 必须先更新,不能只发布新版 Web。
- 加载状态同时绑定商品加载代次和操作序号。关闭/切换立即结束该页面等待状态;同商品重开读取详情,旧响应/旧 finally 不得污染新操作。匹配期间禁止在当前抽屉编辑映射,原人工草稿放弃确认保留。
- POST 未正常返回时只回读详情及关联 PDD,不自动重发 POST;回读失败结束转圈并保留刷新入口。GET 沿用单请求10秒超时,完整结果回读最多两次串行 GET,不是无限等待。
- 验证:Go shopeeproduct/aimatching/clientapi 测试与 go build;Web `node --test tests/unit/shopee-match-lifecycle.test.cjs`,Playwright shopee-match-lifecycle 与 shopee-pdd-picker,受影响 ESLint 与 build:prod。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- Web `syb-products/index.vue` 在“去关联”旁增加“一键关联+下拉箭头”,共享 `quick-link.js` 页面级状态。`quick-link-device-preference.js` 按当前浏览器来源、API环境、用户ID保存设备ID,独立于批量采购偏好;不存密钥或商品数据。首次主动作选机继续原行,箭头选机只保存偏好。
- Server `shopeeproduct/latest_collection.go` 复用 LinkPDD 的可选 Admin 模式。短事务核对 SYB→Shopee、未关联/规格版本、设备状态,选择并锁定最新 completed/completed_partial 的 agent_current_page 任务及其 PDD;仅完整采集、active且有可用颜色或尺码时更新,WHERE pdd_product_id IS NULL 不覆盖其他操作者的关联。响应保留该事务产生的规格版本及来源task/device/PDD,不能用后续GET替换冻结上下文再自动匹配。
- 不新增表、迁移、路由或权限对账。原手动关联/替换请求不变;共享 Handler 明确拒绝 Client API 使用此跨模块模式,不能借 Shopee-only write grant 读取 SYB/设备上下文。来源任务原规则快照不改,taskId 可回溯既有采集证据。
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
## SYB 行内一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+131 -3
View File
@@ -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: 1eb380d07183157a8430c7ced868479e72fbdfe3
synchronized_at: 2026-09-08T07:49:50Z
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
synchronized_at: 2026-09-11T09:09:16Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -37,6 +37,8 @@ synchronized_at: 2026-09-08T07:49:50Z
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
- #257:蝦皮颜色与尺码建议/一键匹配先保留仍有效的已确认映射,再复用相同标准化比较:简体、空白、全半角与大小写统一。颜色标准化后仅命中一个当前可选 PDD 候选时,不调用 AI,建议标为 matched,一键匹配按既有流程保存 exact_match + confirmed;未命中或多个不同候选标准化后相同,仍走原 AI 与置信度/候选校验。只转换比较键,蝦皮/PDD 原规格名不改,映射目标保存 PDD 原始候选名称;不引入颜色同义词猜测、不自动重算历史映射。
## SYB 商品明细
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
@@ -104,6 +106,8 @@ synchronized_at: 2026-09-08T07:49:50Z
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
- #256(Android 0.9.73,实现 43703e0):采集执行器可识别无“已选/请选择”摘要且当前仅一个规格标题的滚动规格面板。必须匹配 PDD 包名、任务详情 Activity 与选择器,无登录/验证等异常,并同时具备唯一有界规格滚动区域、唯一标题及非空候选、关闭、唯一数量输入/增减、支付区和唯一下方提交订单只读动作;缺失任一证据仍拒绝。此分支只由采集上下文启用,不生成虚假选中摘要,采购与其他解析调用维持原条件。订单动作仍仅作只读证据,采集绝不点击。
## 采集任务
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
@@ -404,7 +408,7 @@ synchronized_at: 2026-09-08T07:49:50Z
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
@@ -482,3 +486,127 @@ synchronized_at: 2026-09-08T07:49:50Z
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## 商品页动态变化下的采购入口恢复(#243)
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
## 同一颜色卡片重复选中节点(#244)
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
## 精确规格不可用与入口恢复诊断(#245)
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
## 改地址返回后的动态提交按钮(#248)
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
## 临时采集连续模式(#250)
当前实现绑定 `37e8b56`,Agent 0.9.72 / versionCode 85。2026-09-09 用户批准 v5:按配置次数返回后切回 Agent,替代此前“识别并停留 PDD 首页”的方案;不保证 PDD 最终停在首页。代码、376 项 Android 单测及 Debug 构建通过,新版设置与完整真机闭环待验收。
- 连续采集默认关闭,本机即时保存,失败恢复原值;开启后显示返回次数选择,1~5 次、默认 3,取消不改,写入失败恢复旧值。任务忙碌时开关和次数均禁用,选择提交时再次检查;无新增权限。只用于本次人工新建普通临时采集(source=agent_current_page、attemptNumber=1、无替换来源),Admin 下发、重试、替代与采购不参与。
- 单任务执行开始固定返回次数。完整 completed、服务端提交确认且既有图片上传流程结束后,执行有限 Back,每次后等待 500ms;不要求识别商品/搜索/相机/首页,也不要求画面发生变化。图片上传仍沿用既有非致命行为,不因收尾更改结果。
- 每次 Back 前检查前台仍为 PDD,读取页面后再次检查;离开 PDD 则停止余下 Back。次数完成、离开 PDD 或 Back 动作失败后,尝试打开 Agent 保持原 Tab,等待 500ms 并验证前台包名。动作失败不重试,不把请求启动成功直接当作切回成功。
- 沿用风险页安全边界:登录/验证码/风控、地址编辑、订单详情或支付等证据时保留现场,不发送 Back 或强行切回;Activity 缺失、读取异常明确停止。以上例外不引入首页门禁。
- 导航在原任务互斥和唤醒锁内完成,不变更租约/调度。返回失败只显示人工处理提示,不把成功采集改为失败或自动重采。
- 失败、部分成功、提交未确认保留现场,不运行返回步骤,也不触发旧空闲返回倒计时。关闭开关保持原流程,采集间隔不变。
- 不自动拍照、搜索或创建下一任务,不涉及采购/订单/付款;不使用深链、浏览器、OCR/VLM 或坐标兜底。
## SYB 存疑规格候选核对(#251)
实现绑定 `64b4205`,基线 main `fdd26af`;2026-09-09 代码与本地测试/构建通过,未部署、未重跑生产历史数据。
- 原 Parse 的成功与失败行为不变,仅 uncertain 明细增加既有蝦皮规格候选核对。比较完整原文,沿用【备注】清理,允许全角逗号、逗号两侧空白及连续空白归一;颜色尺码可正序或倒序,使用逗号或空白分隔,只有唯一完整组合才改为 success。返回既有候选标签,不改写标签,不删除加号、不截断体重说明、不做近似匹配。
- 单规格只有在候选确实仅包含该角色时通过;有两个角色时不能用单值补猜另一个角色。空/损坏候选、校验不通过、同角色多维、未知有值维度、多种组合或归一化碰撞,均保留原结果。候选必须已存在,不能用本次待确认输入生成候选再自我证明。
- 同步入库 ApplyDetail、手动 Reparse 共用;成功核对不再次合并规格,因此不新增重复维度、不覆盖已有映射。人工确认仍优先,默认 Reparse 跳过人工与 AI 已确认值;原文保留。
- #198 批处理原本先 Reparse,所以可直接复用本优化:能确定的不用调用 AI;仍不确定的按原 AI 流程处理。没有新增调度、调用额度或同步等待,不自动开启已有任务。
- 不取消 uncertain/failed 状态,不改变采购可信度/可采购规则,不实现一键关联 PDD。部署后新同步或显式重新解析会使用新逻辑;本次没有执行生产批量更新。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- AI 设置中的 timeoutSeconds=T 仍是单次 Provider 请求的上限。详情一键匹配的整次预算为 2T+10 秒,浏览器 POST 等待上限为 2T+20 秒;例如 T=60 时130/140秒,T=180时370/380秒。只保留现有一个 AI 设置入口,不新增时间配置框,也不自动改变管理员已保存的 T。
- 超时上限不是固定等待。颜色/尺码串行执行且共用整次预算;正常部分匹配仍按原事务保存合格结果,AI异常在保存前返回时不提交本轮新映射。既有置信度、候选、人工确认映射保护和采购资格不变。
- 前端超时、关闭或切换详情不代表后端已取消、未保存或已回滚;必须以回读结果为准,无法回读显示结果未确认,不自动重复匹配、不自动创建采购。
- 单商品详情的成功/失败/超时/关闭重开都必须结束失效的等待状态;旧请求不得覆盖新商品或新操作。匹配进行期间本抽屉不能人工改映射,已有未保存草稿仍按原确认流程处理。
- UI匹配提示的等待上限仅指POST,不含提交前GET及结果核对GET;结果核对也有限时,不能让失败提示再次陷入无限转圈。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- 未关联 PDD 且既有处理阶段提供“去关联”的 SYB 行可用一键关联;原人工入口不删除。首次点击选择采集手机后继续当前行,后续使用记忆手机;小箭头查看/切换手机,保存只修改全行共用偏好。取消不改变偏好、不执行关联。与批量采购设备选择互不影响。
- 以所选手机的临时采集 source=agent_current_page 为范围,从 completed、completed_partial 中按 finished_at DESC、id DESC 取最新一条;失败/待执行/执行中不参与。最新为部分成功或商品停用、无可用规格时明确停止,不回退更旧商品,不切换其他手机。离线手机允许使用已有成功结果,停用或已删除手机需要重新选择。
- 主动作开始冻结行、蝦皮商品和设备;服务端关联事务固定来源采集任务、PDD商品与规格版本。仅未关联时自动写入,已有或被其他操作改变的关联不覆盖。关联作用于蝦皮商品,因此共用该蝦皮档案的其他 SYB 明细也受影响;切换手机不会重绑已有商品,也不会改变在途操作。
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
## SYB 已关联商品的一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+21 -2
View File
@@ -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 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
+112 -3
View File
@@ -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: 1f5ee1b29c66773fa571d862241b63f02dae283b
synchronized_at: 2026-09-08T07:09:23Z
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 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
@@ -1060,3 +1095,77 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
本节是 Admin 蝦皮详情接口补充,不修改 Android 请求或采购协议。
- `GET /api/admin/v1/shopee-products/:productId` 的 `data` 增加 `autoMatchTimeoutSeconds` 整数,为后端详情一键匹配整次预算 `2 × AI timeoutSeconds + 10`,配置范围3~600对应16~1210秒。它是非敏感派生预算,沿用商品详情读取权限;不是开放 AI 管理配置读取,响应不包含 Key、Provider、BaseURL 或 Model。现有客户端路由若复用本 Handler 同样仅获得这个数字。
- `POST /api/admin/v1/shopee-products/:productId/specs/mapping/auto-match` 请求字段、业务权限、版本核对与响应映射语义不变。服务端按收到请求时的 AI 配置确定整次父预算,读取配置耗时扣除;Provider 请求仍受自身超时及父 context 剩余时间中更短者约束。
- Admin 点击前先GET新详情取得预算,POST timeout=(autoMatchTimeoutSeconds+10)×1000毫秒;缺字段、非整数或超范围时明确拒绝开始匹配,不猜配置。前后端需配套升级。
- 准备后配置可能变化,浏览器仍可能先超时;不要据超时响应断言事务是否提交。POST失败/断连/超时只执行有限GET回读,不自动重发POST;失败回读显示结果未知。回读结果表示当前已保存状态,不保证这次请求已经永久不再写入,也不保证全部项目都匹配成功。
- 普通GET默认10秒;本轮未修改建议接口610秒、其他AI功能或后台批量匹配600秒预算。T=60时130/140秒、T=180时370/380秒。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
本节仅补充 Admin API;Android 和客户端密钥旧请求不变。
`POST /api/admin/v1/shopee-products/{productId}/link-pdd` 新增与非零 pddProductId 互斥的可选模式:
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"详情返回的未关联版本"}}
```
- 校验当前 SYB→Shopee、未关联、规格版本和设备存在/未停用;离线可用。短事务选择最新已结束成功或部分成功的临时采集,排序 finished_at DESC、id DESC;部分成功不跳过回退旧记录。只接受 completed、存在 finishedAt/PDD、PDD active 且存在可选颜色或尺码。
- 成功返回原 `product`,及 `collectionSource: {taskId, deviceId, pddProductId, requiresSpecConfirmation}`。product.specContextVersion 是此次事务写入后的冻结版本;requiresSpecConfirmation 对应 failed 且没有人工或AI确认,完整资格仍由现有 batch-preview 派生。不得把后续其他人的商品版本当作本次自动匹配上下文。
- 409 `PDD_LINK_CONFLICT` 表示已有关联;409 `LATEST_COLLECTION_UNAVAILABLE` 表示设备/最近采集/候选不可用;409 `SPEC_CONTEXT_VERSION_STALE` 沿用现有语义。非法参数422。重复请求不会覆盖已有关联,成功响应丢失后重发也会冲突,客户端应回读而非自动重试。last_update_request_id 保留本次请求标识;不承诺额外持久化完整重放响应。
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
## Admin 一键替换最近采集候选(#258)
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
先请求只读预览(仍需 UUID requestId):
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
```
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
```json
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
```
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `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` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
+23 -2
View File
@@ -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/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
+14 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: df033d06fd94a4b02a447a27ced8d711521342f3
synchronized_at: 2026-09-08T02:23:08Z
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
synchronized_at: 2026-09-10T01:42:05Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -41,7 +41,7 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,旧版详情复合一键匹配共享600秒总预算,旧Admin客户端等待610秒;#254未部署前仍适用,部署后详情入口改用下节动态预算。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
@@ -93,3 +93,14 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- 本次只完成代码构建与合成测试,未改线上配置、AI设置、常驻服务或Nginx;部署与重启需另行授权。
- 详情匹配新窗口是后端 2T+10 秒、Web POST 2T+20 秒。当前默认API最低写超时校验仍为620秒,这是历史最低值,不保证容纳所有动态预算;发布前读取实际 AI timeoutSeconds、application.writetimeout 和生效代理配置核对。
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
+8
View File
@@ -0,0 +1,8 @@
# PDD 首页控件摘要(设备 192.168.0.9:35727)
- 采集时间:2026-09-15
- 包名:`com.xunmeng.pinduoduo`
- 发现语义入口:`content-desc=拍照搜索`
- 发现搜索入口:`content-desc=搜索`
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
- 未保存坐标、完整控件树、截图、账号或个人数据。
+38
View File
@@ -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 }
}
+3 -1
View File
@@ -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"
@@ -63,8 +64,9 @@ func InitRouter() {
goautoproduct.InitRouter(r, authMiddleware)
goautoreplacement.InitRouter(r, authMiddleware)
goautorule.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
goautosybimport.InitRouter(r, authMiddleware)
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
}
+24 -19
View File
@@ -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"):
+5 -5
View File
@@ -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 {
+5
View File
@@ -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},
+8 -6
View File
@@ -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] {
+23 -1
View File
@@ -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.
+1 -1
View File
@@ -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()
+1
View File
@@ -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},
+11
View File
@@ -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)
}
+1 -9
View File
@@ -202,15 +202,7 @@ func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
return task.syncPurchaseGuardSlots()
}
func (task *PurchaseTask) BeforeSave(tx *gorm.DB) error {
if err := task.syncPurchaseGuardSlots(); err != nil {
return err
}
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" {
return CheckPurchaseOrderNumber(tx, task.ID, *task.PDDOrderNo)
}
return nil
}
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
func (task *PurchaseTask) SetStatus(status string) error {
task.Status = status
@@ -1,41 +0,0 @@
package models
import (
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrPurchaseOrderNumberUsed = errors.New("purchase order number belongs to another task")
type PurchaseOrderNumberUsedError struct {
TaskID uint64
}
func (e *PurchaseOrderNumberUsedError) Error() string {
return fmt.Sprintf("订单号已属于任务 CG-%d", e.TaskID)
}
func (e *PurchaseOrderNumberUsedError) Unwrap() error { return ErrPurchaseOrderNumberUsed }
// CheckPurchaseOrderNumber must run inside the caller's write transaction.
// The existing singleton setting row serializes order assignments across
// processes, including an absent order number, without relying on gap locks or
// a new schema constraint. Locking reads see the latest committed assignment.
// A missing singleton fails closed. Deadlocks roll back the losing transaction.
func CheckPurchaseOrderNumber(tx *gorm.DB, taskID uint64, orderNo string) error {
var setting PurchaseRuleSetting
if err := tx.Session(&gorm.Session{NewDB: true}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, 1).Error; err != nil {
return err
}
var others []PurchaseTask
if err := tx.Session(&gorm.Session{NewDB: true}).Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("pdd_order_no = ? AND id <> ?", orderNo, taskID).Find(&others).Error; err != nil {
return err
}
if len(others) != 0 {
return &PurchaseOrderNumberUsedError{TaskID: others[0].ID}
}
return nil
}
+57 -25
View File
@@ -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" }
+8
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -542,7 +542,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusForbidden
case CodeTaskNotFound:
status = http.StatusNotFound
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed:
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired:
status = http.StatusConflict
}
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
+74 -26
View File
@@ -6,9 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
@@ -379,22 +379,6 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
t.PDDOrderNo = &req.PDDOrderNo
t.OrderSubmittedAt = req.OrderSubmittedAt
t.ActualUnitPriceCent = req.ActualUnitPriceCent
// Keep the assignment lock until commit. A conflicting observation
// after the irreversible boundary must reach manual resolution,
// not roll back the result or claim another task's order number.
if e := models.CheckPurchaseOrderNumber(tx, t.ID, req.PDDOrderNo); e != nil {
var conflict *models.PurchaseOrderNumberUsedError
if !errors.As(e, &conflict) {
return TaskPayload{}, conflictOrInternal(e)
}
next = models.PurchaseTaskStatusOrderResultUnknown
t.PDDOrderNo = nil
failureCode := CodeOrderNumberUsed
message := fmt.Sprintf("读到订单号 %s,但该号已属于任务 %s", req.PDDOrderNo, taskNumber(conflict.TaskID))
t.ErrorCode, t.ErrorMessage = &failureCode, &message
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode, a.ErrorMessage = &failureCode, &message
}
case "order_result_unknown":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
@@ -414,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
@@ -464,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 == "" {
@@ -473,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) {
@@ -542,10 +597,7 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec
}
t.StatusVersion++
t.StatusChangedAt = s.Now()
if e := tx.Save(&t).Error; e != nil {
return conflictOrInternal(e)
}
return nil
return tx.Save(&t).Error
})
return t, replayed, err
}
@@ -748,10 +800,6 @@ func purchaseNotFound(err error) error {
return internal(err)
}
func conflictOrInternal(err error) error {
var conflict *models.PurchaseOrderNumberUsedError
if errors.As(err, &conflict) {
return fail(CodeOrderNumberUsed, conflict.Error())
}
if isDuplicate(err) {
return fail(CodeDeviceBusy, "设备或拼多多账号已有运行任务")
}
+2 -8
View File
@@ -80,10 +80,7 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ
}
out.WritebackStatus = models.PurchaseWritebackStatusPending
out.WritebackSelectRequestID = &req.RequestID
if e := tx.Save(&out).Error; e != nil {
return conflictOrInternal(e)
}
return nil
return tx.Save(&out).Error
})
return out, replayed, err
}
@@ -169,10 +166,7 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
return internal(e)
}
}
if e := tx.Save(&out).Error; e != nil {
return conflictOrInternal(e)
}
return nil
return tx.Save(&out).Error
})
return out, replayed, err
}
@@ -1,202 +0,0 @@
package purchase
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
const (
MaxOrderBackfillItems = 50
CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID"
CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH"
CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT"
CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT"
CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED"
)
type OrderBackfillRequest struct {
RequestID string `json:"requestId"`
Items []OrderBackfillItem `json:"items"`
}
type OrderBackfillItem struct {
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo string `json:"pddOrderNo"`
// A string keeps an invalid page timestamp local to this item.
OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"`
}
type OrderBackfillResult struct {
Index int `json:"index"`
TaskID uint64 `json:"taskId,omitempty"`
Result string `json:"result"`
Code string `json:"code"`
Status string `json:"status,omitempty"`
StatusVersion uint64 `json:"statusVersion,omitempty"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
TimeSource string `json:"timeSource,omitempty"`
Retryable bool `json:"retryable"`
}
type OrderBackfillResponse struct {
RequestID string `json:"requestId"`
Items []OrderBackfillResult `json:"items"`
}
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
out := OrderBackfillResponse{RequestID: req.RequestID}
d, err := device.NewService(s.DB).Authenticate(ctx, token)
if err != nil {
return out, err
}
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
}
ids := make([]uint64, len(req.Items))
orders := make(map[uint64]string)
conflicts := make(map[uint64]bool)
for i, item := range req.Items {
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
if err != nil {
continue
}
ids[i] = id
if previous, ok := orders[id]; ok && previous != item.PDDOrderNo {
conflicts[id] = true
}
orders[id] = item.PDDOrderNo
}
out.Items = make([]OrderBackfillResult, len(req.Items))
for i, item := range req.Items {
r := OrderBackfillResult{Index: i, TaskID: ids[i], Result: "failed"}
if ids[i] == 0 {
r.Code = CodeBackfillSuffix
} else {
r = s.backfillOrder(ctx, d.ID, ids[i], req.RequestID, item, conflicts[ids[i]])
r.Index = i
}
out.Items[i] = r
}
return out, nil
}
func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
var task models.PurchaseTask
// SQL errors must not print bound order numbers or the task's address snapshot.
db := s.DB.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).WithContext(ctx)
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
return purchaseNotFound(err)
}
if task.DeviceID == nil || *task.DeviceID != deviceID {
return fail(CodeBackfillDevice, "任务不属于当前设备")
}
if batchConflict {
return fail(CodeBackfillBatchConflict, "同批任务有不同订单号")
}
if task.ExecutionMode != models.PurchaseExecutionModeLive || (task.Status != models.PurchaseTaskStatusOrderResultUnknown && task.Status != models.PurchaseTaskStatusOrderCreated) {
return fail(CodeStateConflict, "当前任务不允许回填")
}
if item.PDDOrderNo == "" || strings.TrimSpace(item.PDDOrderNo) != item.PDDOrderNo || utf8.RuneCountInString(item.PDDOrderNo) > 100 || strings.ContainsAny(item.PDDOrderNo, "\r\n\t") {
return fail(CodeInvalidRequest, "订单号无效")
}
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" && *task.PDDOrderNo != item.PDDOrderNo {
return fail(CodeBackfillOrderConflict, "已有不同订单号")
}
// The shared model guard also protects manual resolution and late results.
if err := models.CheckPurchaseOrderNumber(tx, taskID, item.PDDOrderNo); err != nil {
return err
}
if task.Status == models.PurchaseTaskStatusOrderCreated {
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
}
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
return nil
}
var submitted time.Time
source := "page"
if item.OrderSubmittedAt != nil {
var err error
submitted, err = time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
if err != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
}
} else {
if task.IrreversibleAt == nil || task.IrreversibleAt.IsZero() {
return fail(CodeOrderTimeMissing, "下单时间和不可逆时间均缺失")
}
submitted, source = *task.IrreversibleAt, "irreversible_at"
}
submitted = submitted.UTC()
task.PDDOrderNo, task.OrderSubmittedAt = &item.PDDOrderNo, &submitted
if err := task.SetStatus(models.PurchaseTaskStatusOrderCreated); err != nil {
return internal(err)
}
task.StatusVersion++
task.StatusChangedAt = s.Now()
task.ErrorCode, task.ErrorMessage = nil, nil
task.LeaseExpiresAt = nil
// Reuse the existing resolution request slot. Scope a batch UUID to a
// task, and retain provenance without a schema change or replay cache.
marker := "backfill:" + source + ":" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
task.UnknownResolveRequestID = &marker
if err := tx.Save(&task).Error; err != nil {
return err
}
r.Result, r.Code = "backfilled", "BACKFILLED"
return nil
})
if err != nil {
r.Result, r.Code = "failed", CodeInternal
r.Retryable = true
var se *ServiceError
if errors.As(err, &se) {
r.Code, r.Retryable = se.Code, se.Retryable
}
if errors.Is(err, models.ErrPurchaseOrderNumberUsed) {
r.Code, r.Retryable = CodeBackfillOrderUsed, false
}
if r.Code == CodeBackfillBatchConflict || r.Code == CodeBackfillOrderConflict || r.Code == CodeBackfillOrderUsed {
r.Result = "conflict"
}
}
// Return only this device's committed facts, including on a rejected item.
// Never return in-memory changes from a rolled back transaction.
saved := task
readable := err == nil
if !readable {
saved = models.PurchaseTask{}
readable = db.Where("id = ? AND device_id = ?", taskID, deviceID).First(&saved).Error == nil
}
if readable {
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
r.PDDOrderNo, r.OrderSubmittedAt = saved.PDDOrderNo, saved.OrderSubmittedAt
if saved.OrderSubmittedAt != nil {
r.TimeSource = "existing_unknown"
if saved.UnknownResolveRequestID != nil {
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:page:") {
r.TimeSource = "page"
}
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:irreversible_at:") {
r.TimeSource = "irreversible_at"
}
}
}
}
return r
}
@@ -1,25 +0,0 @@
package purchase
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (h Handler) BackfillOrders(c *gin.Context) {
var req OrderBackfillRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.BackfillOrders(c.Request.Context(), req, bearer(c.GetHeader("Authorization")))
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": out})
}
@@ -1,498 +0,0 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func backfillTask(t *testing.T, db *gorm.DB, f fixture, status string) models.PurchaseTask {
t.Helper()
now := testService(db).Now()
task := models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, ExecutionMode: models.PurchaseExecutionModeLive,
Status: status, DeviceID: &f.device.ID, PDDProductID: f.pdd.ID, Quantity: 1, Currency: "CNY",
CreateRequestID: uuid.NewString(), RuleSnapshot: string(purchasecontract.DefaultLiveRule()),
SpecDecisionSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`, IrreversibleAt: &now,
ErrorCode: strptr("ORIGINAL_ERROR"), ErrorMessage: strptr("original failure")}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return task
}
func strptr(s string) *string { return &s }
func backfillItem(id uint64, order string) OrderBackfillItem {
return OrderBackfillItem{AddressSuffix: purchasecontract.AddressSuffix(id), PDDOrderNo: order}
}
func runBackfill(t *testing.T, s *Service, token, requestID string, items ...OrderBackfillItem) []OrderBackfillResult {
t.Helper()
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: requestID, Items: items}, token)
if err != nil {
t.Fatal(err)
}
if len(out.Items) != len(items) || out.RequestID != requestID {
t.Fatalf("bad envelope: %+v", out)
}
return out.Items
}
func loadBackfillTask(t *testing.T, db *gorm.DB, id uint64) models.PurchaseTask {
t.Helper()
var task models.PurchaseTask
if err := db.First(&task, id).Error; err != nil {
t.Fatal(err)
}
return task
}
func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
a.TaskType, a.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&a).Error; err != nil {
t.Fatal(err)
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
c := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&c).Update("irreversible_at", nil).Error; err != nil {
t.Fatal(err)
}
page := backfillItem(b.ID, "ORDER-B")
page.OrderSubmittedAt = strptr("2026-09-08T20:30:00+08:00")
rid := uuid.NewString()
items := []OrderBackfillItem{backfillItem(a.ID, "ORDER-A"), {AddressSuffix: "_cg0", PDDOrderNo: "bad"}, page, backfillItem(c.ID, "ORDER-C"), backfillItem(99999, "missing")}
results := runBackfill(t, s, f.token, rid, items...)
want := []string{"BACKFILLED", CodeBackfillSuffix, "BACKFILLED", CodeOrderTimeMissing, CodeTaskNotFound}
for i, r := range results {
if r.Code != want[i] || r.Index != i {
t.Fatalf("item %d: %+v", i, r)
}
}
if results[0].TimeSource != "irreversible_at" || !results[0].OrderSubmittedAt.Equal(*a.IrreversibleAt) {
t.Fatalf("fallback: %+v", results[0])
}
if results[2].TimeSource != "page" || results[2].OrderSubmittedAt.Format(time.RFC3339) != "2026-09-08T12:30:00Z" {
t.Fatalf("page: %+v", results[2])
}
saved := loadBackfillTask(t, db, a.ID)
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("state metadata: %+v", saved)
}
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
t.Fatal("unrelated business facts changed")
}
for _, replayID := range []string{rid, uuid.NewString()} {
item := items[0]
item.OrderSubmittedAt = strptr("2026-09-09T00:00:00Z")
r := runBackfill(t, s, f.token, replayID, item)[0]
if r.Result != "already_backfilled" || r.TimeSource != "irreversible_at" {
t.Fatalf("replay: %+v", r)
}
if got := loadBackfillTask(t, db, a.ID); !reflect.DeepEqual(saved, got) {
t.Fatal("replay changed persisted task")
}
}
if got := loadBackfillTask(t, db, c.ID); got.PDDOrderNo != nil || got.StatusVersion != c.StatusVersion {
t.Fatal("missing time wrote data")
}
}
func TestOrderBackfillRejectsOwnershipStatesAndInvalidTime(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
for _, status := range []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted} {
task := backfillTask(t, db, f, status)
before := loadBackfillTask(t, db, task.ID)
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
if r.Code != CodeStateConflict {
t.Fatalf("%s: %+v", status, r)
}
if got := loadBackfillTask(t, db, task.ID); !reflect.DeepEqual(got, before) {
t.Fatal("rejection wrote data")
}
// Release the fixture's device slot before testing the next running state.
if err := task.SetStatus(models.PurchaseTaskStatusCancelled); err != nil {
t.Fatal(err)
}
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
}
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
t.Fatal(err)
}
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
if r.Code != CodeBackfillDevice || r.Status != "" || r.PDDOrderNo != nil {
t.Fatalf("ownership leaked: %+v", r)
}
other, err := device.NewService(db).Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "Other", Manufacturer: "Test", Model: "Test", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps()}, "")
if err != nil {
t.Fatal(err)
}
if err := db.Model(&task).Update("device_id", other.DeviceID).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeBackfillDevice {
t.Fatalf("cross device: %+v", r)
}
if err := db.Model(&task).Updates(map[string]any{"device_id": f.device.ID, "execution_mode": models.PurchaseExecutionModeRehearsal}).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeStateConflict {
t.Fatalf("rehearsal: %+v", r)
}
if err := db.Model(&task).Update("execution_mode", models.PurchaseExecutionModeLive).Error; err != nil {
t.Fatal(err)
}
for _, raw := range []string{"", "2026-09-08 12:00:00", "0001-01-01T00:00:00Z", "garbage"} {
item := backfillItem(task.ID, "ORDER")
item.OrderSubmittedAt = &raw
if r := runBackfill(t, s, f.token, uuid.NewString(), item)[0]; r.Code != CodeOrderTimeInvalid {
t.Fatalf("invalid time: %+v", r)
}
}
}
func TestOrderBackfillConflictsNeverOverwrite(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
rid := uuid.NewString()
r := runBackfill(t, s, f.token, rid, backfillItem(a.ID, "A"), backfillItem(a.ID, "B"), backfillItem(b.ID, "B"))
if r[0].Code != CodeBackfillBatchConflict || r[1].Code != CodeBackfillBatchConflict || r[2].Code != "BACKFILLED" {
t.Fatalf("batch: %+v", r)
}
r = runBackfill(t, s, f.token, rid, backfillItem(a.ID, "B"), backfillItem(b.ID, "C"))
if r[0].Code != CodeBackfillOrderUsed || r[1].Code != CodeBackfillOrderConflict {
t.Fatalf("changed requestId payload bypassed checks: %+v", r)
}
if got := loadBackfillTask(t, db, b.ID); *got.PDDOrderNo != "B" || got.StatusVersion != b.StatusVersion+1 {
t.Fatal("conflict overwrote")
}
if got := loadBackfillTask(t, db, a.ID); got.PDDOrderNo != nil {
t.Fatal("conflict wrote data")
}
// Even an unknown task with an existing conflicting value must preserve it.
a.PDDOrderNo = strptr("OLD")
if err := db.Save(&a).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "NEW"))[0]; r.Code != CodeBackfillOrderConflict {
t.Fatalf("unknown existing: %+v", r)
}
}
func TestOrderBackfillConcurrentResolveUnknown(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
// SQLite serializes transactions through one connection. These concurrent
// service calls verify both winner orders; they do not certify MySQL locks.
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
for i := 0; i < 12; i++ {
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var out OrderBackfillResponse
var backErr, manualErr error
go func() {
defer wg.Done()
<-start
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK-"+purchasecontract.AddressSuffix(task.ID))}}, f.token)
}()
go func() {
defer wg.Done()
<-start
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL-" + purchasecontract.AddressSuffix(task.ID), OrderSubmittedAt: task.IrreversibleAt})
}()
close(start)
wg.Wait()
if backErr != nil {
t.Fatal(backErr)
}
got := loadBackfillTask(t, db, task.ID)
if got.StatusVersion != task.StatusVersion+1 || got.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatal("competing writes changed version twice")
}
if manualErr == nil {
if out.Items[0].Code != CodeBackfillOrderConflict || !strings.HasPrefix(*got.PDDOrderNo, "MANUAL-") {
t.Fatalf("manual winner: %+v", out)
}
} else if code(manualErr) != CodeStateConflict || out.Items[0].Code != "BACKFILLED" || !strings.HasPrefix(*got.PDDOrderNo, "BACK-") {
t.Fatalf("backfill winner: %+v %v", out, manualErr)
}
}
}
func TestOrderBackfillConcurrentLateResultAndOtherTask(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
attempt := models.PurchaseTaskAttempt{TaskID: a.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusFailed, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(a.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&attempt, attempt.ID).Error; err != nil {
t.Fatal(err)
}
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var out OrderBackfillResponse
var backErr, lateErr error
go func() {
defer wg.Done()
<-start
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(a.ID, "BACK")}}, f.token)
}()
go func() {
defer wg.Done()
<-start
_, lateErr = s.SubmitResult(context.Background(), a.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "LATE", OrderSubmittedAt: a.IrreversibleAt}, f.token)
}()
close(start)
wg.Wait()
if backErr != nil || out.Items[0].Code != "BACKFILLED" || code(lateErr) != CodeStateConflict {
t.Fatalf("late race: %+v %v %v", out, backErr, lateErr)
}
var savedAttempt models.PurchaseTaskAttempt
if err := db.First(&savedAttempt, attempt.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(savedAttempt, attempt) {
t.Fatal("backfill rewrote attempt")
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
_, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "BACK", OrderSubmittedAt: b.IrreversibleAt})
if err == nil {
t.Fatal("manual path assigned another task's order")
}
if got := loadBackfillTask(t, db, b.ID); got.PDDOrderNo != nil || got.StatusVersion != b.StatusVersion {
t.Fatal("other task changed on conflict")
}
}
func TestOrderBackfillHTTPBoundary(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/order-backfill", device.RequireAgentHTTPS(false, false), (Handler{DB: db}).BackfillOrders)
body, _ := json.Marshal(OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "HTTP")}})
for _, test := range []struct {
body, token string
status int
}{
{string(body), "", http.StatusUnauthorized},
{`{"requestId":"bad","items":[]}`, f.token, http.StatusUnprocessableEntity},
{`{"requestId":"x","address":"forbidden"}`, f.token, http.StatusUnprocessableEntity},
{string(body), f.token, http.StatusOK},
} {
req := httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(test.body))
req.Header.Set("Authorization", "Bearer "+test.token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != test.status {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
}
_, err := testService(db).BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: make([]OrderBackfillItem, 51)}, f.token)
if code(err) != CodeInvalidRequest {
t.Fatalf("batch limit: %v", err)
}
}
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
results := make(chan OrderBackfillResponse, 2)
errors := make(chan error, 2)
for _, id := range []uint64{a.ID, b.ID} {
go func(id uint64) {
<-start
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(id, "SAME")}}, f.token)
results <- out
errors <- err
}(id)
}
close(start)
codes := make(map[string]int)
for i := 0; i < 2; i++ {
out := <-results
if err := <-errors; err != nil {
t.Fatal(err)
}
codes[out.Items[0].Code]++
}
if codes["BACKFILLED"] != 1 || codes[CodeBackfillOrderUsed] != 1 {
t.Fatalf("concurrent assignments: %+v", codes)
}
var count int64
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SAME").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("duplicate order: %d %v", count, err)
}
}
func TestOrderBackfillRejectsLateAssignmentOfSameOrder(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
t.Fatal(r)
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderSubmitStarted)
lease := s.Now().Add(time.Minute)
b.LeaseExpiresAt = &lease
if err := db.Save(&b).Error; err != nil {
t.Fatal(err)
}
attempt := models.PurchaseTaskAttempt{TaskID: b.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(b.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
t.Fatal(err)
}
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt}
out, err := s.SubmitResult(context.Background(), b.ID, req, f.token)
if err != nil || out.Status != models.PurchaseTaskStatusOrderResultUnknown {
t.Fatalf("conflicting result must commit as unknown: %+v %v", out, err)
}
got := loadBackfillTask(t, db, b.ID)
if got.Status != models.PurchaseTaskStatusOrderResultUnknown || got.StatusVersion != b.StatusVersion+1 || got.PDDOrderNo != nil {
t.Fatalf("duplicate assignment was not safely downgraded: %+v", got)
}
wantMessage := "读到订单号 SHARED,但该号已属于任务 " + taskNumber(a.ID)
if got.ErrorCode == nil || *got.ErrorCode != CodeOrderNumberUsed || got.ErrorMessage == nil || *got.ErrorMessage != wantMessage {
t.Fatalf("conflict evidence missing: %+v", got)
}
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || got.IrreversibleAt == nil || got.LeaseExpiresAt != nil || got.DeviceRunSlot != nil || got.AccountRunSlot != nil {
t.Fatalf("boundary evidence or released lease missing: %+v", got)
}
var saved models.PurchaseTaskAttempt
if err := db.First(&saved, attempt.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Status != models.PurchaseAttemptStatusFailed || saved.ResultRequestID == nil || *saved.ResultRequestID != req.RequestID || saved.ResultHash == nil || saved.ResultType == nil || *saved.ResultType != "order_created" || saved.FinishedAt == nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderNumberUsed || saved.ErrorMessage == nil || *saved.ErrorMessage != wantMessage {
t.Fatalf("attempt result and conflict evidence missing: %+v", saved)
}
out, err = s.SubmitResult(context.Background(), b.ID, req, f.token)
if err != nil || !out.Replayed || out.Status != models.PurchaseTaskStatusOrderResultUnknown || loadBackfillTask(t, db, b.ID).StatusVersion != got.StatusVersion {
t.Fatalf("unknown result replay failed: %+v %v", out, err)
}
owner := loadBackfillTask(t, db, a.ID)
if owner.PDDOrderNo == nil || *owner.PDDOrderNo != "SHARED" || owner.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("existing owner changed: %+v", owner)
}
var count int64
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SHARED").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("duplicate order: %d %v", count, err)
}
resolved, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "CORRECTED", OrderSubmittedAt: req.OrderSubmittedAt})
if err != nil || resolved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("manual resolution unavailable: %+v %v", resolved, err)
}
}
func TestOrderBackfillHTTPTransportPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, allow := range []string{"false", "true"} {
t.Setenv("GOAUTO_ALLOW_INSECURE_AGENT_HTTP", allow)
r := gin.New()
r.POST("/order-backfill", device.RequireAgentHTTPS(true, false), (Handler{}).BackfillOrders)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(`{}`)))
if allow == "false" && w.Code != http.StatusUpgradeRequired {
t.Fatalf("HTTPS bypass: %d", w.Code)
}
if allow == "true" && w.Code == http.StatusUpgradeRequired {
t.Fatal("HTTP compatibility broken")
}
}
}
func TestOrderBackfillMultiConnectionResolveRace(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(4)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var back OrderBackfillResponse
var backErr, manualErr error
req := OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK")}}
go func() { defer wg.Done(); <-start; back, backErr = s.BackfillOrders(context.Background(), req, f.token) }()
go func() {
defer wg.Done()
<-start
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL", OrderSubmittedAt: task.IrreversibleAt})
}()
close(start)
wg.Wait()
// SQLite returns table-lock errors rather than waiting on FOR UPDATE.
// Only that documented DB contention or a domain conflict is acceptable;
// after the competing calls finish, replay must converge without overwrite.
if backErr != nil && !strings.Contains(backErr.Error(), "locked") {
t.Fatal(backErr)
}
if manualErr != nil && code(manualErr) != CodeStateConflict && !strings.Contains(manualErr.Error(), "locked") {
t.Fatal(manualErr)
}
if backErr == nil && back.Items[0].Code != "BACKFILLED" && back.Items[0].Code != CodeBackfillOrderConflict && !(back.Items[0].Code == CodeInternal && back.Items[0].Retryable) {
t.Fatalf("unexpected race result: %+v", back)
}
before := loadBackfillTask(t, db, task.ID)
replay := runBackfill(t, s, f.token, req.RequestID, req.Items...)[0]
after := loadBackfillTask(t, db, task.ID)
if before.PDDOrderNo != nil && !reflect.DeepEqual(before, after) {
t.Fatal("replay overwrote the concurrent winner")
}
if after.StatusVersion != task.StatusVersion+1 || after.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatal("race did not converge to a single transition")
}
if manualErr == nil {
if *after.PDDOrderNo != "MANUAL" || replay.Code != CodeBackfillOrderConflict {
t.Fatal("manual winner overwritten")
}
} else if *after.PDDOrderNo != "BACK" || (replay.Code != "BACKFILLED" && replay.Code != "ALREADY_BACKFILLED") {
t.Fatalf("backfill did not converge: %+v", replay)
}
}
@@ -1,74 +0,0 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestOrderNumberConflictBusinessErrors(t *testing.T) {
for _, path := range []string{"resolve_unknown", "cancel", "lifecycle"} {
t.Run(path, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
owner := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(owner.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
t.Fatal(r)
}
status := models.PurchaseTaskStatusOrderResultUnknown
if path == "lifecycle" {
status = models.PurchaseTaskStatusRunning
}
task := backfillTask(t, db, f, status)
if path != "resolve_unknown" {
// Model legacy duplicate data predating the global save guard.
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"pdd_order_no": "SHARED", "lease_expires_at": s.Now().Add(time.Minute)}).Error; err != nil {
t.Fatal(err)
}
}
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "SHARED", OrderSubmittedAt: task.IrreversibleAt, Reason: "人工取消"}
var err error
switch path {
case "resolve_unknown":
_, _, err = s.ResolveUnknown(context.Background(), task.ID, req)
case "cancel":
_, _, err = s.Cancel(context.Background(), task.ID, req)
case "lifecycle":
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(task.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if e := db.Omit("Task").Create(&attempt).Error; e != nil {
t.Fatal(e)
}
_, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: req.RequestID}, f.token)
}
want := "订单号已属于任务 " + taskNumber(owner.ID)
if code(err) != CodeOrderNumberUsed || err.Error() != want {
t.Fatalf("unmapped conflict: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeError(c, err)
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
if e := json.Unmarshal(w.Body.Bytes(), &body); e != nil || w.Code != http.StatusConflict || body.Code != CodeOrderNumberUsed || body.Message != want || body.Retryable {
t.Fatalf("unexpected HTTP error: %d %s (%v)", w.Code, w.Body.String(), e)
}
got := loadBackfillTask(t, db, task.ID)
if got.Status != status || got.StatusVersion != task.StatusVersion || got.UnknownResolveRequestID != nil || got.CancelRequestID != nil || got.OrderSubmitRequestID != nil {
t.Fatalf("rejected mutation persisted: %+v", got)
}
})
}
}
@@ -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)
}
}
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// Called inside the link transaction, after locking the SYB and Shopee rows.
// Reuse the authoritative stage calculation, without AI or task creation.
func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error {
s := NewService(tx)
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
return err
}
guard, _, err := s.readOnlyPriceGuard(ctx)
if err != nil {
return err
}
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
stage := processStageFromDataset(id, dataset, preview)
syb := dataset.sybByID[id]
disabled := false
if syb.ShopeeProductID != nil {
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
if shopee.PDDProductID != nil {
pdd := dataset.pddByID[*shopee.PDDProductID]
disabled = pdd.Status == "disabled"
if _, active := dataset.activeCollectionByPDD[pdd.ID]; active {
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"}
}
}
}
if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail ||
(stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) {
return nil
}
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"}
}
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestQuickReplacementUsesCurrentStage(t *testing.T) {
for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} {
t.Run(name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
allow := name == "pending" || name == "disabled" || name == "price-missing"
switch name {
case "pending", "disabled":
db.Model(&f.pdd).Update("status", name)
case "ready":
setCollectedPDDPrice(t, db, f.pdd.ID)
case "parse-failed":
db.Model(&f.syb).Update("parse_status", "failed")
case "collecting":
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
case "purchase-pending", "purchase-running", "order-created", "order-unknown":
status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name]
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil {
t.Fatal(err)
}
}
err := ValidateQuickReplacement(context.Background(), db, f.syb.ID)
if (err == nil) != allow {
t.Fatalf("allowed=%v: %v", allow, err)
}
})
}
}
-1
View File
@@ -18,7 +18,6 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent := engine.Group("/api/agent/v1/purchase-tasks").Use(device.RequireAgentHTTPS(config.ApplicationConfig.Mode == "prod", trust))
agent.GET("", h.AgentHistory)
agent.GET("/next", h.Next)
agent.POST("/order-backfill", h.BackfillOrders)
agent.GET("/:taskId", h.AgentHistoryDetail)
agent.POST("/:taskId/retry", h.AgentRetry)
agent.POST("/:taskId/reset", h.AgentReset)
-1
View File
@@ -22,7 +22,6 @@ const (
CodeRetryStale = "PURCHASE_RETRY_STALE"
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
CodeOrderNumberUsed = "PURCHASE_ORDER_NUMBER_ALREADY_USED"
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED"
@@ -1,17 +0,0 @@
package purchasecontract
import "testing"
func TestParseAddressSuffix(t *testing.T) {
for _, id := range []uint64{7, 72, ^uint64(0)} {
got, err := ParseAddressSuffix(AddressSuffix(id))
if err != nil || got != id {
t.Fatalf("id=%d got=%d err=%v", id, got, err)
}
}
for _, raw := range []string{"", "_cg", "_cg0", "_cg00", "_cg07", "_cg+7", "_cg-7", "_cg18446744073709551616", "_CG7", "_cg7x", "_cg7_cg72", "address_cg7", " _cg7", "_cg7 ", "_cg7", "_cg7\n"} {
if id, err := ParseAddressSuffix(raw); err == nil || id != 0 {
t.Errorf("accepted %q: %d", raw, id)
}
}
}
@@ -9,7 +9,6 @@ import (
"math"
"regexp"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
@@ -361,18 +360,6 @@ func RequiredCapabilities(rule RuleSnapshot) []string {
func AddressSuffix(taskID uint64) string { return fmt.Sprintf("_cg%d", taskID) }
// ParseAddressSuffix accepts only the exact canonical suffix, never an address.
func ParseAddressSuffix(suffix string) (uint64, error) {
if !strings.HasPrefix(suffix, "_cg") {
return 0, errors.New("invalid address suffix")
}
id, err := strconv.ParseUint(strings.TrimPrefix(suffix, "_cg"), 10, 64)
if err != nil || id == 0 || AddressSuffix(id) != suffix {
return 0, errors.New("invalid address suffix")
}
return id, nil
}
func ensureEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
@@ -131,6 +131,12 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
item.Reason = "保留已确认映射"
item.Apply = true
response.MatchedCount++
case role == RoleColor && deterministicColorMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicColorMatch(value.Name, candidateNames)
item.Status = "matched"
item.Reason = "格式统一后唯一匹配"
item.Apply = true
response.MatchedCount++
case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicSizeMatch(value.Name, candidateNames)
item.Status = "matched"
@@ -237,6 +243,15 @@ func deterministicSizeMatch(value string, candidates []string) string {
return match.MappedSize
}
// Keep the original candidate label; normalization is only a comparison key.
func deterministicColorMatch(value string, candidates []string) string {
match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: value, Colors: candidates})
if !ok {
return ""
}
return match.MappedColor
}
// aiSuggestErrorMessage turns an aimatching error into a message safe to
// return to the admin UI (no credential, no raw provider body).
func aiSuggestErrorMessage(err error) string {
+30 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
@@ -48,7 +49,20 @@ type autoMatchRef struct {
// before the transaction; the transaction rechecks the complete spec context
// and PDD candidate set so a stale decision can never be written.
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
started := time.Now()
// Bound the initial settings lookup too. This budget is specific to this
// composite endpoint; other AI operations retain their existing limits.
ctx, cancel := context.WithTimeout(ctx, 1210*time.Second)
defer cancel()
seconds, err := service.autoMatchTimeout(ctx)
if err != nil {
return AutoMatchResponse{}, err
}
return service.autoMatchMappings(ctx, id, request, time.Duration(seconds)*time.Second-time.Since(started))
}
func (service *Service) autoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest, budget time.Duration) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
@@ -248,6 +262,21 @@ func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, reques
return response, nil
}
func autoMatchBudgetSeconds(providerSeconds int) int {
if providerSeconds < 3 || providerSeconds > 600 {
providerSeconds = 15 // same default as AI settings; never allow unbounded waits
}
return providerSeconds*2 + 10
}
func (service *Service) autoMatchTimeout(ctx context.Context) (int, error) {
settings, err := aimatching.NewService(service.DB).Settings(ctx)
if err != nil {
return 0, internalError(err)
}
return autoMatchBudgetSeconds(settings.TimeoutSeconds), nil
}
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
refs := make([]autoMatchRef, 0)
for _, dimension := range specs {
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
@@ -166,7 +167,7 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
@@ -0,0 +1,91 @@
package shopeeproduct
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
func TestAutoMatchBudgetFollowsProviderSetting(t *testing.T) {
for _, tc := range []struct{ ai, total int }{{3, 16}, {60, 130}, {180, 370}, {600, 1210}, {0, 40}, {601, 40}} {
if got := autoMatchBudgetSeconds(tc.ai); got != tc.total {
t.Fatalf("AI %d: got %d want %d", tc.ai, got, tc.total)
}
}
db := openTestDB(t)
setting := seedEnabledAISetting(t, "https://example.invalid", 0.9)
setting.TimeoutSeconds = 60
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-test"})
if err != nil {
t.Fatal(err)
}
detail, err := s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
if err != nil || detail.AutoMatchTimeoutSeconds != 130 {
t.Fatalf("budget detail: %d %v", detail.AutoMatchTimeoutSeconds, err)
}
body, _ := json.Marshal(detail)
if strings.Contains(string(body), "apiKey") || strings.Contains(string(body), "example.invalid") {
t.Fatal("AI configuration leaked")
}
if err := db.Model(&setting).Update("timeout_seconds", 180).Error; err != nil {
t.Fatal(err)
}
detail, err = s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
if err != nil || detail.AutoMatchTimeoutSeconds != 370 {
t.Fatalf("fresh budget: %d %v", detail.AutoMatchTimeoutSeconds, err)
}
}
func TestAutoMatchCompositeBudgetCancelsWithoutSaving(t *testing.T) {
var calls atomic.Int32
responder := chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
responder(w, r)
return
}
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
}))
defer provider.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-cancel", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}, {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "加大码", Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
started := time.Now()
_, err = s.autoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}, 100*time.Millisecond)
if err == nil || time.Since(started) > time.Second {
t.Fatalf("budget not enforced: %v", err)
}
if calls.Load() != 2 {
t.Fatalf("expected sequential color and size calls, got %d", calls.Load())
}
detail, err := s.Detail(context.Background(), created.Product.ID)
if err != nil {
t.Fatal(err)
}
if detail.Product.Specs[0].Values[0].Mapping != nil {
t.Fatal("timed-out operation saved mappings")
}
}
@@ -0,0 +1,100 @@
package shopeeproduct
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
)
func TestColorNormalizedMatchWithoutAIKeepsOriginalLabels(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"260#大红","selectable":true},{"name":"260#墨绿","selectable":true},{"name":"AbC黑色","selectable":true}]}]`
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
sources := []string{"260#大紅", "260#墨綠", "abc 黑色"}
targets := []string{"260#大红", "260#墨绿", "AbC黑色"}
values := []SpecValue{}
for _, name := range sources {
values = append(values, SpecValue{Name: name, Source: ValueSourceImport})
}
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "normalized-color", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: values}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("exact colors must not require AI: %v", err)
}
for i, item := range preview.Items {
if item.Status != "matched" || !item.Apply || item.ValueName != sources[i] || item.PDDValue != targets[i] {
t.Fatalf("unexpected preview: %+v", item)
}
}
if len(preview.Items) != 3 || preview.MatchedCount != 3 {
t.Fatal("missing exact matches")
}
var stored models.ShopeeProduct
if err := db.First(&stored, created.Product.ID).Error; err != nil {
t.Fatal(err)
}
specs, err := Unmarshal(stored.SpecsJSON)
if err != nil {
t.Fatal(err)
}
for _, value := range specs[0].Values {
if value.Mapping != nil {
t.Fatal("preview wrote mappings")
}
}
result, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
if err != nil {
t.Fatal(err)
}
if result.ConfirmedCount != 3 || result.UnmatchedCount != 0 {
t.Fatalf("unexpected counts: %+v", result)
}
for i, value := range result.Product.Specs[0].Values {
m := value.Mapping
if value.Name != sources[i] || m == nil || m.PDDValue != targets[i] || m.Source != MappingSourceExactMatch || m.Status != MappingStatusConfirmed {
t.Fatalf("incorrect persisted mapping: %+v", value)
}
}
var after models.PDDProduct
if err := db.First(&after, pdd.ID).Error; err != nil {
t.Fatal(err)
}
if after.SpecsJSON != pdd.SpecsJSON {
t.Fatal("changed original PDD specs")
}
}
func TestColorNormalizedAmbiguityAndUnavailableStillRequireAI(t *testing.T) {
for _, tc := range []struct{ name, source, specs string }{
{"ambiguous", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true},{"name":"大紅","selectable":true}]}]`},
{"unavailable", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":false},{"name":"黑色","selectable":true}]}]`},
{"unmatched", "深紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true}]}]`},
} {
t.Run(tc.name, func(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = tc.specs
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: tc.name, PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: tc.source, Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
_, err = service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err == nil || errCode(t, err) != CodeAIUnavailable {
t.Fatalf("must fall back to AI, not guess: %v", err)
}
})
}
}
+23 -3
View File
@@ -14,7 +14,10 @@ import (
"gorm.io/gorm"
)
type Handler struct{ DB *gorm.DB }
type Handler struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func (handler Handler) List(c *gin.Context) {
page, err := queryInt(c.Query("page"), 1)
@@ -31,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)
@@ -64,7 +77,7 @@ func (handler Handler) Detail(c *gin.Context) {
if !ok {
return
}
response, err := service.Detail(c.Request.Context(), id)
response, err := service.DetailWithAutoMatchBudget(c.Request.Context(), id)
respond(c, response, err)
}
@@ -96,10 +109,17 @@ func (handler Handler) LinkPDD(c *gin.Context) {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
// This composite workflow reads SYB and device context. It must not inherit
// a client key's Shopee-only write grant through the shared handler.
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
return
}
service, ok := handler.service(c)
if !ok {
return
}
service.ReplacementEligibility = handler.ReplacementEligibility
response, err := service.LinkPDD(c.Request.Context(), id, request)
respond(c, response, err)
}
@@ -467,7 +487,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusConflict
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
status = http.StatusNotFound
case CodePDDProductDisabled, CodeSpecContextStale:
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
status = http.StatusConflict
case CodeAIUnavailable:
status = http.StatusServiceUnavailable
@@ -0,0 +1,190 @@
package shopeeproduct
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
const CodeLinkConflict = "PDD_LINK_CONFLICT"
type LatestCollectionRequest struct {
SYBProductID uint64 `json:"sybProductId"`
DeviceID uint64 `json:"deviceId"`
SpecContextVersion string `json:"specContextVersion"`
Replacement *LatestCollectionReplacement `json:"replacement,omitempty"`
}
type LatestCollectionReplacement struct {
Preview bool `json:"preview"`
ExpectedPDDProductID uint64 `json:"expectedPddProductId"`
TaskID uint64 `json:"taskId"`
TargetPDDProductID uint64 `json:"targetPddProductId"`
TargetVersion string `json:"targetVersion"`
}
type LatestCollectionSource struct {
TaskID uint64 `json:"taskId"`
DeviceID uint64 `json:"deviceId"`
PDDProductID uint64 `json:"pddProductId"`
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
TargetVersion string `json:"targetVersion,omitempty"`
Title string `json:"title,omitempty"`
GoodsID string `json:"goodsId,omitempty"`
PreviousTitle string `json:"previousTitle,omitempty"`
PreviousGoodsID string `json:"previousGoodsId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
}
func latestUnavailable(message string) error {
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
}
// Resolve the candidate exactly once under short row locks; never hold a
// transaction while calling AI. The returned context belongs to this write,
// not a later GET that could silently adopt another operator's association.
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
r := request.LatestCollection
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
}
var response SaveResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("SYB 明细不存在,请刷新列表")
}
return err
}
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
return contextVersionStale()
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return productNotFound()
}
return err
}
replacement := r.Replacement
var old models.PDDProduct
if replacement != nil {
if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID {
return contextVersionStale()
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil {
return err
}
if service.ReplacementEligibility == nil {
return invalidRequest("一键替换入口未配置")
}
if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil {
return err
}
if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") {
return invalidRequest("请先预览并确认替换商品")
}
} else if shopee.PDDProductID != nil {
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
}
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion {
return contextVersionStale()
}
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("手机已不存在,请重新选择")
}
return err
}
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
return latestUnavailable("手机已停用,请重新选择")
}
var task models.CollectionTask
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
if replacement != nil && !replacement.Preview {
query = query.Where("id = ?", replacement.TaskID)
}
err := query.Order("finished_at DESC, id DESC").First(&task).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
}
if err != nil {
return err
}
if task.FinishedAt == nil || task.PDDProductID == nil {
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("最近采集的 PDD 商品已不存在")
}
return err
}
if pdd.Status != "active" {
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
}
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
if err != nil {
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
}
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
if err != nil || len(color)+len(size) == 0 {
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
}
if task.Status == models.TaskStatusCompletedPartial {
if err := validatePartialLinkResult(tx, task, color, size); err != nil {
return err
}
}
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
return contextVersionStale()
}
// Preview never writes. Same-product continuation also preserves the
// existing association and request marker; matching is a separate call.
if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) {
query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id)
if replacement == nil {
query = query.Where("pdd_product_id IS NULL")
} else {
query = query.Where("pdd_product_id = ?", old.ID)
}
result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return contextVersionStale()
}
}
response, err = NewService(tx).Detail(ctx, id)
if err != nil {
return err
}
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
if replacement != nil {
response.CollectionSource.TargetVersion = targetVersion
response.CollectionSource.Title = pdd.Title
response.CollectionSource.GoodsID = pdd.GoodsID
response.CollectionSource.PreviousTitle = old.Title
response.CollectionSource.PreviousGoodsID = old.GoodsID
response.CollectionSource.DeviceName = device.Name
}
return nil
})
if err != nil {
var domain *ServiceError
if errors.As(err, &domain) {
return SaveResponse{}, err
}
return SaveResponse{}, internalError(err)
}
return response, nil
}
@@ -0,0 +1,194 @@
package shopeeproduct
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
t.Helper()
db := openTestDB(t)
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
pdd := seedPDDProduct(t, db, "active")
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
if err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
}
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
s, id, req, task := latestFixture(t)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil {
t.Fatal(err)
}
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
t.Fatalf("invalid frozen response: %+v", result)
}
// Another request (or an ambiguous network retry) cannot overwrite the link.
req.RequestID = uuid.NewString()
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
t.Fatalf("expected conflict: %v", err)
}
other := seedPDDProduct(t, s.DB, "active")
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
t.Fatalf("manual replacement regressed: %v", err)
}
}
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
cases := []struct {
name string
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
}{
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("status", "completed_partial")
}},
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
}},
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("source", "admin")
}},
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
}},
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
}},
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
r.LatestCollection.SpecContextVersion = "stale"
}},
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, id, r, task := latestFixture(t)
tc.change(s.DB, &r, task)
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("unexpected successful write")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
s, id, r, first := latestFixture(t)
newer := first
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("skipped latest partial")
}
// A newer ID with an older completion time must not win.
older := newer
older.ID = 0
older.Status = models.TaskStatusCompleted
ts := first.FinishedAt.Add(-time.Hour)
older.FinishedAt = &ts
if err := s.DB.Create(&older).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("sorted by id instead of completion")
}
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.TaskID != newer.ID {
t.Fatalf("wrong latest result: %+v %v", result, err)
}
}
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
for _, status := range []string{"success", "uncertain", "failed"} {
t.Run(status, func(t *testing.T) {
s, id, r, _ := latestFixture(t)
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
t.Fatalf("parse boundary: %+v %v", result, err)
}
})
}
}
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
if w.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
}
}
// SQLite has no row-level FOR UPDATE. A single test connection serializes
// transactions to exercise concurrent callers; production MySQL locking must
// additionally be verified in its deployment environment.
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
s, id, r, _ := latestFixture(t)
sqlDB, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
errors := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
}
close(start)
wins := 0
for range 2 {
err := <-errors
if err == nil {
wins++
} else if errCode(t, err) != CodeLinkConflict {
t.Fatal(err)
}
}
if wins != 1 {
t.Fatalf("expected one write, got %d", wins)
}
}
@@ -0,0 +1,93 @@
package shopeeproduct
import (
"encoding/json"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// Only auxiliary metadata may be missing. Validate the source task, not the
// merged product archive, which can retain data from an older collection.
func validatePartialLinkResult(tx *gorm.DB, task models.CollectionTask, colors, sizes map[string]bool) error {
reject := func() error {
return latestUnavailable("最近一次采集的规格或价格仍不完整,请先补采;不会回退关联更早的商品")
}
var missing []string
if task.MissingJSON == nil || json.Unmarshal([]byte(*task.MissingJSON), &missing) != nil || len(missing) == 0 {
return reject()
}
for _, field := range missing {
switch field {
case "salesText", "shopName", "reviewCount":
default:
return reject()
}
}
var dimensions []models.CollectionDimension
if err := tx.Where("task_id = ?", task.ID).Find(&dimensions).Error; err != nil {
return err
}
values := map[string]map[string]bool{}
for _, dimension := range dimensions {
if (dimension.Key != RoleColor && dimension.Key != RoleSize) || values[dimension.Key] != nil {
return reject()
}
var rows []models.CollectionDimensionValue
if err := tx.Where("dimension_id = ?", dimension.ID).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return reject()
}
values[dimension.Key] = map[string]bool{}
for _, row := range rows {
if strings.TrimSpace(row.Value) == "" {
return reject()
}
values[dimension.Key][row.Value] = true
}
}
if len(values) == 0 {
return reject()
}
var skus []models.CollectionSKU
if err := tx.Where("task_id = ? AND available = ?", task.ID, true).Find(&skus).Error; err != nil {
return err
}
if len(skus) == 0 {
return reject()
}
covered := map[string]map[string]bool{RoleColor: {}, RoleSize: {}}
for _, sku := range skus {
var specs map[string]string
if !sku.Complete || sku.PriceCent < 0 || json.Unmarshal([]byte(sku.SpecKey), &specs) != nil || len(specs) != len(values) {
return reject()
}
for role, value := range specs {
if !values[role][value] {
return reject()
}
covered[role][value] = true
}
}
var prices []models.CollectionColorPrice
if err := tx.Where("task_id = ?", task.ID).Find(&prices).Error; err != nil {
return err
}
priced := map[string]bool{}
for _, price := range prices {
if price.PriceCent >= 0 {
priced[price.Color] = true
}
}
for role, candidates := range map[string]map[string]bool{RoleColor: colors, RoleSize: sizes} {
for value := range candidates {
if !covered[role][value] || (role == RoleColor && !priced[value]) {
return reject()
}
}
}
return nil
}
@@ -0,0 +1,121 @@
package shopeeproduct
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func seedPartialResult(t *testing.T, db *gorm.DB, task models.CollectionTask, missing string, single bool) {
t.Helper()
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusCompletedPartial, "missing_json": missing}).Error; err != nil {
t.Fatal(err)
}
dimensions := map[string][]string{"color": {"黑色"}, "size": {"M", "L", "XL", "2XL"}}
if single {
delete(dimensions, "size")
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"黑色","selectable":true,"priceCent":1000}]}]`).Error; err != nil {
t.Fatal(err)
}
}
for role, values := range dimensions {
d := models.CollectionDimension{TaskID: task.ID, Key: role, Name: role}
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
for _, value := range values {
if err := db.Create(&models.CollectionDimensionValue{DimensionID: d.ID, Value: value}).Error; err != nil {
t.Fatal(err)
}
}
}
if err := db.Create(&models.CollectionColorPrice{TaskID: task.ID, Color: "黑色", PriceCent: 1000}).Error; err != nil {
t.Fatal(err)
}
sizes := dimensions["size"]
if single {
sizes = []string{""}
}
for _, size := range sizes {
specs := map[string]string{"color": "黑色"}
if size != "" {
specs["size"] = size
}
raw, _ := json.Marshal(specs)
if err := db.Create(&models.CollectionSKU{TaskID: task.ID, SpecKey: string(raw), PriceCent: 1000, Available: true, Complete: true}).Error; err != nil {
t.Fatal(err)
}
}
}
func TestPartialAuxiliaryMetadataAllowsLinkAndKeepsStatus(t *testing.T) {
for _, missing := range []string{`["salesText"]`, `["shopName","reviewCount","salesText"]`} {
t.Run(missing, func(t *testing.T) {
s, id, req, task := latestFixture(t)
seedPartialResult(t, s.DB, task, missing, false)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || result.CollectionSource.TaskID != task.ID {
t.Fatalf("link: %+v %v", result, err)
}
s.DB.First(&task, task.ID)
if task.Status != models.TaskStatusCompletedPartial || *task.MissingJSON != missing {
t.Fatal("changed historical completeness")
}
})
}
}
func TestPartialMetadataAllowsSingleDimensionAndReplacement(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
seedPartialResult(t, s.DB, task, `["salesText"]`, true)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview wrote")
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID {
t.Fatalf("replacement: %+v %v", result, err)
}
}
func TestPartialMissingOrResultDefectsStillReject(t *testing.T) {
for _, scenario := range []string{"[]", "null", "bad-json", `["salesText","size"]`, `["title"]`, `["price:黑色"]`, `["skuLimit:12>8"]`, `["unknown"]`, "sku-incomplete", "sku-unavailable", "sku-invalid", "no-current-price", "old-archive-value"} {
t.Run(scenario, func(t *testing.T) {
s, id, req, task := latestFixture(t)
missing := scenario
if scenario[0] != '[' && scenario != "null" && scenario != "bad-json" {
missing = `["salesText"]`
}
seedPartialResult(t, s.DB, task, missing, false)
switch scenario {
case "sku-incomplete":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("complete", false)
case "sku-unavailable":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("available", false)
case "sku-invalid":
var sku models.CollectionSKU
if err := s.DB.Where("task_id = ?", task.ID).First(&sku).Error; err != nil {
t.Fatal(err)
}
if err := s.DB.Model(&sku).Update("spec_key", `{}`).Error; err != nil {
t.Fatal(err)
}
case "no-current-price":
s.DB.Model(&models.CollectionColorPrice{}).Where("task_id = ?", task.ID).Update("color", "不是本次颜色")
case "old-archive-value":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","values":[{"name":"旧颜色","selectable":true,"priceCent":1000}]}]`)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted incomplete current result")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
@@ -0,0 +1,145 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) {
s, id, req, task := latestFixture(t)
old := seedPDDProduct(t, s.DB, "pending")
if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), id)
if err != nil {
t.Fatal(err)
}
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID}
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil }
return s, id, req, task, old
}
func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse {
t.Helper()
preview, err := s.LinkPDD(context.Background(), id, *req)
if err != nil {
t.Fatal(err)
}
r := req.LatestCollection.Replacement
r.Preview = false
r.TaskID = preview.CollectionSource.TaskID
r.TargetPDDProductID = preview.CollectionSource.PDDProductID
r.TargetVersion = preview.CollectionSource.TargetVersion
return preview
}
func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview changed association")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil {
t.Fatal("preview wrote")
}
newer := task
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID {
t.Fatalf("frozen candidate: %+v %v", result, err)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("duplicate replaced stale association")
}
}
func TestReplacementSameProductDoesNotRebind(t *testing.T) {
s, id, req, task, _ := replacementFixture(t)
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID)
detail, _ := s.Detail(context.Background(), id)
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID
previewReplacement(t, s, id, &req)
if _, err := s.LinkPDD(context.Background(), id, req); err != nil {
t.Fatal(err)
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if record.LastUpdateRequestID != nil {
t.Fatal("same product wrote association")
}
}
func TestReplacementRejectsChangesAfterPreview(t *testing.T) {
for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} {
t.Run(change, func(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
previewReplacement(t, s, id, &req)
expected := old.ID
switch change {
case "association":
other := seedPDDProduct(t, s.DB, "active")
expected = other.ID
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected)
case "specs":
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]")
case "target-specs":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`)
case "task-status":
s.DB.Model(&task).Update("status", "completed_partial")
case "device":
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled")
case "stage":
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} }
case "missing-validator":
s.ReplacementEligibility = nil
case "target-id":
req.LatestCollection.Replacement.TargetPDDProductID++
case "task-id":
req.LatestCollection.Replacement.TaskID++
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted changed context")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != expected {
t.Fatal("overwrote rejected association")
}
})
}
}
func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) {
s, id, req, _, _ := replacementFixture(t)
previewReplacement(t, s, id, &req)
connection, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim.
results := make(chan error, 2)
for range 2 {
go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }()
}
wins := 0
for range 2 {
if <-results == nil {
wins++
}
}
if wins != 1 {
t.Fatalf("writes=%d", wins)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) {
handler := Handler{ReplacementEligibility: eligibility}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
+32 -7
View File
@@ -57,7 +57,12 @@ func (err *ServiceError) Error() string {
}
func (err *ServiceError) Unwrap() error { return err.Cause }
type Service struct{ DB *gorm.DB }
type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
type Service struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
@@ -76,8 +81,10 @@ type ProductView struct {
}
type SaveResponse struct {
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
}
type ListItemView struct {
@@ -156,6 +163,16 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Save
return SaveResponse{Product: view}, err
}
// DetailWithAutoMatchBudget exposes only a derived time budget, never AI settings.
func (service *Service) DetailWithAutoMatchBudget(ctx context.Context, id uint64) (SaveResponse, error) {
response, err := service.Detail(ctx, id)
if err != nil {
return SaveResponse{}, err
}
response.AutoMatchTimeoutSeconds, err = service.autoMatchTimeout(ctx)
return response, err
}
// ---------------------------------------------------------------- update (archive fields only)
type UpdateRequest struct {
@@ -214,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) {
@@ -240,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)
@@ -262,8 +283,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
// ---------------------------------------------------------------- link PDD
type LinkPDDRequest struct {
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
}
// LinkPDD sets or changes the linked PDD product via the search-and-select
@@ -274,6 +296,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
}
if request.LatestCollection != nil {
return service.linkLatestCollection(ctx, id, request)
}
if request.PDDProductID == 0 {
return SaveResponse{}, invalidRequest("pddProductId 必填")
}
@@ -282,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)
+3
View File
@@ -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
+15 -6
View File
@@ -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}
}
+3
View File
@@ -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
+59 -5
View File
@@ -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)
+7 -2
View File
@@ -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
}
}
+2
View File
@@ -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")
}
}
+19
View File
@@ -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 {
+5 -3
View File
@@ -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 {
@@ -255,7 +255,9 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
}
now := service.Now()
if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{
// Validate the locked task's real state; an empty model fails BeforeSave
// when a retried current-page task already has a product binding.
if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{
"identify_request_id": request.RequestID, "identity_resolved_at": now,
}).Error; err != nil {
return internalError(err)
@@ -0,0 +1,153 @@
package task
import (
"context"
"reflect"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
)
func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) {
db := openTaskDatabase(t)
_, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{
rulecontract.CapabilitySchemaV2,
rulecontract.CapabilityPDDProductDetailV1,
rulecontract.CapabilityPDDCurrentPageShareV1,
})
_, otherToken := registerTaskDevice(t, db, "other-device")
rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatal(err)
}
service := newTaskService(db)
ctx := context.Background()
created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if err != nil {
t.Fatal(err)
}
load := func() models.CollectionTask {
t.Helper()
var record models.CollectionTask
if err := db.First(&record, created.TaskID).Error; err != nil {
t.Fatal(err)
}
return record
}
request := CurrentPageIdentifyRequest{
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255",
}
// Direct links resolve locally: the regression never calls PDD or a live DB.
identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatal(err)
}
const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED"
if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{
RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure",
}, token); err != nil {
t.Fatal(err)
}
reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token)
if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending {
t.Fatalf("same-task reset: %+v, %v", reset, err)
}
resetRecord := load()
if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID ||
resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil {
t.Fatal("reset must keep the product and clear per-attempt identification")
}
if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
before := load()
request.RequestID = uuid.NewString()
for _, tc := range []struct {
name, token, url, code string
}{
{"other device", otherToken, request.ShareURL, CodeTaskNotFound},
{"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token)
if taskErrorCode(t, err) != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
if !reflect.DeepEqual(before, load()) {
t.Fatal("rejected identification changed the task")
}
})
}
reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatalf("reidentify same product after retry: %v", err)
}
if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID ||
reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed {
t.Fatalf("identity changed: %+v", reidentified)
}
after := load()
if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil {
t.Fatal("new attempt identification was not persisted")
}
// Apart from identity metadata and the normal update timestamp, every field
// (including status, device, rule, lease and uniqueness slots) must survive.
expected := before
expected.IdentifyRequestID = after.IdentifyRequestID
expected.IdentityResolvedAt = after.IdentityResolvedAt
expected.UpdatedAt = after.UpdatedAt
if !reflect.DeepEqual(expected, after) {
t.Fatal("reidentification changed unrelated task fields")
}
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
t.Fatal("replay must not resolve the link again")
return ResolvedPDDShare{}, nil
}
replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) {
t.Fatalf("replay changed identity or task: %+v, %v", replay, err)
}
service.ResolveCurrentPageShare = nil
title := "Synthetic collected product"
result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
}, token)
if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted {
t.Fatalf("complete retried collection: %+v, %v", result, err)
}
terminal := load()
request.RequestID = uuid.NewString()
if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict {
t.Fatalf("expected terminal-state rejection: %v", err)
}
if !reflect.DeepEqual(terminal, load()) {
t.Fatal("terminal identification changed the task")
}
var archived []models.CollectionTaskAttempt
if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil {
t.Fatal(err)
}
if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed ||
archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode {
t.Fatal("original failed attempt was not preserved")
}
for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} {
var count int64
if err := db.Model(model).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("retry duplicated task/product: count=%d, %v", count, err)
}
}
}
+373
View File
@@ -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(&current, 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)
}
}
+3
View File
@@ -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
+1
View File
@@ -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)
+18 -4
View File
@@ -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
})
}
+1
View File
@@ -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 }) }
+9 -6
View File
@@ -8,16 +8,16 @@ export function createShopeeProduct(data) {
return request({ url: '/api/admin/v1/shopee-products', method: 'post', data })
}
export function getShopeeProduct(productId) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get' })
export function getShopeeProduct(productId, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get', ...options })
}
export function updateShopeeProduct(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
}
export function linkShopeeProductPdd(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
export function linkShopeeProductPdd(productId, data, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
}
export function restoreShopeeProduct(productId, data) {
@@ -66,8 +66,11 @@ export function suggestShopeeSizeMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
}
export function autoMatchShopeeSpecMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
export function autoMatchShopeeSpecMappings(productId, data, timeoutSeconds) {
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 16 || timeoutSeconds > 1210) {
return Promise.reject(new Error('匹配等待时间不可用,请刷新页面后重试'))
}
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: (timeoutSeconds + 10) * 1000, suppressErrorMessage: true })
}
export function startShopeeSpecAutoMatchRun(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' }) }
@@ -0,0 +1,24 @@
// Separate from the purchase-device preference; browser origin provides the
// outer isolation and API environment + user identify the inner namespace.
function key(userId) {
const id = Number(userId)
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
}
export function readQuickLinkDevice(userId) {
try {
const name = key(userId)
if (!name) return null
const id = JSON.parse(localStorage.getItem(name) || 'null')
return Number.isSafeInteger(id) && id > 0 ? id : null
} catch { return null }
}
export function rememberQuickLinkDevice(userId, deviceId) {
try {
const name = key(userId)
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
localStorage.setItem(name, JSON.stringify(deviceId))
return true
} catch { return false }
}
@@ -13,8 +13,8 @@
<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" @click="openEdit">编辑档案</el-button></div>
<el-descriptions :column="2" border>
<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>
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item>
@@ -27,7 +27,10 @@
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
<el-alert v-if="detail.matchMessage" :title="detail.matchMessage" :type="detail.matchMessageType" :closable="false" show-icon class="notice" role="status" aria-live="polite">
<template #default><el-button v-if="!detail.aiMatching" link type="primary" @click="reload">刷新查看</el-button></template>
</el-alert>
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs" :inert="detail.aiMatching">
<el-tab-pane label="颜色匹配" name="color">
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
@@ -48,10 +51,10 @@
</el-tab-pane>
</el-tabs>
<div class="section-heading"><h3>添加规格值</h3></div>
<div class="quick-row"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
<div class="quick-row" :inert="detail.aiMatching"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
</template>
</div>
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="!hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="detail.aiMatching || !hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
</el-drawer>
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" append-to-body :close-on-click-modal="false">
@@ -95,7 +98,7 @@ export default {
emits: ['update:modelValue', 'product-updated', 'mapping-updated', 'pdd-linked', 'purchase-created', 'switch-cancelled'],
data() {
return {
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null,
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null, matchOperation: 0,
pddPicker: { open: false, loading: false, keyword: '', items: [] }, pddDetail: { open: false, productId: null },
edit: { open: false, saving: false, productId: null }, editData: { title: '', shopName: '', imageUrl: '', salePriceCent: null },
addValueForm: { dimension: '', role: 'other', name: '' }
@@ -105,7 +108,7 @@ export default {
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
canAutoMatch() { return !this.detail.aiMatching && !this.detail.loading && !this.detail.saving && !this.edit.open && !this.pddPicker.open && !this.pddDetail.open && this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
},
watch: {
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
@@ -114,17 +117,17 @@ export default {
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
},
methods: {
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
emptyDetail() { return { loading: false, saving: false, aiMatching: false, matchMessage: '', matchMessageType: 'info', product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
isCurrent(generation, id) { return this.modelValue && this.detailDrawerOpen && generation === this.requestGeneration && Number(this.productId) === Number(id) },
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
async requestSwitch(id) {
if (!this.validProductId(id)) { this.loadError = '虾皮商品编号无效'; this.detailDrawerOpen = true; return }
if (this.loadedProductId === Number(id) && this.detail.product) { this.detailDrawerOpen = true; return }
if (this.detail.product && !await this.confirmDiscard()) { this.$emit('switch-cancelled', this.loadedProductId); return }
await this.loadProduct(Number(id))
},
async loadProduct(id) {
this.finishMatchView()
const generation = ++this.requestGeneration
this.loadedProductId = null; this.detailDrawerOpen = true; this.detail = { ...this.emptyDetail(), loading: true, highlightColor: String(this.targetColor || '').trim() }; this.loadError = ''; this.pddPicker.open = false; this.pddDetail.open = false
try {
@@ -138,13 +141,14 @@ export default {
this.loadError = error?.response?.data?.message || error?.response?.data?.msg || '虾皮商品详情加载失败,请稍后重试'
} finally { if (this.isCurrent(generation, id)) this.detail.loading = false }
},
reload() { return this.loadProduct(Number(this.productId)) },
closeInternals() { this.requestGeneration++; this.detailDrawerOpen = false; this.pddPicker.open = false; this.pddDetail.open = false; this.edit.open = false },
async beforeCloseDetail(done) { if (this.pddDetail.open) return; if (!await this.confirmDiscard()) return; done(); this.requestGeneration++; this.$emit('update:modelValue', false) },
async reload() { if (this.detail.aiMatching || !await this.confirmDiscard()) return; return this.loadProduct(Number(this.productId)) },
finishMatchView() { this.matchOperation++; this.detail.aiMatching = false; this.detail.matchMessage = '' },
closeInternals() { this.finishMatchView(); this.requestGeneration++; this.detailDrawerOpen = false; this.pddPicker.open = false; this.pddDetail.open = false; this.edit.open = false },
async beforeCloseDetail(done) { if (this.pddDetail.open) return; if (!await this.confirmDiscard()) return; this.finishMatchView(); done(); this.requestGeneration++; this.$emit('update:modelValue', false) },
closeDetail() { this.beforeCloseDetail(() => { this.detailDrawerOpen = false }) },
withDraftMappings(product) { return { ...product, specs: (product.specs || []).map(d => ({ ...d, values: d.values.map(v => ({ ...v, dimensionName: d.name, role: d.role, pddValueDraft: v.mapping ? v.mapping.pddValue : '', originalPddValue: v.mapping ? v.mapping.pddValue : '', draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '', draftConfidence: null })) })) } },
async loadLinkedPdd(pddId, generation = this.requestGeneration, productId = this.loadedProductId) { this.detail.pddLoaded = false; this.detail.pddProduct = null; try { const r = await getPddProduct(pddId); if (!this.isCurrent(generation, productId)) return; this.detail.pddProduct = r.data.product } finally { if (this.isCurrent(generation, productId)) this.detail.pddLoaded = true } },
async refreshDetail(eventName = 'mapping-updated') { const id = this.loadedProductId; const generation = this.requestGeneration; const r = await getShopeeProduct(id); if (!this.isCurrent(generation, id)) return; this.detail.product = this.withDraftMappings(r.data.product); if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id); this.$emit(eventName, { id, product: this.detail.product }) },
async refreshDetail(eventName = 'mapping-updated') { const id = this.loadedProductId; const generation = this.requestGeneration; const r = await getShopeeProduct(id, { suppressErrorMessage: this.detail.aiMatching }); if (!this.isCurrent(generation, id)) return; this.detail.product = this.withDraftMappings(r.data.product); if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id); if (this.isCurrent(generation, id)) this.$emit(eventName, { id, product: this.detail.product }) },
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` },
mappingMeta(product) { if (!product) return { label: '-', type: 'info' }; if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping && v.mapping.status === 'confirmed' && (!this.detail.pddLoaded || this.pddValueExists(v.role || '', v.mapping.pddValue))).length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
openPddPicker() { this.pddPicker = { open: true, loading: false, keyword: '', items: [] }; this.searchPddPicker() },
@@ -161,7 +165,60 @@ export default {
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
async autoMatchMappings() {
if (!this.canAutoMatch) return
const id = this.loadedProductId
const generation = this.requestGeneration
const operation = ++this.matchOperation
const current = () => this.isCurrent(generation, id) && operation === this.matchOperation
this.detail.aiMatching = true
this.detail.matchMessage = '正在读取最新规格与等待时间'
this.detail.matchMessageType = 'info'
let submitted = false
let received = false
try {
const prepared = await getShopeeProduct(id, { suppressErrorMessage: true })
if (!current()) return
const seconds = prepared.data.autoMatchTimeoutSeconds
if (!Number.isInteger(seconds) || seconds < 16 || seconds > 1210) throw new Error('匹配等待时间不可用,请确认服务端已更新后刷新重试')
this.detail.product = this.withDraftMappings(prepared.data.product)
this.detail.matchMessage = `正在匹配并保存,最多等待 ${seconds + 10} 秒`
submitted = true
const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: prepared.data.product.specContextVersion }, seconds)
if (!current()) return
received = true
this.detail.matchMessage = '匹配请求已完成,正在读取保存结果'
await this.refreshDetail()
if (!current()) return
const unmatched = Number(r.data.unmatchedCount || 0)
const message = `新匹配并确认 ${Number(r.data.confirmedCount || 0)} 项,保留已确认 ${Number(r.data.preservedCount || 0)} 项,未匹配 ${unmatched} 项`
this.detail.matchMessage = message
this.detail.matchMessageType = unmatched ? 'warning' : 'success'
if (unmatched) ElMessage.warning(message); else ElMessage.success(message)
} catch (error) {
if (!current()) return
this.detail.matchMessageType = 'warning'
if (!submitted) {
this.detail.matchMessage = error?.message || '读取匹配设置失败,请刷新后重试'
return
}
// A lost response is not proof of a rollback. Read once, never retry POST.
if (received) {
this.detail.matchMessage = '匹配请求已完成,但结果暂未确认,请刷新查看'
return
}
this.detail.matchMessage = '匹配请求超时或未正常返回,正在核对保存结果'
try {
await this.refreshDetail()
if (!current()) return
this.detail.matchMessage = '已刷新当前保存结果;原请求未正常返回,请检查未匹配项后再操作'
} catch {
if (current()) this.detail.matchMessage = '结果暂未确认,请刷新查看;不要重复提交匹配'
}
} finally {
if (current()) this.detail.aiMatching = false
}
},
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
@@ -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>
+85 -8
View File
@@ -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>
@@ -25,12 +26,54 @@
<el-table-column label="数量" prop="quantity" width="56" />
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else><el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag><div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div><div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div><el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button></template></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210">
<template #default="{ row }">
<span v-if="purchaseReadinessLoading" class="muted">正在检查</span>
<template v-else>
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
<div class="quick-link-actions">
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
<el-button-group v-if="quickCanLink(row)">
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">{{ quickActionLabel(row) }}</el-button>
<el-dropdown trigger="click" @command="quickOpenPicker()">
<el-button size="small" type="primary" plain :aria-label="`${quickActionLabel(row)}使用的手机`" :icon="ArrowDown" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
<el-dropdown-item command="switch">切换手机</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</div>
</template>
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</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" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
<p>{{ quickPicker.row ? '选择后继续处理刚才的商品;更换为其他商品前会再次确认。' : '只修改各行共用的手机,不启动关联或替换,也不影响正在进行的操作。' }}</p>
<el-form label-width="80px">
<el-form-item label="采集手机">
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
<el-option v-for="device in quickDevices" :key="device.id" :value="device.id" :label="`${device.name}(#${device.id} · ${device.status === 'online' ? '在线' : '离线'})`" />
</el-select>
</el-form-item>
</el-form>
<p class="muted">使用这台手机最近一次成功的临时采集;离线也可使用已有结果。关联会作用于共用该蝦皮商品的 SYB 明细。</p>
<p v-if="quickDeviceError" role="alert">{{ quickDeviceError }} <el-button link type="primary" @click="quickOpenPicker(quickPicker.row)">重试</el-button></p>
<template #footer>
<el-button @click="quickCancelPicker">取消</el-button>
<el-button type="primary" :disabled="quickDeviceLoading || !!quickDeviceError || !quickDevices.some(d => d.id === quickPicker.deviceId)" @click="quickSaveDevice">{{ quickPicker.row ? '选择并继续' : '保存选择' }}</el-button>
</template>
</el-dialog>
<!-- 批量创建采购任务确认 -->
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
@@ -73,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" />
@@ -118,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">
@@ -171,12 +220,13 @@
<script>
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
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'
@@ -186,7 +236,8 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
setup() { return { Search, RefreshLeft } },
mixins: [quickLink],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
@@ -198,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: '' },
@@ -222,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 ''
@@ -314,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 || '') }
@@ -415,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) {
@@ -513,6 +588,7 @@ export default {
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;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}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -530,3 +606,4 @@ export default {
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
@media(max-width:900px){.split{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,161 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDevices } from '@/api/goauto/devices'
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
import { readQuickLinkDevice, rememberQuickLinkDevice } from '@/utils/quick-link-device-preference'
const readOptions = { timeout: 10000, suppressErrorMessage: true }
export default {
data() {
return {
quickDeviceId: null, quickDevices: [], quickDeviceError: '', quickDeviceLoading: false,
quickPicker: { open: false, deviceId: null, row: null },
quickBusyId: null, quickPhase: '', quickMessages: {}, quickGeneration: 0, quickPickerGeneration: 0
}
},
created() { this.quickDeviceId = readQuickLinkDevice(this.$store.getters.userId) },
beforeUnmount() { this.quickInvalidate() },
deactivated() { this.quickInvalidate() },
methods: {
quickInvalidate() {
if (this.quickPhase === '等待替换确认') ElMessageBox.close()
this.quickGeneration++
this.quickPickerGeneration++
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
this.quickBusyId = null
this.quickPhase = ''
this.quickPicker.open = false
},
quickCanLink(row) {
const ready = this.purchaseReady(row)
return this.canPurchase && !!row.shopeeProductId && ((ready.processNextAction === 'open_pdd_link' && !ready.pddProductId) || this.quickCanReplace(row))
},
quickCanReplace(row) {
const ready = this.purchaseReady(row)
return !!ready.pddProductId && ready.processNextAction === 'open_pdd' && (
['pdd_pending', 'pdd_collection_failed'].includes(ready.processStage) ||
(ready.processStage === 'manual_action' && ['PDD_NOT_READY', 'PDD_PRICE_MISSING'].includes(ready.reasonCode)))
},
quickActionLabel(row) {
return this.quickCanReplace(row) ? '一键替换' : '一键关联'
},
quickDeviceLabel() {
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
return device ? `${device.name}(#${device.id})` : this.quickDeviceId ? `手机 #${this.quickDeviceId}` : '尚未选择'
},
async quickOpenPicker(row = null) {
const generation = ++this.quickPickerGeneration
this.quickPicker = { open: true, deviceId: this.quickDeviceId, row: row ? { ...row } : null }
this.quickDeviceLoading = true
this.quickDeviceError = ''
try {
// Include offline phones: historical collection does not require a live phone.
const response = await listDevices({ page: 1, pageSize: 100 })
if (generation !== this.quickPickerGeneration) return
this.quickDevices = response.data.items.filter(d => d.status === 'online' || d.status === 'offline')
if (!this.quickDevices.length) this.quickDeviceError = '暂无可选手机,请先在设备列表中登记或启用手机'
} catch { if (generation === this.quickPickerGeneration) this.quickDeviceError = '手机列表加载失败,请重试' } finally { if (generation === this.quickPickerGeneration) this.quickDeviceLoading = false }
},
quickCancelPicker() { this.quickPickerGeneration++; this.quickPicker.open = false },
quickSaveDevice() {
const id = this.quickPicker.deviceId
if (this.quickDeviceLoading || this.quickDeviceError || !this.quickDevices.some(d => d.id === id)) return
const row = this.quickPicker.row
this.quickDeviceId = id
if (!rememberQuickLinkDevice(this.$store.getters.userId, id)) ElMessage.warning('本次选择已生效,但浏览器未能保存;下次可能需要重新选择')
this.quickCancelPicker()
if (row) this.quickStart(row)
},
async quickStart(row) {
if (!this.quickCanLink(row) || this.quickBusyId) return
if (!this.quickDeviceId) return this.quickOpenPicker(row)
const replacing = this.quickCanReplace(row)
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId, oldPddId: this.purchaseReady(row).pddProductId }
const generation = ++this.quickGeneration
const current = () => generation === this.quickGeneration
this.quickBusyId = row.id
this.quickMessages[row.id] = ''
this.quickPhase = '正在关联'
let wrote = false
let targetId = null
try {
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
if (!current()) return
if (!replacing && detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
if (replacing && detail.product.pddProductId !== frozen.oldPddId) throw new Error('关联已变化,请刷新列表')
const latestCollection = { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
if (replacing) {
this.quickPhase = '正在读取最近采集'
const preview = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(), latestCollection: { ...latestCollection, replacement: { preview: true, expectedPddProductId: frozen.oldPddId }}
}, readOptions)).data
if (!current()) return
const source = preview.collectionSource
targetId = source.pddProductId
latestCollection.replacement = { preview: false, expectedPddProductId: frozen.oldPddId, taskId: source.taskId, targetPddProductId: targetId, targetVersion: source.targetVersion }
if (targetId !== frozen.oldPddId) {
this.quickPhase = '等待替换确认'
try {
await ElMessageBox.confirm(`将「${source.previousTitle || '原商品'}(${source.previousGoodsId || frozen.oldPddId})」替换为「${source.title || '最近采集商品'}(${source.goodsId || targetId})」。来源:${source.deviceName || '手机'} #${frozen.deviceId}。共用此蝦皮商品的 SYB 明细也会使用新关联;已有采购任务不变。`, '确认替换 PDD 商品', { confirmButtonText: '确认替换并匹配', cancelButtonText: '取消', closeOnClickModal: false })
} catch {
if (current()) this.quickMessages[row.id] = '已取消,未修改关联'
return
}
if (!current()) return
}
this.quickPhase = targetId === frozen.oldPddId ? '正在核对现有关联' : '正在替换'
}
const linked = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(),
latestCollection
}, readOptions)).data
wrote = true
if (!current()) return
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
if (!current()) return
const outcome = replacing && targetId !== frozen.oldPddId ? '已替换' : '已关联'
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
this.quickMessages[row.id] = ready?.eligible ? `${outcome},当前明细可创建采购` : `${outcome};请按处理阶段提示继续人工处理`
} else {
this.quickPhase = '正在匹配并保存'
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
}, detail.autoMatchTimeoutSeconds)).data
if (!current()) return
this.quickMessages[row.id] = result.unmatchedCount ? `${outcome}并保存有效匹配;剩余规格请去匹配处理` : `${outcome}并保存匹配;以最新处理阶段为准`
}
} catch (error) {
if (!current()) return
const reason = error?.response?.data?.message || error?.message || '请求失败'
// A definite validation/conflict rejection is not an unknown write.
if (!wrote && [403, 404, 409, 422].includes(error?.response?.status)) {
this.quickMessages[row.id] = reason
return
}
this.quickPhase = '正在核对结果'
try {
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
if (!current()) return
this.quickMessages[row.id] = replacing
? (targetId && saved.pddProductId === targetId
? `当前关联为本次目标商品,匹配结果请去匹配查看。${reason}`
: `当前关联${saved.pddProductId === frozen.oldPddId ? '仍为原商品' : '已变化'};本次结果未确认,请刷新核对,不自动重试。${reason}`)
: saved.pddProductId
? `当前已有关联,匹配结果请去匹配查看。${reason}`
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
} catch {
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
}
} finally {
if (current()) {
this.quickBusyId = null
this.quickPhase = ''
// Read-only refresh, never automatically resubmit a failed write.
void this.loadPurchaseReadiness(this.products.map(item => item.id))
}
}
}
}
}
@@ -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>

Some files were not shown because too many files have changed in this diff Show More