Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a3817591 | ||
|
|
d35d7354d6 | ||
|
|
8c01329f97 | ||
|
|
9194fd664f | ||
|
|
c2426e2671 | ||
|
|
8c1cec4430 | ||
|
|
60c75261d3 | ||
|
|
f634996dad | ||
|
|
57f6cec803 | ||
|
|
0e41000f62 | ||
|
|
c1bd39496d | ||
|
|
87a532f3bb | ||
|
|
8f337d5248 | ||
|
|
03647dd7dd | ||
|
|
7c43dc6029 | ||
|
|
9cdc9f325c | ||
|
|
08d77bd982 | ||
|
|
b2b8c0d1e0 | ||
|
|
1274161a1d | ||
|
|
bb097a6a15 | ||
|
|
2f1e88ea04 | ||
|
|
5b3c8df3bc | ||
|
|
a27db5f960 | ||
|
|
41996f33b2 | ||
|
|
2193762cbd | ||
|
|
174d92a1c9 | ||
|
|
95b5f05ae8 | ||
|
|
750b8763dc | ||
|
|
b4393dac84 | ||
|
|
c1ed3750d2 | ||
|
|
1d01186b8e | ||
|
|
93a175d04f | ||
|
|
12e9aa8591 | ||
|
|
c42e32d49d | ||
|
|
95be6e7569 | ||
|
|
c30b8c4624 | ||
|
|
ec2d86b1a8 | ||
|
|
51265681c2 | ||
|
|
c1eb23e9b1 | ||
|
|
2ddfd52f5d | ||
|
|
c2c0bae056 | ||
|
|
b7babfba7e | ||
|
|
a16e6920b7 | ||
|
|
def0a86f63 | ||
|
|
ba7d2a6b72 | ||
|
|
a3cf98940a | ||
|
|
56706a0b45 | ||
|
|
a4874091a8 | ||
|
|
9e0dcc254e | ||
|
|
cda1978bc6 | ||
|
|
c80d0c91d5 | ||
|
|
be491c7692 | ||
|
|
90c1668fe3 | ||
|
|
804448ede0 | ||
|
|
5a7e219230 | ||
|
|
36d1f82ce2 | ||
|
|
1c998911be | ||
|
|
45437a9214 | ||
|
|
42b5312198 | ||
|
|
7b540f3163 | ||
|
|
ffa5fc0710 | ||
|
|
66ab36c88c | ||
|
|
35b69fc25a | ||
|
|
b4dc7fc9ae | ||
|
|
baa9408a36 | ||
|
|
e01fcd9860 | ||
|
|
be03972779 | ||
|
|
c137f5072f | ||
|
|
82b4ebd406 | ||
|
|
b83f20c6b7 | ||
|
|
e0ed05d15a | ||
|
|
8163dd10eb | ||
|
|
aed295c8c1 | ||
|
|
c14f5f528b | ||
|
|
8293b27c19 | ||
|
|
c24e8a4439 | ||
|
|
40a254dc41 | ||
|
|
503b3cbbc5 | ||
|
|
cad9f6d185 | ||
|
|
78df997fc3 | ||
|
|
20f59b68bd | ||
|
|
652bf30f5a | ||
|
|
79929036bb | ||
|
|
e4677069d7 | ||
|
|
8cfd32babe | ||
|
|
3181302c16 | ||
|
|
2b01974fe5 | ||
|
|
305062dba0 | ||
|
|
ea38ced58c | ||
|
|
2cb78ad8cd | ||
|
|
ca997f6108 | ||
|
|
b1594dc7bd | ||
|
|
08d939a9b8 | ||
|
|
9088e6b734 | ||
|
|
ba0e48bcea | ||
|
|
edd1cb15df | ||
|
|
482ba3408a | ||
|
|
ca7f768a79 | ||
|
|
72b8b5d4d0 | ||
|
|
290a17ea15 |
@@ -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,16 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 首次启动时仍然弹一次系统授权框(体验最佳位置),但这不是唯一入口:拒绝后
|
||||
// (包括"不再询问")用户可以在"状态"页的相册权限卡片里重新发起授权或跳转应用详情页,
|
||||
// 见 cn.ilapage.goauto.agent.ui.MediaPermissionPolicy / AgentStatusFragment。
|
||||
private fun requestMediaPermission() {
|
||||
val permission = cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.requiredPermission()
|
||||
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 +156,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
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -194,6 +194,39 @@ 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()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.math.BigDecimal
|
||||
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?,
|
||||
val pddOrderAmountCent: Long? = null,
|
||||
)
|
||||
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>()
|
||||
private val amounts = mutableSetOf<Long>()
|
||||
private var invalidAmount = false
|
||||
|
||||
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) }
|
||||
// Treat each accessibility node label independently: a label and an
|
||||
// unrelated price in the next node must never be joined into evidence.
|
||||
text.lineSequence().forEach(::acceptAmountLabel)
|
||||
}
|
||||
|
||||
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)
|
||||
}, amounts.singleOrNull()?.takeUnless { invalidAmount })
|
||||
} else null
|
||||
suffixes.clear()
|
||||
orders.clear()
|
||||
times.clear()
|
||||
amounts.clear()
|
||||
invalidAmount = false
|
||||
return BackfillDetail(item, time)
|
||||
}
|
||||
|
||||
private fun acceptAmountLabel(label: String) {
|
||||
var start = label.indexOf("实付")
|
||||
while (start >= 0) {
|
||||
val ignored = label.substring(0, start).trimEnd().let { it.endsWith("未") || it.endsWith("预计") }
|
||||
if (!ignored) {
|
||||
val candidate = label.substring(start)
|
||||
val match = PAID_AMOUNT.find(candidate)
|
||||
if (match == null || match.range.first != 0) invalidAmount = true
|
||||
else try {
|
||||
amounts += BigDecimal(match.groupValues[1]).movePointRight(2).toBigIntegerExact().longValueExact()
|
||||
} catch (_: ArithmeticException) {
|
||||
invalidAmount = true
|
||||
}
|
||||
}
|
||||
start = label.indexOf("实付", start + 2)
|
||||
}
|
||||
}
|
||||
|
||||
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 PAID_AMOUNT = Regex("实付[ \\t::,,]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
|
||||
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class PreparedImageSearchAsset(val uri: Uri)
|
||||
|
||||
/**
|
||||
* 图搜参考图的纯校验与缩放决策逻辑(#277),与 Android 框架解耦以便 JVM 单测覆盖。
|
||||
* MediaStore 读写、真机相册顺序等只能真机验证,见 [PinduoduoImageSearchAssetStore]。
|
||||
*/
|
||||
object ImageSearchAssetPolicy {
|
||||
const val MAX_BYTES = 20 * 1024 * 1024
|
||||
const val MIN_SEARCH_IMAGE_EDGE = 960
|
||||
const val MAX_SEARCH_IMAGE_EDGE = 4_096
|
||||
const val SEARCH_JPEG_QUALITY = 95
|
||||
|
||||
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
|
||||
if (reference.mediaType == "image/jpeg" && !hasJpegMarkers(bytes)) return false
|
||||
return sha256(bytes) == reference.sha256.lowercase()
|
||||
}
|
||||
|
||||
fun hasJpegMarkers(bytes: ByteArray): Boolean =
|
||||
bytes.size >= 4 &&
|
||||
bytes[0] == 0xff.toByte() &&
|
||||
bytes[1] == 0xd8.toByte() &&
|
||||
bytes[bytes.size - 2] == 0xff.toByte() &&
|
||||
bytes[bytes.size - 1] == 0xd9.toByte()
|
||||
|
||||
fun sha256(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/**
|
||||
* 目标缩放尺寸,纯整数运算,便于单测覆盖临界值。
|
||||
* 已经不小于 [MIN_SEARCH_IMAGE_EDGE] 时返回 null(原图直接使用)。
|
||||
* 放大后超过 [MAX_SEARCH_IMAGE_EDGE] 时也返回 null(放弃放大,交由调用方决定是否使用原图或失败)。
|
||||
*/
|
||||
fun targetScaledSize(width: Int, height: Int): Pair<Int, Int>? {
|
||||
if (width <= 0 || height <= 0) return null
|
||||
if (width >= MIN_SEARCH_IMAGE_EDGE && height >= MIN_SEARCH_IMAGE_EDGE) return null
|
||||
val scale = maxOf(
|
||||
MIN_SEARCH_IMAGE_EDGE.toDouble() / width,
|
||||
MIN_SEARCH_IMAGE_EDGE.toDouble() / height,
|
||||
)
|
||||
val targetWidth = (width * scale).toInt()
|
||||
val targetHeight = (height * scale).toInt()
|
||||
if (targetWidth > MAX_SEARCH_IMAGE_EDGE || targetHeight > MAX_SEARCH_IMAGE_EDGE) return null
|
||||
return targetWidth to targetHeight
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
deleteStalePreparedImages()
|
||||
val searchBytes = prepareSearchCopy(bytes) ?: return null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "$DISPLAY_NAME_PREFIX${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, MEDIA_DIRECTORY)
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
|
||||
return try {
|
||||
val wrote = resolver.openOutputStream(uri)?.use { it.write(searchBytes) } != null
|
||||
if (!wrote) {
|
||||
resolver.delete(uri, null, null)
|
||||
return null
|
||||
}
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||
resolver.notifyChange(uri, null)
|
||||
Thread.sleep(MEDIA_INDEX_SETTLE_MILLIS)
|
||||
PreparedImageSearchAsset(uri)
|
||||
} catch (_: Exception) {
|
||||
resolver.delete(uri, null, null)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本方案不做图像识别,只保证自己写入的图片是相册中最新的一项,从而在 PDD 图搜页"最近项目"
|
||||
* 网格里稳定命中第一格。
|
||||
*
|
||||
* `[坑②修复]` 真机实测该网格同时包含图片和视频(观察到带时长文案的视频格子)。早前实现只在
|
||||
* `MediaStore.Images` 里比较 jpeg,若相册里有更新的视频,网格第一格就不是我们写入的图,会导致
|
||||
* 点错图。这里改为查询 `MediaStore.Files`(图片 + 视频),把结果交给纯函数
|
||||
* [ImageSearchMediaRecencyPolicy] 判定;只要存在按 (DATE_ADDED, _ID) 排序更靠前的记录,
|
||||
* 就返回 false(调用方据此明确失败,不猜测、不往后找)。真机验证未执行。
|
||||
*/
|
||||
/**
|
||||
* 确认我们写入的图是相册中最新的一项(图片与视频一起比较)。
|
||||
*
|
||||
* `[必须]` 用经典的 sortOrder 字符串,不要用 Bundle 的 QUERY_ARG_SORT_COLUMNS /
|
||||
* QUERY_ARG_LIMIT。2026-09-15 真机对比:OnePlus PLY110(Android 16 / SDK 36)
|
||||
* 认 Bundle 参数,三星 SM-G9700(Android 12 / SDK 31)不认——排序被忽略后
|
||||
* LIMIT 取回的是任意 5 行,我们刚写入的图根本不在其中,于是判定"不是最新"
|
||||
* 并以 IMAGE_SEARCH_ASSET_NOT_LATEST 失败,实际相册里最新的照片还是两天前的。
|
||||
*
|
||||
* LIMIT 不能写进 sortOrder:Android 11 起 MediaProvider 会以
|
||||
* "Invalid token LIMIT" 拒绝。改为按排序游标读前 [RECENCY_QUERY_LIMIT] 行。
|
||||
*/
|
||||
fun isMostRecent(asset: PreparedImageSearchAsset): Boolean {
|
||||
val ownId = runCatching { ContentUris.parseId(asset.uri) }.getOrNull() ?: return false
|
||||
val filesUri = MediaStore.Files.getContentUri("external")
|
||||
val selection = "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (?, ?)"
|
||||
val selectionArgs = arrayOf(
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(),
|
||||
)
|
||||
val sortOrder =
|
||||
"${MediaStore.Files.FileColumns.DATE_ADDED} DESC, ${MediaStore.Files.FileColumns._ID} DESC"
|
||||
val entries = runCatching {
|
||||
resolver.query(
|
||||
filesUri,
|
||||
arrayOf(MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns.DATE_ADDED),
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder,
|
||||
)?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||
val dateIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||
val result = mutableListOf<ImageSearchMediaEntry>()
|
||||
while (result.size < RECENCY_QUERY_LIMIT && cursor.moveToNext()) {
|
||||
result += ImageSearchMediaEntry(cursor.getLong(idIndex), cursor.getLong(dateIndex))
|
||||
}
|
||||
result
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId)
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) {
|
||||
asset ?: return
|
||||
runCatching { resolver.delete(asset.uri, null, null) }
|
||||
}
|
||||
|
||||
private fun deleteStalePreparedImages() {
|
||||
runCatching {
|
||||
resolver.delete(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
"${MediaStore.Images.Media.RELATIVE_PATH} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?",
|
||||
arrayOf("$MEDIA_DIRECTORY/", "$DISPLAY_NAME_PREFIX%.jpg"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareSearchCopy(source: ByteArray): ByteArray? {
|
||||
val bitmap = BitmapFactory.decodeByteArray(source, 0, source.size) ?: return null
|
||||
val target = ImageSearchAssetPolicy.targetScaledSize(bitmap.width, bitmap.height)
|
||||
if (target == null) {
|
||||
bitmap.recycle()
|
||||
return source
|
||||
}
|
||||
val (targetWidth, targetHeight) = target
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
return try {
|
||||
ByteArrayOutputStream().use { output ->
|
||||
if (!scaled.compress(Bitmap.CompressFormat.JPEG, ImageSearchAssetPolicy.SEARCH_JPEG_QUALITY, output)) return null
|
||||
output.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
scaled.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MEDIA_DIRECTORY = "Pictures/GoAutoSearch"
|
||||
const val DISPLAY_NAME_PREFIX = "goauto-search-"
|
||||
const val MEDIA_INDEX_SETTLE_MILLIS = 750L
|
||||
const val RECENCY_QUERY_LIMIT = 5
|
||||
}
|
||||
}
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/**
|
||||
* PDD 图搜状态机(#277)。
|
||||
*
|
||||
* 拆成两层:
|
||||
* - 本文件顶部的纯判定对象([ImageSearchRecoveryPolicy]、[ImageSearchCandidatePolicy]、
|
||||
* [ImageSearchMediaRecencyPolicy])不依赖 AccessibilityNodeInfo/MediaStore,可在 JVM
|
||||
* 单测中直接覆盖。
|
||||
* - [PinduoduoImageSearchAutomation] 是实际驱动 [PddCollectorDriver] 的执行器,只能真机验证。
|
||||
*
|
||||
* 归位状态机思路参考本机 D:\chengma\cmroubao_old 的
|
||||
* PinduoduoImageSearchAutomation.kt(未整包复制,只借鉴"归位到入口,上限次数内不断重试"的结构)。
|
||||
* OCR/VLM 相关文件(PinduoduoGoodsIdOcr / PinduoduoCheckoutOcr / PinduoduoSpecificationVlm)
|
||||
* 未参考、未移植。
|
||||
*/
|
||||
enum class ImageSearchPageKind {
|
||||
HOME,
|
||||
IMAGE_SEARCH_ENTRY,
|
||||
IMAGE_SEARCH_RESULTS,
|
||||
RETRY_DIALOG,
|
||||
ORDER_CONFIRM,
|
||||
OTHER_PDD,
|
||||
NON_PDD,
|
||||
}
|
||||
|
||||
enum class ImageSearchRecoveryAction {
|
||||
/** 已经在图搜入口页,无需再动作。 */
|
||||
NONE,
|
||||
/** 在首页,点击"拍照搜索"入口(唯一可点节点)。 */
|
||||
CLICK_CAMERA,
|
||||
/**
|
||||
* 关闭"再试一次"弹窗。
|
||||
*
|
||||
* `[必须]` 这个弹窗按返回键**无效**——2026-09-15 真机实测,按 BACK 前后两次
|
||||
* uiautomator dump 逐字节相同。唯一出口是点"取消",而点"取消"会退出图搜、
|
||||
* 回到拼多多首页,所以关闭之后仍要重新走一遍入口。
|
||||
*/
|
||||
DISMISS_RETRY_DIALOG,
|
||||
|
||||
/** 在结果页/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回,
|
||||
* 绝不点击任何支付/下单控件。 */
|
||||
BACK,
|
||||
/** 完全不在拼多多前台,需要先拉起应用。 */
|
||||
LAUNCH_PDD,
|
||||
}
|
||||
|
||||
/**
|
||||
* 归位状态机的纯判定逻辑:给定当前页面的包名、可见文案、排序控件数量,判断处于哪种页面形态,
|
||||
* 以及归位到图搜入口还需要做什么动作。
|
||||
*/
|
||||
object ImageSearchRecoveryPolicy {
|
||||
/** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */
|
||||
const val MAX_RECOVERY_ACTIONS = 50
|
||||
|
||||
/**
|
||||
* 允许关闭"再试一次"弹窗并重新进入的次数上限。
|
||||
*
|
||||
* `[必须]` 进入图搜页后拼多多的摄像头会实时识别,识别不到东西约 4 秒后自己弹出
|
||||
* 该弹窗(2026-09-15 真机实测:+3s 无弹窗、+5s 有)。关闭后会回到首页,重新进入
|
||||
* 又会撞上同一个窗口,因此必须单独设上限,否则会在通用的 50 次预算里空转数分钟。
|
||||
*/
|
||||
const val MAX_RETRY_DIALOG_RECOVERIES = 3
|
||||
|
||||
/**
|
||||
* 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。
|
||||
* 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。
|
||||
*/
|
||||
const val SEARCH_BAR_CONTENT_DESCRIPTION = "搜索"
|
||||
|
||||
fun classify(
|
||||
packageName: String?,
|
||||
visibleTexts: Collection<String>,
|
||||
sortControlCount: Int,
|
||||
hasClickableCameraEntry: Boolean,
|
||||
): ImageSearchPageKind {
|
||||
if (packageName != PDD_PACKAGE) return ImageSearchPageKind.NON_PDD
|
||||
if (PinduoduoImageSearchCriteria.isRetryDialog(visibleTexts)) return ImageSearchPageKind.RETRY_DIALOG
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(visibleTexts, sortControlCount)) {
|
||||
return ImageSearchPageKind.IMAGE_SEARCH_RESULTS
|
||||
}
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchEntryPage(visibleTexts)) return ImageSearchPageKind.IMAGE_SEARCH_ENTRY
|
||||
if (visibleTexts.any { text -> ORDER_CONFIRM_MARKERS.any(text::contains) }) return ImageSearchPageKind.ORDER_CONFIRM
|
||||
// `[必须]` HOME 只能由**可点**的相机入口证明,不能只看文案。真机核对
|
||||
// (2026-09-15)发现个人中心 tab 的根节点同样带 content-desc='拍照搜索',
|
||||
// 覆盖全屏且 clickable=false,而 visibleTexts() 会把 contentDescription
|
||||
// 当文本收集。只看文案会把个人中心判成首页,随后几何兜底就会点到该页
|
||||
// 顶部最靠右的可点节点——实测那是「设置」按钮。
|
||||
if (hasClickableCameraEntry) return ImageSearchPageKind.HOME
|
||||
return ImageSearchPageKind.OTHER_PDD
|
||||
}
|
||||
|
||||
fun nextAction(kind: ImageSearchPageKind): ImageSearchRecoveryAction = when (kind) {
|
||||
ImageSearchPageKind.IMAGE_SEARCH_ENTRY -> ImageSearchRecoveryAction.NONE
|
||||
ImageSearchPageKind.HOME -> ImageSearchRecoveryAction.CLICK_CAMERA
|
||||
ImageSearchPageKind.NON_PDD -> ImageSearchRecoveryAction.LAUNCH_PDD
|
||||
ImageSearchPageKind.RETRY_DIALOG -> ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG
|
||||
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
|
||||
ImageSearchPageKind.ORDER_CONFIRM,
|
||||
ImageSearchPageKind.OTHER_PDD,
|
||||
-> ImageSearchRecoveryAction.BACK
|
||||
}
|
||||
|
||||
fun exceedsRecoveryBudget(actionCount: Int): Boolean = actionCount >= MAX_RECOVERY_ACTIONS
|
||||
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
|
||||
// 只读识别信号:用于判断当前是否停在订单确认形态的页面,从而选择"按返回"这一条归位路径。
|
||||
// 这里的任何文案都不会被配置成点击目标。
|
||||
private val ORDER_CONFIRM_MARKERS = listOf(
|
||||
"提交订单", "确认购买", "现在买,仅",
|
||||
"待付款", "待支付", "去支付", "立即支付", "确认支付",
|
||||
)
|
||||
}
|
||||
|
||||
/** 图搜结果页里的一张候选卡片,供纯函数挑选。 */
|
||||
data class ImageSearchResultCard(
|
||||
val top: Int,
|
||||
val left: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val texts: List<String>,
|
||||
/** 卡片内图片是否已加载完成;不作为过滤条件,仅用于诊断/测试,容忍未加载完成的卡片。 */
|
||||
val imageLoaded: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 结果页候选卡片挑选:取第一个(先按 top 再按 left),跳过带"广告"文案的卡片。
|
||||
* 不检查 [ImageSearchResultCard.imageLoaded]——参考项目 T-271 曾因为"图片未加载完时卡片读不出来"
|
||||
* 在真机失败过,这里刻意不把加载状态当作过滤条件。
|
||||
*/
|
||||
object ImageSearchCandidatePolicy {
|
||||
private const val AD_MARKER = "广告"
|
||||
|
||||
/** 节点是否完全落在卡片矩形内。 */
|
||||
fun inside(card: ImageSearchResultCard, bounds: NodeBounds): Boolean =
|
||||
bounds.left >= card.left && bounds.top >= card.top &&
|
||||
bounds.right <= card.left + card.width && bounds.bottom <= card.top + card.height
|
||||
|
||||
/**
|
||||
* 卡片内最靠上的有文案节点,作为点击目标。
|
||||
*
|
||||
* `[必须]` 点击目标不能是卡片容器本身。快照里 SnapshotNode.label 只取节点**自身**的
|
||||
* text/contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel()
|
||||
* ——会往子孙里挖。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串、
|
||||
* 一个是标题文字,永远对不上,重找直接返回 TARGET_NOT_FOUND。改为瞄准有文案的子节点,
|
||||
* 由 clickFreshDetailed 既有的"向上走到可点祖先"逻辑落到卡片容器上。
|
||||
*/
|
||||
fun clickTargetInside(card: ImageSearchResultCard, nodes: List<SnapshotNode>): SnapshotNode? =
|
||||
nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top }
|
||||
|
||||
fun firstOpenableCandidate(cards: List<ImageSearchResultCard>): ImageSearchResultCard? =
|
||||
cards
|
||||
.filter { card -> card.texts.none { it.contains(AD_MARKER) } }
|
||||
.sortedWith(compareBy({ it.top }, { it.left }))
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/** 相册里的一条媒体记录(图片或视频),仅取判定所需字段。 */
|
||||
data class ImageSearchMediaEntry(val id: Long, val dateAdded: Long)
|
||||
|
||||
/**
|
||||
* 坑②:现有 [PinduoduoImageSearchAssetStore.isMostRecent] 只比较 jpeg,若相册里有更新的视频,
|
||||
* 网格第一格就不是我们写入的图。这里把"最新一项"的判定扩大到全部媒体(图片 + 视频),
|
||||
* 并入 [PinduoduoImageSearchAssetStore] 时以 MediaStore.Files 同时查询两种类型。
|
||||
* 找不到自己、或者存在按 (dateAdded, id) 排序更靠前的记录时,一律返回 false(明确失败,不往后找)。
|
||||
*/
|
||||
object ImageSearchMediaRecencyPolicy {
|
||||
fun isMostRecent(entries: List<ImageSearchMediaEntry>, ownId: Long): Boolean {
|
||||
if (entries.isEmpty()) return false
|
||||
val latest = entries.maxWithOrNull(compareBy({ it.dateAdded }, { it.id })) ?: return false
|
||||
return latest.id == ownId
|
||||
}
|
||||
}
|
||||
|
||||
data class ImageSearchAutomationResult(val successful: Boolean, val code: String, val message: String)
|
||||
|
||||
/**
|
||||
* 图搜状态机执行器:驱动 [PddCollectorDriver] 完成"归位到图搜入口 -> 选相册第一张 -> 等结果页 ->
|
||||
* 打开第一个候选"。goods_id 识别与商品采集接回现有的 [CurrentPageIdentityRunner] +
|
||||
* [PddProductDetailCollector] 链路(在 AgentForegroundService 里完成),本类只负责把
|
||||
* 拼多多带到"候选商品详情页已打开"这一步。
|
||||
*
|
||||
* `[未验证]` 全部真机点击序列均未在真机执行,只能保证纯判定逻辑(上面三个 object)经过单测覆盖。
|
||||
*/
|
||||
class PinduoduoImageSearchAutomation(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val currentPackage: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
/**
|
||||
* 归位到图搜入口页并在同一次迭代内选中相册第一张。
|
||||
*
|
||||
* `[必须]` 归位与选图必须合成一个循环,不能拆成 restoreToEntry() + selectFirstRecentImage()
|
||||
* 两次调用。2026-09-15 真机实测:进入图搜页后拼多多的摄像头开始实时识别,识别不到东西
|
||||
* 约 4 秒后自己弹出"再试一次"弹窗,把相册网格整个遮住;而该弹窗是独立 window,
|
||||
* rootInActiveWindow 只能看到弹窗的 13 个节点,底下的图搜页完全不可见。
|
||||
*
|
||||
* 拆成两次调用时,中间要多一次完整的无障碍树抓取(图搜页节点很多,开销不小),
|
||||
* 等第二次抓取回来时弹窗往往已经弹出,于是找不到"最近项目"锚点并报
|
||||
* IMAGE_SEARCH_ENTRY_NOT_FOUND——线上任务 262 连续四次都是这样,每次约 3 秒。
|
||||
* 手动实测在 1.8 秒内点中第一格可以干净进入结果页,所以余量存在,只是不能浪费。
|
||||
*
|
||||
* 结构参考同作者的 cmroubao_old(android-buyer PinduoduoImageSearchAutomation):
|
||||
* 检测到图搜页的那一刻就在当前快照上选图,并且每个动作之后等待预期页面出现,
|
||||
* 而不是盲目重判——页面加载中时判据一个都不命中,会被误判成其它页面而按下返回键。
|
||||
*/
|
||||
fun enterAndSelectImage(
|
||||
launchPdd: () -> PddLaunchResult,
|
||||
resetPddToHome: () -> PddLaunchResult = launchPdd,
|
||||
): ImageSearchAutomationResult {
|
||||
var actions = 0
|
||||
var dialogRecoveries = 0
|
||||
var stalledBacks = 0
|
||||
while (true) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, texts)?.let {
|
||||
return ImageSearchAutomationResult(false, it.code, it.message)
|
||||
}
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
val cameraEntry = uniqueCameraEntry(visible)
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null,
|
||||
)
|
||||
when (ImageSearchRecoveryPolicy.nextAction(kind)) {
|
||||
ImageSearchRecoveryAction.NONE -> {
|
||||
// 已在图搜入口页:就用刚抓到的这份快照选图,不再返回调用方、不再重新抓树。
|
||||
if (selectFirstRecentImageFrom(visible)) {
|
||||
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
|
||||
}
|
||||
// 选不中通常是网格还没渲染出来,等一轮重来;弹窗弹出时会在下一轮被识别并关闭。
|
||||
}
|
||||
ImageSearchRecoveryAction.CLICK_CAMERA -> {
|
||||
val target = cameraEntry
|
||||
?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口")
|
||||
driver.clickFreshDetailed(target)
|
||||
// `[必须]` 点完入口后等页面稳定再决策。图搜页加载中时判据一个都不命中,
|
||||
// 会被判成 OTHER_PDD 并按下返回键,等于自己把刚打开的图搜页退掉。
|
||||
val settled = awaitPage(ENTRY_SETTLE_TIMEOUT_MILLIS) {
|
||||
it == ImageSearchPageKind.IMAGE_SEARCH_ENTRY || it == ImageSearchPageKind.RETRY_DIALOG
|
||||
}
|
||||
if (settled != null && selectFirstRecentImageFrom(settled)) {
|
||||
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG -> {
|
||||
dialogRecoveries++
|
||||
if (dialogRecoveries > ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
|
||||
"拼多多摄像头识别反复失败,图搜页始终被重试弹窗占据",
|
||||
)
|
||||
}
|
||||
if (!dismissRetryDialog(visible)) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
|
||||
"无法关闭重试弹窗,请人工处理后重试",
|
||||
)
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.BACK -> {
|
||||
driver.back()
|
||||
// `[必须]` 按完返回要等页面真的换掉再判下一步。固定 300ms 盲等不够
|
||||
// PDD 完成一次切换,抓到的是动画中间态:判据一个都不命中,被归为
|
||||
// OTHER_PDD,于是再按一次——50 次预算 15 秒空转耗尽,App 可能一次
|
||||
// 完整返回都没做完(#292 真机任务 156)。CLICK_CAMERA 分支早就这么
|
||||
// 等了,这里是同一个坑漏掉的另一半。
|
||||
val moved = awaitPage(BACK_SETTLE_TIMEOUT_MILLIS) { it != kind } != null
|
||||
stalledBacks = if (moved) 0 else stalledBacks + 1
|
||||
if (stalledBacks >= MAX_STALLED_BACKS) {
|
||||
// 返回键推不动就别再按了:把 PDD 的返回栈清到根活动,直接回首页。
|
||||
stalledBacks = 0
|
||||
when (resetPddToHome()) {
|
||||
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddLaunchResult.STARTED -> awaitPage(HOME_SETTLE_TIMEOUT_MILLIS) { it == ImageSearchPageKind.HOME }
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.LAUNCH_PDD -> {
|
||||
when (launchPdd()) {
|
||||
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddLaunchResult.STARTED -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
actions++
|
||||
if (ImageSearchRecoveryPolicy.exceedsRecoveryBudget(actions)) {
|
||||
return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到图搜入口并选中参考图失败,请人工检查")
|
||||
}
|
||||
pause(RECOVERY_STEP_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询直到页面稳定为 [accept] 接受的形态,命中时返回**那一次抓取**的可见节点,
|
||||
* 供调用方直接使用,避免为了拿到同一个页面再抓一次树。超时返回 null。
|
||||
*/
|
||||
private fun awaitPage(
|
||||
timeoutMillis: Long,
|
||||
accept: (ImageSearchPageKind) -> Boolean,
|
||||
): List<SnapshotNode>? {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
currentPackage(), texts, sortControlCount,
|
||||
hasClickableCameraEntry = uniqueCameraEntry(visible) != null,
|
||||
)
|
||||
if (accept(kind)) return visible
|
||||
pause(POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 点"取消"关闭"再试一次"弹窗。
|
||||
*
|
||||
* `[必须]` 不用 BACK:真机实测 BACK 对该弹窗完全无效。也不点"再试一次"——那会
|
||||
* 重新启动摄像头识别,几秒后再弹一次,形成死循环。
|
||||
*/
|
||||
private fun dismissRetryDialog(visible: List<SnapshotNode>): Boolean {
|
||||
val cancel = visible.firstOrNull {
|
||||
it.enabled && it.clickable && it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CANCEL_TEXT
|
||||
} ?: return false
|
||||
return driver.clickFreshDetailed(cancel).result == FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
/** 只保留唯一同时满足 content-desc 和 clickable 的节点;否则按几何兜底(顶部区域最靠右的可点节点)。 */
|
||||
private fun uniqueCameraEntry(visible: List<SnapshotNode>): SnapshotNode? {
|
||||
val byText = visible.filter {
|
||||
it.enabled && it.clickable && it.contentDescription == PinduoduoImageSearchCriteria.CAMERA_ENTRY_CONTENT_DESCRIPTION
|
||||
}
|
||||
byText.singleOrNull()?.let { return it }
|
||||
if (byText.isNotEmpty()) return null
|
||||
// `[必须]` 几何兜底只在确实看得到顶部搜索栏时才允许。否则任何 PDD 页面都会
|
||||
// 交出"顶部最靠右的可点节点",在个人中心那是「设置」。实测首页搜索栏含
|
||||
// content-desc='搜索' 的节点,个人中心没有。
|
||||
val hasSearchBar = visible.any {
|
||||
it.visible && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX &&
|
||||
it.contentDescription?.trim() == ImageSearchRecoveryPolicy.SEARCH_BAR_CONTENT_DESCRIPTION
|
||||
}
|
||||
if (!hasSearchBar) return null
|
||||
val fallback = visible.filter {
|
||||
it.enabled && it.clickable && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX
|
||||
}
|
||||
return fallback.maxByOrNull { it.bounds.right }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在**已经抓好**的快照上定位"最近项目"锚点下方的网格并点击第一格。
|
||||
*
|
||||
* `[必须]` 接收 visible 而不是自己 capture:见 [enterAndSelectImage] 的说明,
|
||||
* 多一次抓取就可能错过 4 秒窗口。
|
||||
*/
|
||||
fun selectFirstRecentImageFrom(visible: List<SnapshotNode>): Boolean {
|
||||
val anchor = visible.firstOrNull { it.label.contains(PinduoduoImageSearchCriteria.RECENT_ITEMS_LABEL) } ?: return false
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return false
|
||||
val cells = visible
|
||||
.filter { it.clickable && it.bounds.top >= anchor.bounds.bottom }
|
||||
.map { PinduoduoImageSearchCriteria.GridCandidate(it.bounds.top, it.bounds.left, it.bounds.width, it.bounds.height) }
|
||||
val target = PinduoduoImageSearchCriteria.firstRecentImageGridCell(cells, screenWidth) ?: return false
|
||||
val node = visible.firstOrNull {
|
||||
it.clickable && it.bounds.top == target.top && it.bounds.left == target.left &&
|
||||
it.bounds.width == target.width && it.bounds.height == target.height
|
||||
} ?: return false
|
||||
return driver.clickFreshDetailed(node).result == FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询直到进入结果页。
|
||||
*
|
||||
* `[必须]` 出现"再试一次"弹窗时**不点"再试一次"**:那会重启摄像头识别、几秒后再弹一次。
|
||||
* 这里直接判失败交回调用方,由 restoreToEntry 走关闭并重新进入的路径(有独立次数上限)。
|
||||
*/
|
||||
fun waitForResultsPage(timeoutMillis: Long = DEFAULT_RESULTS_TIMEOUT_MILLIS): Boolean {
|
||||
// 失败由调用方映射为 IMAGE_SEARCH_RESULTS_NOT_READY,与"结果页出来了但候选点不动"
|
||||
// 区分开——两者此前共用 IMAGE_SEARCH_NO_CANDIDATES,定位问题时完全分不清。
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(texts, sortControlCount)) return true
|
||||
if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) return false
|
||||
pause(RECOVERY_STEP_MILLIS)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 打开结果页第一个非广告候选卡片;容忍卡片内图片尚未加载完成。 */
|
||||
fun openFirstCandidate(): ImageSearchAutomationResult {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val cardContainers = visible.filter { node ->
|
||||
node.clickable &&
|
||||
node.bounds.width in CARD_WIDTH_RANGE &&
|
||||
node.bounds.height in CARD_HEIGHT_RANGE
|
||||
}
|
||||
if (cardContainers.isEmpty()) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_NO_CANDIDATES", "结果页没有可识别的商品卡片",
|
||||
)
|
||||
}
|
||||
// `[必须]` 卡片内文案按**几何包含**判定,不用 path 前缀。path 的父子语义从未核对过,
|
||||
// 而 bounds 包含在 2026-09-15 的真机 dump 上直接验证过:四张 535x764 卡片各自的
|
||||
// 标题、价格、店铺文案都能正确归位。
|
||||
val cards = cardContainers.map { container ->
|
||||
ImageSearchResultCard(
|
||||
top = container.bounds.top,
|
||||
left = container.bounds.left,
|
||||
width = container.bounds.width,
|
||||
height = container.bounds.height,
|
||||
texts = visible.filter {
|
||||
it.label.isNotEmpty() && ImageSearchCandidatePolicy.inside(
|
||||
ImageSearchResultCard(container.bounds.top, container.bounds.left, container.bounds.width, container.bounds.height, emptyList()),
|
||||
it.bounds,
|
||||
)
|
||||
}.map { it.label },
|
||||
)
|
||||
}
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_NO_CANDIDATES",
|
||||
"结果页 ${cards.size} 张卡片全部是广告,没有可采集的候选",
|
||||
)
|
||||
val container = cardContainers.firstOrNull {
|
||||
it.bounds.top == chosen.top && it.bounds.left == chosen.left &&
|
||||
it.bounds.width == chosen.width && it.bounds.height == chosen.height
|
||||
} ?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "选中的候选卡片在点击前消失",
|
||||
)
|
||||
|
||||
// `[必须]` 不能直接点卡片容器。快照里 SnapshotNode.label 只取节点**自身**的
|
||||
// text/contentDescription,而 clickFreshDetailed 重新定位时用的是
|
||||
// preferredOrDescendantLabel()——会往子孙里挖。候选卡片是自身无文案的
|
||||
// FrameLayout,两者算出的 label 一个是空串、一个是标题文字,永远对不上,
|
||||
// 重找直接返回 TARGET_NOT_FOUND(2026-09-15 真机:任务 262 卡在这里)。
|
||||
// 改为瞄准卡片内有文案的子节点,由 clickFreshDetailed 既有的"向上走到可点祖先"
|
||||
// 逻辑落到卡片容器上。
|
||||
val labelled = ImageSearchCandidatePolicy.clickTargetInside(chosen, visible)
|
||||
?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "候选卡片内没有可定位的文案节点",
|
||||
)
|
||||
val outcome = driver.clickFreshDetailed(labelled)
|
||||
if (outcome.result != FreshActionResult.SUCCESS) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED",
|
||||
"候选卡片点击失败[result=${outcome.result};reason=${outcome.reason}]",
|
||||
)
|
||||
}
|
||||
return ImageSearchAutomationResult(true, "OK", "已打开第一个候选")
|
||||
}
|
||||
|
||||
|
||||
private fun SnapshotNode.width6xHeight(): Boolean = bounds.width > 0 && bounds.height > 0
|
||||
|
||||
private companion object {
|
||||
const val RECOVERY_STEP_MILLIS = 300L
|
||||
|
||||
/** 动作之间的轮询间隔,取自 cmroubao_old 的实测值。 */
|
||||
const val POLL_INTERVAL_MILLIS = 200L
|
||||
|
||||
/**
|
||||
* 点击图搜入口后等待页面稳定的上限。
|
||||
*
|
||||
* `[必须]` 必须明显短于摄像头自动弹出重试弹窗的约 4 秒,否则等到的就是弹窗而不是图搜页。
|
||||
*/
|
||||
const val ENTRY_SETTLE_TIMEOUT_MILLIS = 2_500L
|
||||
|
||||
/** 按下返回后等待页面形态变化的上限。 */
|
||||
const val BACK_SETTLE_TIMEOUT_MILLIS = 1_500L
|
||||
|
||||
/** 连续多少次返回推不动页面后,改用清栈重启回首页。 */
|
||||
const val MAX_STALLED_BACKS = 3
|
||||
|
||||
/** 清栈回首页后等待首页出现的上限。 */
|
||||
const val HOME_SETTLE_TIMEOUT_MILLIS = 5_000L
|
||||
const val DEFAULT_RESULTS_TIMEOUT_MILLIS = 15_000L
|
||||
val SORT_CONTROL_LABELS = setOf("综合", "销量", "价格", "品牌")
|
||||
val CARD_WIDTH_RANGE = 400..700
|
||||
val CARD_HEIGHT_RANGE = 600..950
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/**
|
||||
* PDD 图搜相关页面判据的纯函数实现(#277)。
|
||||
*
|
||||
* 常量来源:移植自同作者另一个项目(本机 D:\chengma\cmroubao_old),并已于
|
||||
* 2026-09-15 在真机(1080x2354,PDD HomeActivity / NewPageActivity)dump 无障碍
|
||||
* 树逐条核对,三处判据全部命中:
|
||||
*
|
||||
* 1. 首页搜索框右侧 `[941,161][1080,228] ImageView clickable=true`
|
||||
* content-desc 正是 [CAMERA_ENTRY_CONTENT_DESCRIPTION]。
|
||||
* `[必须]` 必须同时要求 clickable:个人中心 tab 的**根节点**也带着同一个
|
||||
* content-desc 且覆盖全屏、clickable=false,只按文案匹配会点到整屏。
|
||||
* 实测首页该文案共 2 个节点、仅 1 个可点,"唯一可点"过滤是必需的。
|
||||
* 2. 图搜页 [isImageSearchEntryPage] 的四段文案齐备;`最近项目` 锚点
|
||||
* `[0,1892][210,1898]`,其下网格每格 267px,267*4=1068,屏宽 1080,
|
||||
* 落在 ±[GRID_WIDTH_TOLERANCE_PX] 容差内。
|
||||
* 3. 结果页 `搜图片同款` 出现,排序控件为 综合/销量/价格/品牌 共 4 个(>=3)。
|
||||
*
|
||||
* 已知与移植来源的差异,以及尚未验证的部分,见下方各常量注释。
|
||||
*/
|
||||
object PinduoduoImageSearchCriteria {
|
||||
|
||||
/** 首页/搜索结果页上,图搜入口按钮的 contentDescription。要求唯一匹配后才可点击。 */
|
||||
const val CAMERA_ENTRY_CONTENT_DESCRIPTION = "拍照搜索"
|
||||
|
||||
/**
|
||||
* 几何兜底(用户 2026-09-15 提供):若按 content-desc 找不到入口,首页顶部
|
||||
* 区域最靠右的可点节点即是相机图标。实测 `[941,161][1080,228]` 紧贴右边缘。
|
||||
* 仅在文案匹配失败时使用,且必须限制在顶部搜索栏所在的纵向范围内。
|
||||
*/
|
||||
const val CAMERA_ENTRY_FALLBACK_MAX_TOP_PX = 320
|
||||
|
||||
/**
|
||||
* "再试一次"弹窗文案。
|
||||
*
|
||||
* `[已验证]` 2026-09-15 真机复现,三个常量全部命中。进入图搜页后摄像头会
|
||||
* 实时识别,识别不到东西约 4 秒后拼多多**自己**弹出该弹窗(+3s 无、+5s 有),
|
||||
* 不需要我们点任何东西。它会遮住相册网格。
|
||||
*
|
||||
* `[必须]` 按 BACK 对该弹窗**完全无效**(前后两次 dump 逐字节相同);点「取消」
|
||||
* 可以关闭,但会退出图搜、回到拼多多首页。实测在 1.8 秒内点中相册第一格
|
||||
* 可以赶在弹窗之前干净进入结果页。
|
||||
*/
|
||||
const val RETRY_DIALOG_CANCEL_TEXT = "取消"
|
||||
const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次"
|
||||
const val RETRY_DIALOG_HINT_TEXT = "请对准商品或码,保持手机稳定"
|
||||
|
||||
/**
|
||||
* 相册网格锚点。
|
||||
*
|
||||
* `[注意]` 真机实测该网格同时包含**视频**(观察到带时长文案 03:02 的格子)。
|
||||
* 资产准备侧的"自己是否为相册最新一张"只比较 jpeg,若有更新的视频排在前面,
|
||||
* 第一格就不是我们写入的图。接线时必须复核这一点,必要时改为按 uri 定位。
|
||||
*/
|
||||
const val RECENT_ITEMS_LABEL = "最近项目"
|
||||
|
||||
private const val GRID_COLUMNS = 4
|
||||
private const val GRID_WIDTH_TOLERANCE_PX = 24
|
||||
|
||||
/** 图搜首页(选择"最近项目"/拍照)判据:需同时出现相册、最近搜索、历史浏览等文案,且有拍照引导。 */
|
||||
fun isImageSearchEntryPage(texts: Collection<String>): Boolean {
|
||||
val hasAlbum = texts.any { it.contains("我的相册") }
|
||||
val hasRecentSearch = texts.any { it.contains("最近搜索") }
|
||||
val hasHistory = texts.any { it.contains("历史浏览") }
|
||||
val hasCameraHint = texts.any {
|
||||
// 真机实测取景提示是「对准商品/条形码/二维码,自动识别」,移植来源的
|
||||
// 「即可进行自动识别」不命中;靠「点击拍照」兜住。三者保留为或关系,
|
||||
// 以覆盖未授予相机权限等其它形态。
|
||||
it.contains("点击拍照") || it.contains("开启相机权限") || it.contains("即可进行自动识别")
|
||||
}
|
||||
return hasAlbum && hasRecentSearch && hasHistory && hasCameraHint
|
||||
}
|
||||
|
||||
/** 图搜结果页判据:出现"搜图片同款"标题,且排序/筛选控件数量达到阈值。 */
|
||||
fun isImageSearchResultsPage(texts: Collection<String>, sortControlCount: Int): Boolean =
|
||||
texts.any { it.contains("搜图片同款") } && sortControlCount >= 3
|
||||
|
||||
/** "再试一次"弹窗判据。 */
|
||||
fun isRetryDialog(texts: Collection<String>): Boolean {
|
||||
val hasHint = texts.any { it.contains(RETRY_DIALOG_HINT_TEXT) }
|
||||
val hasCancel = texts.any { it == RETRY_DIALOG_CANCEL_TEXT }
|
||||
val hasRetry = texts.any { it == RETRY_DIALOG_CONFIRM_TEXT }
|
||||
return hasHint && hasCancel && hasRetry
|
||||
}
|
||||
|
||||
data class GridCandidate(val top: Int, val left: Int, val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* 在"最近项目"标签下方的候选矩形里,挑出 4 列等宽网格(宽度 * 4 约等于屏宽,容差
|
||||
* [GRID_WIDTH_TOLERANCE_PX]px;高度 >= 宽度/2)里最靠左上的一个(先按 top 再按 left 排序)。
|
||||
* 找不到符合条件的候选时返回 null。
|
||||
*/
|
||||
fun firstRecentImageGridCell(candidates: List<GridCandidate>, screenWidth: Int): GridCandidate? {
|
||||
val fourColumns = candidates.filter { cell ->
|
||||
cell.width > 0 &&
|
||||
cell.height >= cell.width / 2 &&
|
||||
kotlin.math.abs(cell.width * GRID_COLUMNS - screenWidth) <= GRID_WIDTH_TOLERANCE_PX
|
||||
}
|
||||
return fourColumns.sortedWith(compareBy({ it.top }, { it.left })).firstOrNull()
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -20,7 +20,15 @@ data class FinalConfirmationEvidence(
|
||||
)
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
|
||||
data class PurchaseOrderReadFailure(val code: String, val message: String)
|
||||
data class PurchaseOrderReadFailure(
|
||||
val code: String,
|
||||
val message: String,
|
||||
// `[必须]` 与是否读到订单号无关:只要采样期间出现过待付款/支付相关文案,
|
||||
// 就说明大概率已经点通了下单、PDD 侧可能已经建了订单,只是没能读全证据
|
||||
// (比如返回窗口太短、错过了订单详情页)。给 order_result_unknown 分优先级用,
|
||||
// 不改变判定结果本身(#302)。
|
||||
val paymentPageObserved: Boolean = false,
|
||||
)
|
||||
|
||||
class PurchaseLiveException(val code: String, message: String, val actualUnitPriceCent: Long? = null) : IllegalStateException(message)
|
||||
|
||||
@@ -340,8 +348,9 @@ class PurchaseLiveAutomation(
|
||||
/** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */
|
||||
fun readOrderResult(): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = null
|
||||
var paymentPageObserved = false
|
||||
fun unknown(code: String, message: String): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = PurchaseOrderReadFailure(code, message)
|
||||
lastOrderReadFailure = PurchaseOrderReadFailure(code, message, paymentPageObserved)
|
||||
return null
|
||||
}
|
||||
val labels = linkedSetOf<String>()
|
||||
@@ -399,6 +408,7 @@ class PurchaseLiveAutomation(
|
||||
// it permits only bounded reading gestures below, never a payment click.
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
|
||||
if (paymentVisible) {
|
||||
paymentPageObserved = true
|
||||
if (paymentBackAttempts == 0) {
|
||||
paymentBackAttempts++
|
||||
if (!driver.backPurchase()) {
|
||||
@@ -420,6 +430,7 @@ class PurchaseLiveAutomation(
|
||||
return@repeat
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (unpaidContextVisible) paymentPageObserved = true
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
@@ -452,6 +463,9 @@ class PurchaseLiveAutomation(
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
|
||||
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
|
||||
// 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。
|
||||
|
||||
private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection<String>): Boolean {
|
||||
val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES
|
||||
val activity = snapshot.activityName.orEmpty()
|
||||
|
||||
+5
@@ -77,6 +77,8 @@ data class PurchaseExecutionOutcome(
|
||||
val pddOrderNo: String? = null,
|
||||
val orderSubmittedAt: String? = null,
|
||||
val actualUnitPriceCent: Long? = null,
|
||||
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
|
||||
val paymentPageObserved: Boolean = false,
|
||||
)
|
||||
|
||||
class PurchaseRehearsalExecutor(
|
||||
@@ -140,6 +142,8 @@ class PurchaseRehearsalExecutor(
|
||||
live.submitOrderOnce()
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
// paymentPageObserved 留默认 false:此处是 submitOrderOnce() 本身失败
|
||||
// (提交按钮点击不明确),readOrderResult 还没跑过,谈不上见没见过支付页。
|
||||
if (irreversibleStarted) PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
"PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
@@ -157,6 +161,7 @@ class PurchaseRehearsalExecutor(
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
actualUnitPriceCent = observedPrice,
|
||||
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,22 @@ object AgentCapabilities {
|
||||
const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1"
|
||||
const val PDD_CURRENT_PAGE_SHARE_V1 = "collector.pdd.current-page-share.v1"
|
||||
|
||||
/**
|
||||
* 图搜采集能力(#277)。
|
||||
*
|
||||
* `[必须]` 必须与服务端 `task.ImageSearchCapability` 逐字一致。
|
||||
* `taskCompatible` 对 source='image_search' 的任务按该能力过滤:
|
||||
* 设备不声明则图搜任务**永远不会被领取**,且是静默的——
|
||||
* 任务停在 pending、不失败也不报错。
|
||||
*/
|
||||
const val PDD_IMAGE_SEARCH_V1 = "pdd.image-search.v1"
|
||||
|
||||
val supported: List<String> = listOf(
|
||||
SCHEMA_V2,
|
||||
SWIPE_V1,
|
||||
PDD_PRODUCT_DETAIL_V1,
|
||||
PDD_CURRENT_PAGE_SHARE_V1,
|
||||
PDD_IMAGE_SEARCH_V1,
|
||||
PurchaseAgentCapabilities.REHEARSAL_V1,
|
||||
PurchaseAgentCapabilities.LIVE_V1,
|
||||
PurchaseAgentCapabilities.SPEC_PROBE_V1,
|
||||
|
||||
@@ -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,6 +211,9 @@ 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))
|
||||
}
|
||||
@@ -326,6 +337,32 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图搜参考图字节内容(#277)。只做网络传输,不做任何校验——校验交给
|
||||
* `ImageSearchAssetPolicy.valid`(sha256/大小/JPEG 标记),保持单一职责。
|
||||
*/
|
||||
fun downloadImageSearchReferenceImage(reference: ImageSearchImage, token: String): ByteArray {
|
||||
val url = if (reference.imageUrl.startsWith("http")) reference.imageUrl else serverUrl + reference.imageUrl
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 20_000
|
||||
useCaches = false
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
return try {
|
||||
val status = connection.responseCode
|
||||
if (status !in 200..299) {
|
||||
val body = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
val json = runCatching { JSONObject(body) }.getOrElse { JSONObject() }
|
||||
throw AgentApiException(status, json.optString("code", "HTTP_$status"), json.optString("message", "图搜参考图下载失败"), status >= 500)
|
||||
}
|
||||
connection.inputStream.use { it.readBytes() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun nextTask(token: String): AgentTask? {
|
||||
val response = request("GET", "/api/agent/v1/tasks/next", null, token) ?: return null
|
||||
return task(response.getJSONObject("data"))
|
||||
@@ -584,6 +621,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(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 pddOrderAmountCent: Long? = null, val warningCode: String? = null, val warningMessage: String? = null,
|
||||
) {
|
||||
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()}" +
|
||||
(warningCode?.let { ";需人工检查($it${warningMessage?.let { message -> ":$message" }.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) }
|
||||
item.pddOrderAmountCent?.let { put("pddOrderAmountCent", 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"),
|
||||
if (it.isNull("pddOrderAmountCent")) null else it.optLong("pddOrderAmountCent"),
|
||||
if (it.isNull("warningCode")) null else it.optString("warningCode"),
|
||||
if (it.isNull("warningMessage")) null else it.optString("warningMessage"))
|
||||
} }
|
||||
}
|
||||
|
||||
/** 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,6 +11,34 @@ 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)
|
||||
.putNullable("pddOrderAmountCent", result.pddOrderAmountCent)
|
||||
.putNullable("warningCode", result.warningCode).putNullable("warningMessage", result.warningMessage)
|
||||
.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)
|
||||
@@ -90,9 +118,21 @@ 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("pddOrderAmountCent", result.pddOrderAmountCent ?: JSONObject.NULL)
|
||||
.put("backfillWarningCode", result.warningCode ?: JSONObject.NULL)
|
||||
.put("backfillWarningMessage", result.warningMessage ?: JSONObject.NULL)
|
||||
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
|
||||
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
|
||||
}
|
||||
|
||||
+247
-2
@@ -31,6 +31,9 @@ import cn.ilapage.goauto.agent.automation.PddLaunchResult
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAssetStore
|
||||
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PreparedImageSearchAsset
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
@@ -75,6 +78,7 @@ 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)
|
||||
@@ -132,6 +136,14 @@ 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) {
|
||||
@@ -147,6 +159,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
collectionCooldownFuture.getAndSet(null)?.cancel(false)
|
||||
@@ -165,7 +178,7 @@ class AgentForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun triggerSync() {
|
||||
if (!working.compareAndSet(false, true)) return
|
||||
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
|
||||
executor.execute {
|
||||
val manualCheck = manualCheckRequested.getAndSet(false)
|
||||
try {
|
||||
@@ -307,6 +320,98 @@ 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?,
|
||||
@@ -414,6 +519,31 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把采购员看得懂的文案和内部原因拼在一起:文案在前,原始说法括注在后。
|
||||
*
|
||||
* 内部原因只描述页面形态与归位结果,不含凭据、账号、订单和个人数据(#294)。
|
||||
*/
|
||||
private fun imageSearchFailureMessage(code: String, internal: String): String {
|
||||
if (!code.startsWith("IMAGE_SEARCH")) return internal
|
||||
val user = imageSearchUserMessage(code)
|
||||
return if (internal.isBlank() || internal == user) user else "$user[$internal]"
|
||||
}
|
||||
|
||||
/** #277:图搜任务失败码到人话文案的映射,采购员看得懂即可,不暴露内部状态机细节。 */
|
||||
private fun imageSearchUserMessage(code: String): String = when (code) {
|
||||
"IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。"
|
||||
"IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。"
|
||||
"IMAGE_SEARCH_RESULTS_NOT_READY" -> "图搜结果页没有如期出现,请稍后重试。"
|
||||
"IMAGE_SEARCH_CANDIDATE_CLICK_FAILED" -> "搜到了商品但打不开,请稍后重试或人工关联。"
|
||||
"IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。"
|
||||
"IMAGE_SEARCH_CAMERA_BLOCKED" -> "拼多多摄像头一直识别不到东西,图搜页反复弹出重试提示。请把手机对着有内容的画面后重试。"
|
||||
"IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。"
|
||||
"IMAGE_SEARCH_ASSET_NOT_LATEST" -> "相册里有更新的照片或视频,无法确认图搜会用到我们准备的图片,请清理相册后重试。"
|
||||
"IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。"
|
||||
else -> "图搜任务失败,请稍后重试。"
|
||||
}
|
||||
|
||||
private fun currentPageUserMessage(code: String, fallback: String): String = when (code) {
|
||||
"DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。"
|
||||
"AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。"
|
||||
@@ -591,6 +721,7 @@ class AgentForegroundService : Service() {
|
||||
"order_result_unknown",
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
|
||||
)
|
||||
} else {
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
|
||||
@@ -708,6 +839,89 @@ 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") {
|
||||
// #277:相册权限是图搜可执行的前置条件。
|
||||
if (cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.current(this) ==
|
||||
cn.ilapage.goauto.agent.ui.MediaPermissionReadiness.NOT_GRANTED
|
||||
) {
|
||||
throw TaskFailure("IMAGE_SEARCH_PERMISSION_REQUIRED", imageSearchUserMessage("IMAGE_SEARCH_PERMISSION_REQUIRED"))
|
||||
}
|
||||
if (rule.schemaVersion != 2) {
|
||||
throw TaskFailure("RULE_INVALID", "图搜采集只支持新版采集规则")
|
||||
}
|
||||
val reference = task.imageSearch
|
||||
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
val assetStore = PinduoduoImageSearchAssetStore(this)
|
||||
var prepared: PreparedImageSearchAsset? = null
|
||||
try {
|
||||
when (accessibility.restoreRecentPddPage(::launchPddToForeground)) {
|
||||
PddForegroundRestoreResult.READY -> Unit
|
||||
PddForegroundRestoreResult.INTENT_MISSING ->
|
||||
throw TaskFailure("PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddForegroundRestoreResult.LAUNCH_FAILED ->
|
||||
throw TaskFailure("PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddForegroundRestoreResult.TIMEOUT ->
|
||||
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "拼多多未能切换到前台")
|
||||
}
|
||||
val bytes = try {
|
||||
api.downloadImageSearchReferenceImage(reference, token)
|
||||
} catch (error: AgentApiException) {
|
||||
throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
}
|
||||
prepared = assetStore.prepare(bytes, reference)
|
||||
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
if (!assetStore.isMostRecent(prepared)) {
|
||||
throw TaskFailure("IMAGE_SEARCH_ASSET_NOT_LATEST", imageSearchUserMessage("IMAGE_SEARCH_ASSET_NOT_LATEST"))
|
||||
}
|
||||
val imageSearch = PinduoduoImageSearchAutomation(accessibility, accessibility::currentPackage)
|
||||
// `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过
|
||||
// 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。
|
||||
val entry = imageSearch.enterAndSelectImage(::launchPddToForeground, ::resetPddToHome)
|
||||
// `[必须]` 保留归位状态机的原始说法。IMAGE_SEARCH_ENTRY_NOT_FOUND 有两个
|
||||
// 来源——「找不到唯一可点的拍照搜索入口」和「归位失败(预算耗尽)」——
|
||||
// 统一文案会把两者抹平,排查任务 156 时只能靠失败耗时反推(#294)。
|
||||
// 括注写法与下面候选点击失败一致。
|
||||
if (!entry.successful) throw TaskFailure(entry.code, imageSearchFailureMessage(entry.code, entry.message))
|
||||
if (!imageSearch.waitForResultsPage()) {
|
||||
throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY"))
|
||||
}
|
||||
val candidate = imageSearch.openFirstCandidate()
|
||||
if (!candidate.successful) {
|
||||
// 保留执行器给出的具体原因(含 result/reason),排查时不必再猜卡在哪一步。
|
||||
throw TaskFailure(candidate.code, "${imageSearchUserMessage(candidate.code)}[${candidate.message}]")
|
||||
}
|
||||
// 接回现有的分享链接识别 + 商品详情采集链路(与 agent_current_page 完全一致)。
|
||||
val identityResult = CurrentPageIdentityRunner(
|
||||
driver = accessibility,
|
||||
readFreshClipboard = { copiedAt, timeout ->
|
||||
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
|
||||
},
|
||||
taskId = task.taskId,
|
||||
diagnostic = diagnosticRecorder::record,
|
||||
).identify(rule)
|
||||
if (!identityResult.successful) throw TaskFailure(identityResult.code, identityResult.message)
|
||||
val identity = try {
|
||||
api.identifyCurrentPageCollectionTask(
|
||||
task.taskId,
|
||||
UUID.randomUUID().toString(),
|
||||
requireNotNull(identityResult.shareUrl),
|
||||
token,
|
||||
)
|
||||
} catch (error: AgentApiException) {
|
||||
throw TaskFailure(error.code, error.message)
|
||||
}
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddProductDetailCollector(
|
||||
accessibility,
|
||||
trace = trace,
|
||||
taskId = task.taskId,
|
||||
diagnostic = diagnosticRecorder::record,
|
||||
).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} finally {
|
||||
assetStore.delete(prepared)
|
||||
}
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
@@ -1038,10 +1252,15 @@ 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(content)
|
||||
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
@@ -1052,6 +1271,27 @@ class AgentForegroundService : Service() {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把拼多多的返回栈清到根活动,等于回首页。
|
||||
*
|
||||
* `[必须]` 不能复用 [launchPddToForeground]:那个只带 FLAG_ACTIVITY_NEW_TASK,
|
||||
* 会把已有任务**连同深层页面**一起恢复到前台,采集当前页的流程正需要这个行为。
|
||||
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
|
||||
*/
|
||||
private fun resetPddToHome(): PddLaunchResult {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
return runCatching { startActivity(launchIntent) }
|
||||
.fold(
|
||||
onSuccess = { PddLaunchResult.STARTED },
|
||||
onFailure = {
|
||||
Log.w("GoAutoAgent", "failed to reset PDD to home", it)
|
||||
PddLaunchResult.FAILED
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchPddToForeground(): PddLaunchResult {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
@@ -1067,6 +1307,11 @@ 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"
|
||||
|
||||
@@ -104,6 +104,10 @@ internal enum class TaskDispatchDecision {
|
||||
CHECK_COLLECTION,
|
||||
}
|
||||
|
||||
// 采集 > 图搜的优先级不在这里实现,也无法在这里实现:图搜任务就是 source =
|
||||
// "image_search" 的普通采集任务,由同一个 /tasks/next 端点下发,Agent 调用时
|
||||
// 服务端已经选好了行。该排序落在服务端 task.Service.Next 的 ORDER BY 里(#277)。
|
||||
|
||||
internal object TaskDispatchPolicy {
|
||||
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
|
||||
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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")
|
||||
}
|
||||
@@ -16,6 +16,10 @@ internal fun purchaseResultPayload(
|
||||
put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR")
|
||||
put("errorMessage", outcome.message.take(1000))
|
||||
}
|
||||
// 仅 order_result_unknown 有意义;服务端只在这个结果类型下读取,其它类型忽略。
|
||||
if (outcome.resultType == "order_result_unknown" && outcome.paymentPageObserved) {
|
||||
put("paymentPageObserved", true)
|
||||
}
|
||||
outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) }
|
||||
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
|
||||
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
|
||||
@@ -51,3 +56,36 @@ object SettingsAvailabilityResolver {
|
||||
fun editable(stateCode: String, currentTaskId: Long?, testing: Boolean): Boolean =
|
||||
!testing && stateCode != "BUSY" && currentTaskId == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册权限状态(#276):`MainActivity.onCreate` 只在首次无条件弹一次系统授权框,
|
||||
* 用户拒绝(尤其"不再询问")后没有任何补救入口。这里补一个可复用的状态查询 +
|
||||
* 引导跳转,供设置/状态页和图搜执行器共用,模式与 [AccessibilityReadinessDetector] 保持一致。
|
||||
*/
|
||||
enum class MediaPermissionReadiness {
|
||||
GRANTED,
|
||||
NOT_GRANTED,
|
||||
}
|
||||
|
||||
object MediaPermissionPolicy {
|
||||
/** 供图搜执行器调用的只读查询:未授权时应返回明确失败原因,不弹窗、不静默失败。 */
|
||||
fun current(context: Context): MediaPermissionReadiness {
|
||||
val permission = requiredPermission()
|
||||
val granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
return if (granted) MediaPermissionReadiness.GRANTED else MediaPermissionReadiness.NOT_GRANTED
|
||||
}
|
||||
|
||||
fun requiredPermission(): String =
|
||||
if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
|
||||
/**
|
||||
* "不再询问"之后系统弹窗不会再出现,只能引导用户去应用详情页手动开启。
|
||||
* 调用方在 `shouldShowRequestPermissionRationale` 返回 false 且权限仍未授予时使用本方法。
|
||||
*/
|
||||
fun appDetailsSettingsIntent(context: Context): Intent =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,17 @@ class AgentStatusFragment : Fragment() {
|
||||
private lateinit var taskText: TextView
|
||||
private lateinit var accessibilityTitle: TextView
|
||||
private lateinit var accessibilityDetail: TextView
|
||||
private lateinit var mediaPermissionTitle: TextView
|
||||
private lateinit var mediaPermissionDetail: TextView
|
||||
private lateinit var mediaPermissionAction: android.widget.Button
|
||||
private lateinit var deviceText: TextView
|
||||
private val mediaPermissionRequestLauncher by lazy {
|
||||
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
private var receiverRegistered = false
|
||||
private var mediaPermissionRequestedOnce = false
|
||||
|
||||
private val refresh = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -144,6 +153,21 @@ class AgentStatusFragment : Fragment() {
|
||||
addView(accessibilityTitle)
|
||||
addView(accessibilityDetail)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("相册权限(图搜用)", 14f, context.getColor(R.color.agent_text_muted)))
|
||||
mediaPermissionTitle = context.label("正在检查", 18f, context.getColor(R.color.agent_warning), true)
|
||||
mediaPermissionDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
addView(mediaPermissionTitle)
|
||||
addView(mediaPermissionDetail)
|
||||
mediaPermissionAction = android.widget.Button(context).apply {
|
||||
text = "重新授权"
|
||||
setPadding(0, context.dp(8), 0, 0)
|
||||
setOnClickListener { requestMediaPermission() }
|
||||
}
|
||||
addView(mediaPermissionAction)
|
||||
}))
|
||||
}
|
||||
refreshLayout = SwipeRefreshLayout(context).apply {
|
||||
setColorSchemeResources(R.color.agent_primary_light)
|
||||
@@ -258,10 +282,44 @@ class AgentStatusFragment : Fragment() {
|
||||
}
|
||||
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
|
||||
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
val mediaReadiness = MediaPermissionPolicy.current(context)
|
||||
when (mediaReadiness) {
|
||||
MediaPermissionReadiness.GRANTED -> {
|
||||
mediaPermissionTitle.text = "已授权"
|
||||
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_primary_light))
|
||||
mediaPermissionDetail.text = "图搜任务可以正常准备参考图"
|
||||
mediaPermissionAction.visibility = View.GONE
|
||||
}
|
||||
MediaPermissionReadiness.NOT_GRANTED -> {
|
||||
mediaPermissionTitle.text = "未授权"
|
||||
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_error))
|
||||
mediaPermissionDetail.text = "未授权时图搜任务会直接失败,不影响普通采集/采购"
|
||||
mediaPermissionAction.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
val displayName = settingsStore.deviceName().ifBlank { "本机" }
|
||||
deviceText.text = "$displayName · ${state.deviceId.takeIf { it > 0 }?.let { "#$it" } ?: "未注册"}"
|
||||
}
|
||||
|
||||
private fun requestMediaPermission() {
|
||||
val context = requireContext()
|
||||
val permission = MediaPermissionPolicy.requiredPermission()
|
||||
if (shouldShowRequestPermissionRationale(permission)) {
|
||||
mediaPermissionRequestLauncher.launch(permission)
|
||||
return
|
||||
}
|
||||
// 首次请求,或用户已选择"不再询问":先尝试系统弹窗;若此前已经拒绝过一次且
|
||||
// rationale 也不再展示(即"不再询问"状态),跳转到应用详情页由用户手动开启。
|
||||
if (context.checkSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED &&
|
||||
!mediaPermissionRequestedOnce
|
||||
) {
|
||||
mediaPermissionRequestedOnce = true
|
||||
mediaPermissionRequestLauncher.launch(permission)
|
||||
} else {
|
||||
startActivity(MediaPermissionPolicy.appDetailsSettingsIntent(context))
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerCheckReceiver() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CHECK_RESULT)
|
||||
|
||||
@@ -141,8 +141,16 @@ 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)
|
||||
@@ -182,6 +190,11 @@ 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())
|
||||
@@ -207,6 +220,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
backfillPanel = null
|
||||
requestGeneration++
|
||||
cancelImageRequests()
|
||||
super.onDestroyView()
|
||||
@@ -273,6 +287,14 @@ 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 {
|
||||
@@ -280,6 +302,75 @@ 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) {
|
||||
@@ -427,6 +518,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
return context.card(context.cardColumn().apply {
|
||||
val goodsLabel = item.goodsId.ifBlank { "未识别商品" }
|
||||
addView(context.label("#${item.taskId} · 第 ${item.attemptNumber} 次 · $goodsLabel", 17f, context.getColor(R.color.agent_text), true))
|
||||
// #279:图搜任务和普通采集混在同一列表里,加一个行内标记让采购员看出这条商品是机器图搜找到的,
|
||||
// 不改变既有 `collection` 布尔分叉的界面结构。
|
||||
if (item.source == "image_search") {
|
||||
addView(context.label("来源:图搜自动匹配", 13f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(2), 0, 0) })
|
||||
}
|
||||
addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
|
||||
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
|
||||
if (CollectionResetPolicy.showsListAction(item.status)) {
|
||||
@@ -632,6 +728,7 @@ 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 {
|
||||
@@ -724,6 +821,12 @@ 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))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.AgentCapabilities
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentCapabilitiesTest {
|
||||
|
||||
// #277: 服务端 taskCompatible 对 source='image_search' 的任务按这个能力过滤。
|
||||
// 漏声明时图搜任务永远不会被领取,而且是静默的:任务停在 pending、
|
||||
// 不失败也不报错。字符串必须与 task.ImageSearchCapability 逐字一致。
|
||||
@Test
|
||||
fun `supported capabilities include the image search capability the server gates on`() {
|
||||
assertTrue(
|
||||
"AgentCapabilities.supported 缺少 pdd.image-search.v1,图搜任务将无法被领取",
|
||||
AgentCapabilities.supported.contains("pdd.image-search.v1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supported capabilities have no duplicates`() {
|
||||
assertTrue(
|
||||
"能力清单出现重复项: " + AgentCapabilities.supported,
|
||||
AgentCapabilities.supported.size == AgentCapabilities.supported.toSet().size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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 `paid amount accepts exact label formats and ignores unrelated prices`() {
|
||||
for ((label, cents) in listOf("实付:,22.98元,(免运费)" to 2298L, "实付: 22元" to 2200L, "实付,22.9 元" to 2290L)) {
|
||||
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n¥999.00\n$label") }.finish()
|
||||
assertEquals(cents, detail.item!!.pddOrderAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `paid amount ambiguity or malformed candidate omits only amount`() {
|
||||
for (labels in listOf(
|
||||
"实付:22.98元\n实付:23.00元",
|
||||
"实付:22.98元\n实付:坏值元",
|
||||
"实付:22.345元",
|
||||
"实付:\n22.98元",
|
||||
"未实付:99.00元\n预计实付:88.00元",
|
||||
)) {
|
||||
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n下单时间:2026-09-08 10:00:00\n$labels") }.finish()
|
||||
assertNotNull(detail.item)
|
||||
assertNull(labels, detail.item!!.pddOrderAmountCent)
|
||||
assertEquals("TEST-000007", detail.item!!.pddOrderNo)
|
||||
}
|
||||
val duplicate = BackfillDetailReader(zone).apply {
|
||||
accept("_cg7\n订单号:TEST-000007\n实付:22.98元")
|
||||
accept("实付:22.98元")
|
||||
}.finish()
|
||||
assertEquals(2298L, duplicate.item!!.pddOrderAmountCent)
|
||||
val reused = BackfillDetailReader(zone)
|
||||
reused.accept("_cg7\n订单号:TEST-000007\n实付:1.00元")
|
||||
assertEquals(100L, reused.finish().item!!.pddOrderAmountCent)
|
||||
reused.accept("_cg8\n订单号:TEST-000008\n实付:0元")
|
||||
assertEquals(0L, reused.finish().item!!.pddOrderAmountCent)
|
||||
reused.accept("_cg9\n订单号:TEST-000009\n实付:999999999999999999999999.99元")
|
||||
assertNull(reused.finish().item!!.pddOrderAmountCent)
|
||||
}
|
||||
|
||||
@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("估算"))
|
||||
assertNull(parsed.single().pddOrderAmountCent)
|
||||
assertNull(parsed.single().warningCode)
|
||||
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
|
||||
val warning = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"already_backfilled","code":"ALREADY_BACKFILLED","status":"order_created","statusVersion":4,"pddOrderNo":"TEST-000007","pddOrderAmountCent":2298,"timeSource":"page","warningCode":"PURCHASE_BACKFILL_AMOUNT_CONFLICT","warningMessage":"已有不同实付金额,本次未覆盖","retryable":false}]}""")).single()
|
||||
assertEquals(2298L, warning.pddOrderAmountCent)
|
||||
assertTrue(warning.display().contains("PURCHASE_BACKFILL_AMOUNT_CONFLICT"))
|
||||
|
||||
val amountPayload = backfillPayload("test", listOf(item.copy(pddOrderAmountCent = 2298)))
|
||||
assertEquals(2298L, amountPayload.getJSONArray("items").getJSONObject(0).getLong("pddOrderAmountCent"))
|
||||
}
|
||||
|
||||
@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"))
|
||||
val warning = success().copy(pddOrderAmountCent = 2298, warningCode = "PURCHASE_BACKFILL_AMOUNT_CONFLICT", warningMessage = "conflict")
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, warning)
|
||||
assertEquals(2298L, task.getLong("pddOrderAmountCent"))
|
||||
assertEquals("PURCHASE_BACKFILL_AMOUNT_CONFLICT", task.getString("backfillWarningCode"))
|
||||
assertTrue(task.isNull("errorCode"))
|
||||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,35 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
// `[必须]` #302:卡在支付页放弃时,paymentPageObserved 必须为 true——这正是
|
||||
// 「大概率已经下单只是没读到证据」的信号,区分于真的什么都没发生的失败。
|
||||
@Test
|
||||
fun `payment repeated failure marks paymentPageObserved so it can be triaged later`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertTrue(automation.lastOrderReadFailure?.paymentPageObserved ?: false)
|
||||
}
|
||||
|
||||
// 从未出现过支付/待付款文案的失败不该被标记,否则这个字段就没有区分价值了。
|
||||
@Test
|
||||
fun `a failure that never saw the payment page leaves paymentPageObserved false`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(15) { "empty" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg30")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg30"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code)
|
||||
assertFalse(automation.lastOrderReadFailure?.paymentPageObserved ?: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageSearchAssetPolicyTest {
|
||||
|
||||
private fun jpegBytes(payloadSize: Int = 10): ByteArray {
|
||||
val body = ByteArray(payloadSize) { 0x11 }
|
||||
return byteArrayOf(0xff.toByte(), 0xd8.toByte()) + body + byteArrayOf(0xff.toByte(), 0xd9.toByte())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid returns true for matching jpeg with correct markers and hash`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertTrue(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects jpeg missing markers`() {
|
||||
val bytes = ByteArray(20) { 0x11 } // no ff d8 / ff d9
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects size mismatch`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong() + 1,
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects hash mismatch`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = "0".repeat(64),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects unsupported media type`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.gif",
|
||||
mediaType = "image/gif",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects oversized payload`() {
|
||||
val bytes = jpegBytes(ImageSearchAssetPolicy.MAX_BYTES)
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasJpegMarkers detects ff d8 ff d9 boundaries`() {
|
||||
assertTrue(ImageSearchAssetPolicy.hasJpegMarkers(jpegBytes()))
|
||||
assertFalse(ImageSearchAssetPolicy.hasJpegMarkers(byteArrayOf(1, 2, 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize returns null when already above minimum edge`() {
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(1200, 1200))
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(960, 960))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize scales up small square image to minimum edge`() {
|
||||
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(480, 480)!!
|
||||
assertEquals(960, w)
|
||||
assertEquals(960, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize preserves aspect ratio for narrow image`() {
|
||||
// width limits: scale = 960/100 = 9.6 -> height 200*9.6=1920
|
||||
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(100, 200)!!
|
||||
assertEquals(960, w)
|
||||
assertEquals(1920, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize gives up when scaling would exceed max edge`() {
|
||||
// width 10 -> scale 96 -> height 10*96=960 within max, but width*96=960... choose extreme
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(1, 5000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize rejects non positive dimensions`() {
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(0, 100))
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(100, 0))
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* #292:返回键推不动页面时不得空转到预算耗尽。
|
||||
*
|
||||
* 真机任务 156(2026-09-16)停在 PDD 深层页面,归位状态机 15 秒内按满 50 次返回仍未回到
|
||||
* 首页,报 IMAGE_SEARCH_ENTRY_NOT_FOUND。根因是 BACK 分支按完就走、固定 300ms 盲等,
|
||||
* 抓到的是切换动画中间态,被归为 OTHER_PDD 后继续按。
|
||||
*/
|
||||
class ImageSearchBackRecoveryTest {
|
||||
|
||||
/** 一个永远停在「其它拼多多页面」的驱动:返回键按多少次都不改变页面形态。 */
|
||||
private class StuckDriver : PddCollectorDriver {
|
||||
var backCount = 0
|
||||
override fun capture(): UiSnapshot = UiSnapshot(
|
||||
packageName = "com.xunmeng.pinduoduo",
|
||||
activityName = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
nodes = listOf(node("某个商品页")),
|
||||
)
|
||||
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { backCount++; return true }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(text: String) = SnapshotNode(
|
||||
path = "0", parentPath = null, text = text, contentDescription = null,
|
||||
resourceId = null, className = "android.widget.TextView",
|
||||
bounds = NodeBounds(0, 900, 200, 960),
|
||||
clickable = false, scrollable = false, selected = false,
|
||||
checked = false, enabled = true, visible = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stalled backs trigger a clear-top relaunch instead of burning the budget`() {
|
||||
val driver = StuckDriver()
|
||||
var resets = 0
|
||||
val automation = PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
currentPackage = { "com.xunmeng.pinduoduo" },
|
||||
pause = { /* 测试里不真的睡 */ },
|
||||
)
|
||||
val result = automation.enterAndSelectImage(
|
||||
launchPdd = { PddLaunchResult.STARTED },
|
||||
resetPddToHome = { resets++; PddLaunchResult.STARTED },
|
||||
)
|
||||
|
||||
// 页面始终推不动,最终仍然明确失败——不静默成功、不无限重试。
|
||||
assertEquals("IMAGE_SEARCH_ENTRY_NOT_FOUND", result.code)
|
||||
// 关键:不再是「按满 50 次返回」,每 MAX_STALLED_BACKS 次就改用清栈重启。
|
||||
assertTrue("返回推不动时应当改用清栈重启,实际重启 $resets 次", resets > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a back that does move the page does not count as stalled`() {
|
||||
// 能推动页面时不应触发清栈重启:驱动第一次返回后就变成首页形态。
|
||||
var moved = false
|
||||
val driver = object : PddCollectorDriver {
|
||||
override fun capture(): UiSnapshot = UiSnapshot(
|
||||
packageName = "com.xunmeng.pinduoduo",
|
||||
activityName = "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity",
|
||||
nodes = if (moved) {
|
||||
listOf(
|
||||
node("拍照搜索").copy(
|
||||
clickable = true,
|
||||
bounds = NodeBounds(900, 100, 1000, 200),
|
||||
contentDescription = "拍照搜索",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
listOf(node("某个商品页"))
|
||||
},
|
||||
)
|
||||
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { moved = true; return true }
|
||||
}
|
||||
var resets = 0
|
||||
PinduoduoImageSearchAutomation(driver, { "com.xunmeng.pinduoduo" }, pause = {})
|
||||
.enterAndSelectImage({ PddLaunchResult.STARTED }, { resets++; PddLaunchResult.STARTED })
|
||||
assertEquals("页面被返回键推动了,不该触发清栈重启", 0, resets)
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageSearchRecoveryPolicyTest {
|
||||
|
||||
private val entryTexts = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照", "最近项目")
|
||||
private val resultsTexts = listOf("搜图片同款", "综合", "销量", "价格", "品牌")
|
||||
private val retryTexts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
|
||||
private val homeTexts = listOf("拍照搜索", "首页", "推荐")
|
||||
|
||||
@Test
|
||||
fun `classify recognizes non-pdd package regardless of texts`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.android.launcher", entryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.NON_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes image search entry page`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", entryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_ENTRY, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes results page over other markers`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", resultsTexts, 4, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_RESULTS, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes retry dialog`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes order confirm page by read-only markers without treating them as click targets`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("提交订单", "收货地址"), 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.ORDER_CONFIRM, kind)
|
||||
// order confirm only ever maps to BACK -- never a click action.
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(kind))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes home page only when a clickable camera entry exists`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = true,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.HOME, kind)
|
||||
}
|
||||
|
||||
// 真机核对(2026-09-15):个人中心 tab 的根节点也带 content-desc='拍照搜索',
|
||||
// 覆盖全屏且不可点,而 visibleTexts() 会把 contentDescription 当文本收集。
|
||||
// 只看文案会把个人中心判成首页,几何兜底随后会点到「设置」。
|
||||
@Test
|
||||
fun `classify refuses home when camera text is present but nothing clickable carries it`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = false,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify falls back to other pdd page`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("商品详情", "加入购物车"), 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextAction maps each page kind to the expected recovery action`() {
|
||||
assertEquals(ImageSearchRecoveryAction.NONE, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_ENTRY))
|
||||
assertEquals(ImageSearchRecoveryAction.CLICK_CAMERA, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.HOME))
|
||||
assertEquals(ImageSearchRecoveryAction.LAUNCH_PDD, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.NON_PDD))
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_RESULTS))
|
||||
// RETRY_DIALOG 不再是 BACK,理由见下方 retry dialog must be dismissed by cancel。
|
||||
assertEquals(ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG))
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.OTHER_PDD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovery budget caps at the documented limit`() {
|
||||
assertFalse(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS - 1))
|
||||
assertTrue(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS))
|
||||
}
|
||||
|
||||
// `[必须]` 2026-09-15 真机实测:按 BACK 对这个弹窗完全无效,前后两次 uiautomator
|
||||
// dump 逐字节相同;唯一出口是点「取消」。此前把它映射成 BACK,导致任务在弹窗上
|
||||
// 空转直到预算耗尽、报 IMAGE_SEARCH_ENTRY_NOT_FOUND,而弹窗一直留在屏幕上。
|
||||
@Test
|
||||
fun `retry dialog must be dismissed by cancel, never by back`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
|
||||
assertEquals(
|
||||
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG,
|
||||
ImageSearchRecoveryPolicy.nextAction(kind),
|
||||
)
|
||||
}
|
||||
|
||||
// 其余需要归位的页面仍然用返回,不受上面那条影响。
|
||||
@Test
|
||||
fun `other pages still recover by back`() {
|
||||
for (kind in listOf(
|
||||
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
|
||||
ImageSearchPageKind.ORDER_CONFIRM,
|
||||
ImageSearchPageKind.OTHER_PDD,
|
||||
)) {
|
||||
assertEquals(
|
||||
"$kind 的归位动作被改动了",
|
||||
ImageSearchRecoveryAction.BACK,
|
||||
ImageSearchRecoveryPolicy.nextAction(kind),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗会退出图搜回到首页,重新进入又会撞上同一个约 4 秒的摄像头识别窗口,
|
||||
// 因此必须有独立且很小的上限,不能落在通用的 50 次预算里空转数分钟。
|
||||
@Test
|
||||
fun `retry dialog recovery budget is separate and small`() {
|
||||
assertTrue(
|
||||
"弹窗恢复上限必须显著小于通用恢复预算",
|
||||
ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES in
|
||||
1 until ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS / 5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchCandidatePolicyTest {
|
||||
|
||||
private fun card(top: Int, left: Int, ad: Boolean = false, loaded: Boolean = true) =
|
||||
ImageSearchResultCard(top = top, left = left, width = 535, height = 764, texts = if (ad) listOf("广告", "¥19.9") else listOf("¥19.9"), imageLoaded = loaded)
|
||||
|
||||
@Test
|
||||
fun `picks the first card in reading order`() {
|
||||
val cards = listOf(card(top = 1085, left = 0), card(top = 312, left = 0), card(top = 312, left = 545))
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
assertEquals(0, chosen?.left)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips ad cards and takes the next candidate`() {
|
||||
val cards = listOf(
|
||||
card(top = 312, left = 0),
|
||||
card(top = 312, left = 545, ad = true),
|
||||
card(top = 1085, left = 0, ad = true),
|
||||
card(top = 1085, left = 545),
|
||||
)
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
assertEquals(0, chosen?.left)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates a candidate whose image has not finished loading`() {
|
||||
val cards = listOf(card(top = 312, left = 0, loaded = false))
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when every candidate is an ad`() {
|
||||
val cards = listOf(card(top = 312, left = 0, ad = true), card(top = 312, left = 545, ad = true))
|
||||
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(cards))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for an empty candidate list`() {
|
||||
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchClickTargetTest {
|
||||
|
||||
private fun node(top: Int, left: Int, right: Int, bottom: Int, text: String?) = SnapshotNode(
|
||||
path = "0/$top/$left", parentPath = "0", text = text, contentDescription = null,
|
||||
resourceId = null, className = "android.widget.TextView",
|
||||
bounds = NodeBounds(left, top, right, bottom),
|
||||
clickable = false, scrollable = false, selected = false, checked = false,
|
||||
enabled = true, visible = true,
|
||||
)
|
||||
|
||||
// 真机 dump(2026-09-15)的第一张卡片:[0,600] 535x764。
|
||||
private val card = ImageSearchResultCard(top = 600, left = 0, width = 535, height = 764, texts = emptyList())
|
||||
|
||||
// `[必须]` 卡片容器自身无文案,label 为空串;clickFreshDetailed 重找时用的是
|
||||
// preferredOrDescendantLabel()(会挖子孙),两者永远对不上,直接 TARGET_NOT_FOUND。
|
||||
// 所以点击目标必须是卡片内有文案的子节点。
|
||||
@Test
|
||||
fun `click target is the topmost labelled node inside the card`() {
|
||||
val nodes = listOf(
|
||||
node(600, 0, 535, 1364, null), // 卡片容器本身,无文案
|
||||
node(1100, 20, 300, 1140, "¥21.8"),
|
||||
node(1000, 20, 500, 1060, "港风时尚"), // 最靠上的有文案节点
|
||||
)
|
||||
val target = ImageSearchCandidatePolicy.clickTargetInside(card, nodes)
|
||||
assertEquals("港风时尚", target?.label)
|
||||
}
|
||||
|
||||
// 相邻卡片的文案不得被当成本卡片的内容。真机上右侧卡片从 x=545 开始。
|
||||
@Test
|
||||
fun `nodes outside the card bounds are ignored`() {
|
||||
val nodes = listOf(node(1000, 565, 1040, 1060, "森系假两件"))
|
||||
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, nodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `card without any labelled child yields no target`() {
|
||||
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, listOf(node(700, 10, 500, 900, null))))
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchMediaRecencyPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `is most recent when our entry sorts first`() {
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
|
||||
ImageSearchMediaEntry(id = 90, dateAdded = 1_000),
|
||||
)
|
||||
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails when a newer video sorts ahead of our image`() {
|
||||
// Regression for pitfall 2: the recent-items grid mixes photos and videos; if a
|
||||
// freshly-recorded video is newer than the jpeg we wrote, we must not claim first slot.
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 101, dateAdded = 3_000), // newer video, not ours
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000), // our prepared jpeg
|
||||
)
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails when our own entry is missing from the query result`() {
|
||||
val entries = listOf(ImageSearchMediaEntry(id = 5, dateAdded = 1_000))
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails on an empty media list rather than guessing`() {
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(emptyList(), ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ties break by id so a same-timestamp newer insert still wins`() {
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 101, dateAdded = 2_000),
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
|
||||
)
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 101))
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PinduoduoImageSearchCriteriaTest {
|
||||
|
||||
@Test
|
||||
fun `isImageSearchEntryPage requires all four signals`() {
|
||||
val complete = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照即可开始识别")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(complete))
|
||||
|
||||
val missingHint = listOf("我的相册", "最近搜索", "历史浏览")
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchEntryPage(missingHint))
|
||||
|
||||
val alternateHint = listOf("我的相册", "最近搜索", "历史浏览", "开启相机权限")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(alternateHint))
|
||||
|
||||
val thirdHintVariant = listOf("我的相册", "最近搜索", "历史浏览", "拍摄清晰照片即可进行自动识别")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(thirdHintVariant))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isImageSearchResultsPage requires title and enough sort controls`() {
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 3))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 2))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("其他标题"), sortControlCount = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isRetryDialog requires hint text plus both buttons`() {
|
||||
val texts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isRetryDialog(texts))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("请对准商品或码,保持手机稳定", "取消")))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("取消", "再试一次")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstRecentImageGridCell picks topmost then leftmost four column cell`() {
|
||||
val screenWidth = 1080
|
||||
val cellWidth = screenWidth / 4 // 270
|
||||
val candidates = listOf(
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 200, left = cellWidth, width = cellWidth, height = cellWidth),
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = cellWidth * 2, width = cellWidth, height = cellWidth),
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = 0, width = cellWidth, height = cellWidth),
|
||||
// not a 4-column cell (too narrow height ratio)
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 50, left = 0, width = cellWidth, height = 10),
|
||||
)
|
||||
val result = PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth)
|
||||
assertTrue(result != null)
|
||||
assertTrue(result!!.top == 100 && result.left == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstRecentImageGridCell returns null when nothing matches grid shape`() {
|
||||
val screenWidth = 1080
|
||||
val candidates = listOf(
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 0, left = 0, width = 1080, height = 100),
|
||||
)
|
||||
assertNull(PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TaskDispatchPolicyTest {
|
||||
|
||||
// 四条既有路径的回归。图搜任务由服务端 ORDER BY 垫底(见
|
||||
// task.Service.Next),客户端这里不参与来源优先级,保持四分支不变。
|
||||
|
||||
@Test
|
||||
fun `spec probe pending waits for purchase match regardless of cooldown`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
|
||||
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = false),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
|
||||
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other purchase status runs purchase`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.RUN_PURCHASE,
|
||||
TaskDispatchPolicy.decide("matched", collectionCooldownActive = false),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.RUN_PURCHASE,
|
||||
TaskDispatchPolicy.decide("matched", collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no purchase status and cooldown active waits for cooldown`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
|
||||
TaskDispatchPolicy.decide(null, collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no purchase status and no cooldown checks collection`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.CHECK_COLLECTION,
|
||||
TaskDispatchPolicy.decide(null, collectionCooldownActive = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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: f465790ee95fef0f1a501626da50cc66d740f0fc
|
||||
synchronized_at: 2026-09-08T01:38:06Z
|
||||
wiki_revision: d192f3976c44b15a53221211eaed903bdec4fcd6
|
||||
synchronized_at: 2026-09-18T01:23:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -68,6 +68,8 @@ Android Portal/Agent
|
||||
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
|
||||
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
|
||||
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
|
||||
|
||||
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
|
||||
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
@@ -364,3 +366,110 @@ 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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
## 人工订单回填金额扩展(#306)
|
||||
|
||||
实现绑定 8c01329(2026-09-18),分支 feat/306-order-paid-amount;待验收、未部署。复用 #241/#242 链路,不改普通采购提交和扫描器导航。
|
||||
|
||||
- Android automation/OrderBackfillPolicy.kt:BackfillDetailReader 在订单内收集唯一“实付”金额,使用 BigDecimal 精确转换为人民币分;BackfillItem 增加可选 pddOrderAmountCent。
|
||||
- Android network/OrderBackfillUpload.kt 与 persistence/TaskHistoryCache.kt:上传可选金额,读取并缓存 Server 确认金额和独立警告;不把成功警告改为任务失败。
|
||||
- Server models/purchase.go:purchase_task.pdd_order_amount_cent 为 nullable BIGINT、非负;与 actual_unit_price_cent 独立。追加迁移 1789800100000_purchase_order_amount.go 使用版本内的局部表模型,仅扩展 purchase_task;历史值 NULL,不回填猜测金额。
|
||||
- Server purchase/order_backfill.go:原有逐条事务/设备与订单号保护内补齐空金额;相同值幂等,异值保留并警告;已 order_created 同号任务也能补写。purchase/admin_query.go 输出金额。
|
||||
- Web views/goauto/purchase-tasks/index.vue:复用现有订单 state-card 显示 CNY 实付价格或“尚未获取”;列表结构不变。
|
||||
- 本次不修改 SYB 客户端或 #305,金额不进入 SYB 请求。
|
||||
|
||||
验证覆盖 Android 解析/载荷/回执缓存、Server 幂等/冲突/旧请求和局部 SQLite 迁移、Web 金额/空值展示;真机端到端、实际 MySQL 迁移和发布须另行授权,不能以本地测试替代。
|
||||
|
||||
@@ -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: 7d7f515652a2b24880e55cfd95e94d3c25cbbc4d
|
||||
synchronized_at: 2026-09-08T02:22:59Z
|
||||
wiki_revision: d11ff945f559869ad638de2c0da198cbf6f31d7e
|
||||
synchronized_at: 2026-09-18T01:23:55Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -37,6 +37,8 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
- #257:蝦皮颜色与尺码建议/一键匹配先保留仍有效的已确认映射,再复用相同标准化比较:简体、空白、全半角与大小写统一。颜色标准化后仅命中一个当前可选 PDD 候选时,不调用 AI,建议标为 matched,一键匹配按既有流程保存 exact_match + confirmed;未命中或多个不同候选标准化后相同,仍走原 AI 与置信度/候选校验。只转换比较键,蝦皮/PDD 原规格名不改,映射目标保存 PDD 原始候选名称;不引入颜色同义词猜测、不自动重算历史映射。
|
||||
|
||||
## SYB 商品明细
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
@@ -58,6 +60,13 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号、可选下单时间,以及 #306 的可选实付金额。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -97,6 +106,8 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
|
||||
|
||||
- #256(Android 0.9.73,实现 43703e0):采集执行器可识别无“已选/请选择”摘要且当前仅一个规格标题的滚动规格面板。必须匹配 PDD 包名、任务详情 Activity 与选择器,无登录/验证等异常,并同时具备唯一有界规格滚动区域、唯一标题及非空候选、关闭、唯一数量输入/增减、支付区和唯一下方提交订单只读动作;缺失任一证据仍拒绝。此分支只由采集上下文启用,不生成虚假选中摘要,采购与其他解析调用维持原条件。订单动作仍仅作只读证据,采集绝不点击。
|
||||
|
||||
## 采集任务
|
||||
|
||||
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
|
||||
@@ -397,7 +408,7 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
@@ -475,3 +486,135 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
`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日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
## PDD 订单实付价格(#306)
|
||||
|
||||
实现绑定 8c01329(2026-09-18),待验收,尚未部署。人工“我的订单”回填除订单号和下单时间外,可提交唯一、合法的“实付”价格。它是订单展示总额,人民币分,独立存为 purchase_task.pdd_order_amount_cent;actual_unit_price_cent 仍是下单前观察的商品单价,二者不互相推算。
|
||||
|
||||
不判断是否付款,不改变人工支付复核状态。没看到实付、格式异常或多个不同实付值时,仅跳过金额,订单号和时间照常按原规则回填。采购员可稍后再点既有“回填”,为空金额补写;已有相同值幂等,不同值保留旧值并返回金额冲突提示,不撤销正确订单事实。只允许当前设备自己的、原有允许状态的任务;不新增 attempt 或采购动作。
|
||||
|
||||
金额仅保存 Admin;#305 订单号回写 SYB 的数据流不得使用该字段或将其映射为 cost,金额缺失/冲突不得成为订单号回写的前置条件。本单不代表 #305 已实现。Admin 现有订单详情卡片增加“实付价格”,缺失显示“尚未获取”,不新增列表列、入口或支付动作。安全点击集合及不保存原始控件树/个人信息的规则不变。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
```
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
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: 217239a85b50c880e004ecc19a559fb5e03555e1
|
||||
synchronized_at: 2026-09-08T01:38:42Z
|
||||
wiki_revision: 05441afb3c22828b8d17d4b9dcf2592075092076
|
||||
synchronized_at: 2026-09-18T01:24:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: ba259178cf6514d5b48ea425005dfd366ba803a3
|
||||
synchronized_at: 2026-09-12T03:09:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -100,6 +108,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 +591,17 @@ 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` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
`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 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
@@ -782,6 +827,158 @@ Content-Type: application/json
|
||||
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
|
||||
|
||||
|
||||
## SYB 商品 PDD 图搜采集(#275~#280)
|
||||
|
||||
图搜任务**就是采集任务**:同一张 `collection_task` 表、`source = 'image_search'`、同一个
|
||||
`/api/agent/v1/tasks/*` 端点。与 `agent_current_page` 的唯一区别是**谁打开那个商品页**——
|
||||
后者由采购员手动打开,图搜由 Agent 用 PDD 的拍照搜索找到并打开。打开之后的链路
|
||||
(分享链接 → goods_id → 身份回填 → 采集 → 提交结果)完全复用,没有新的采集接口。
|
||||
|
||||
### 管理端:批量创建
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/collection-tasks/image-search/batch
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>","sybProductIds":[1,2,3],"ruleId":3,"deviceId":null,"overwriteLinked":false}
|
||||
```
|
||||
|
||||
- **按 `shopee_product` 去重**:多条共用同一张参考图的 SYB 明细合并为一个任务。请求最多
|
||||
100 个 `sybProductIds`。
|
||||
- **批次上限作用于去重后的任务数**,不是入参明细数,上限 50(约 17~33 分钟设备占用,
|
||||
按单次图搜 20~40 秒估算)。超限整批拒绝,不部分创建。该值是服务端常量,**不可由环境
|
||||
变量覆盖**:确认对话框必须在提交前禁用按钮,前端持有同一个数字,可覆盖只会让两侧静默
|
||||
不一致。改动时两侧必须同改。
|
||||
- `deviceId` 可空,为空由具备能力的空闲设备领取。`overwriteLinked` 为 false 时已关联 PDD
|
||||
的蝦皮商品返回 `IMAGE_SEARCH_ALREADY_LINKED` 并跳过。
|
||||
- 响应逐条返回 `shopeeProductId` / `sybProductIds` / `taskId` 或 `code` + `message`。
|
||||
|
||||
### 设备能力
|
||||
|
||||
图搜任务要求设备声明 `pdd.image-search.v1`。不具备该能力的设备在领取阶段跳过图搜任务,
|
||||
不会失败。
|
||||
|
||||
### 任务下发
|
||||
|
||||
`GET /api/agent/v1/tasks/next` 与 `claim` 返回的载荷结构对所有来源一致,图搜任务额外携带
|
||||
`imageSearch`:
|
||||
|
||||
```json
|
||||
{"imageUrl":"https://...","mediaType":"image/jpeg","sizeBytes":81234,"sha256":"<64 位小写 hex>"}
|
||||
```
|
||||
|
||||
- **只下发这四个字段。** 关联关系、参考价、价格倍数、去重明细等快照留在服务端的
|
||||
`image_search_snapshot`,不下发给 Agent。
|
||||
- 服务端不下发图片字节;Agent 自行下载 `imageUrl` 并按 `sizeBytes` 与 `sha256` 校验。
|
||||
参考图上限 10MB。
|
||||
- 身份回填前 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串,
|
||||
与 `agent_current_page` 相同。
|
||||
|
||||
### 身份回填
|
||||
|
||||
图搜复用 `POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify`,该接口已同时
|
||||
接受 `agent_current_page` 与 `image_search` 两种来源。
|
||||
|
||||
- `[已核对]` 该接口要求任务已绑定 `device_id`。批量创建允许 `deviceId` 为空,但
|
||||
`Claim` 在事务中写入 `device_id = <领取设备>`,因此 Agent 调用 identify 时设备必定已绑定。
|
||||
- 回填之后的结果提交、失败上报、reset 重采与 `agent_current_page` 完全一致。
|
||||
**`ruleSnapshot` 结构与普通采集任务同构**,图搜不引入第二种规则形态。
|
||||
|
||||
### 结果自动关联与价格兜底
|
||||
|
||||
任务完成后服务端尝试建立 shopee↔PDD 关联,条件全部满足才写入:
|
||||
|
||||
- 任务 `source = 'image_search'`、状态 `completed`、已回填 `pddProductId`;
|
||||
- 目标蝦皮商品 `pdd_product_id IS NULL AND image_search_linked = false`(CAS 谓词:Agent
|
||||
执行期间发生的人工改动胜出,不被覆盖);
|
||||
- 蝦皮商品币种为 CNY。**非 CNY 一律不自动关联**,快照记 `priceGuardSkipped` 与
|
||||
`CROSS_CURRENCY_TWD_PDD_CNY`,留给人工复核;
|
||||
- 候选价与 `syb_product.unit_price_cent` 的偏离在 `maxPriceRatio`(1~100 的整数)之内。
|
||||
|
||||
自动建立的关联置 `shopee_product.image_search_linked = true`,含义是**「机器找的,没人核过」**。
|
||||
采购员在虾皮商品页手动关联(`LinkPDD`)时置回 false。该标记只读地出现在批量采购预览
|
||||
(`BatchPreviewItem.imageSearchLinked`)与采购任务列表,**不参与任何采购门禁、资格判定或
|
||||
下单条件**。
|
||||
|
||||
### 调度:采集 > 图搜
|
||||
|
||||
图搜任务在领取队列中垫底,由 `task.Service.Next` 的 `ORDER BY` 实现:
|
||||
|
||||
```sql
|
||||
-- 共享候选池
|
||||
CASE WHEN device_id IS NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN source = 'image_search' THEN 1 ELSE 0 END,
|
||||
created_at ASC, id ASC
|
||||
```
|
||||
|
||||
- `[必须]` 该排序只能落在服务端。图搜任务是同一端点下发的普通采集任务,Agent 调用
|
||||
`next` 时服务端已经选好了行,客户端无从推翻。
|
||||
- `[必须]` 来源降级排在 `device_id` **之后**:指派设备是人的显式选择,必须继续优先于来源
|
||||
降级,否则未指派的采集任务会抢在运营明确路由到本机的图搜任务前面。
|
||||
- 采购任务不需要此处排序:它们在独立表、独立端点,Agent 已在检查采集之前处理。
|
||||
|
||||
### 错误码
|
||||
|
||||
管理端(批量创建):
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|---|---|
|
||||
| `IMAGE_SEARCH_BATCH_TOO_LARGE` | 去重后任务数超过单批上限 |
|
||||
| `IMAGE_SEARCH_ALREADY_LINKED` | 蝦皮商品已关联 PDD,逐条跳过 |
|
||||
| `IMAGE_SEARCH_TASK_ACTIVE` | 该蝦皮商品已有未结束的图搜任务 |
|
||||
| `IMAGE_SEARCH_IMAGE_INVALID` | 参考图下载失败、格式不支持或超过限制 |
|
||||
| `IMAGE_SEARCH_INPUT_CHANGED` | 关联、参考图、价格或币种在请求期间变化 |
|
||||
| `IMAGE_SEARCH_REQUEST_CONFLICT` | 同一 `requestId` 的参数已变化 |
|
||||
| `IMAGE_SEARCH_CONFIG_INVALID` | 价格倍数配置不是 1~100 的整数 |
|
||||
| `IMAGE_SEARCH_SNAPSHOT_INVALID` | 任务快照无效 |
|
||||
|
||||
Agent 上报(`POST /api/agent/v1/tasks/{taskId}/fail`):
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|---|---|
|
||||
| `IMAGE_SEARCH_PERMISSION_REQUIRED` | 未授予相册权限 |
|
||||
| `IMAGE_SEARCH_ASSET_INVALID` | 参考图下载、校验或写入相册失败 |
|
||||
| `IMAGE_SEARCH_ASSET_NOT_LATEST` | 相册中存在更新的图片或视频,无法确认会选中我们准备的图 |
|
||||
| `IMAGE_SEARCH_ENTRY_NOT_FOUND` | 找不到唯一可点的拍照搜索入口,或归位失败 |
|
||||
| `IMAGE_SEARCH_NO_CANDIDATES` | 结果页没有可用候选(含全部为广告) |
|
||||
| `IMAGE_SEARCH_AUTOMATION_UNAVAILABLE` | Agent 版本尚未接入图搜自动化 |
|
||||
|
||||
### Agent 执行边界
|
||||
|
||||
- 只使用无障碍树,**禁止 OCR / VLM**。
|
||||
- 相册权限:Android 13+ `READ_MEDIA_IMAGES`,10~12 `READ_EXTERNAL_STORAGE`
|
||||
(`maxSdkVersion=32`)。这是本功能新增的权限,既有设备升级后需重新授权。
|
||||
- 参考图写入设备相册后,Agent 先确认它是相册中**最新的一项(图片与视频一起比较)**,
|
||||
再点击网格第一格;不是最新一项时以 `IMAGE_SEARCH_ASSET_NOT_LATEST` 明确失败,
|
||||
不往后找、不猜测。任务结束(成功或失败)都删除本次写入的图片。
|
||||
- 归位状态机可从商品详情、规格弹层、订单确认页、结果页、重试弹窗回到图搜入口,上限
|
||||
50 次动作。**订单确认页只执行返回**;支付与下单文案仅作只读识别信号,任何情况下都不是
|
||||
点击目标。
|
||||
- 结果页默认打开第一个候选,跳过带「广告」标记的卡片。
|
||||
- 「重新采集」对图搜任务的语义是**重采同一个商品**,不重新搜索;搜错了由采购员在管理端
|
||||
手动改关联(该动作同时清除图搜标记)。
|
||||
|
||||
### PDD 界面判据(2026-09-15 真机核对,设备 1080×2354)
|
||||
|
||||
常量集中在 `PinduoduoImageSearchCriteria`。以下为核对结论,PDD 版本更新后需要重新 dump:
|
||||
|
||||
| 位置 | 判据 | 状态 |
|
||||
|---|---|---|
|
||||
| 首页入口 | `content-desc='拍照搜索'` 且 **clickable** 的唯一节点,实测 `[941,161][1080,228]` | 命中 |
|
||||
| 图搜页 | `我的相册` + `最近搜索` + `历史浏览` + `点击拍照` | 命中 |
|
||||
| 选图网格 | `最近项目` 锚点下方 4 列等宽网格,每格 267px,屏宽 1080,容差 ±24 | 命中 |
|
||||
| 结果页 | `搜图片同款` 且排序控件 ≥ 3(实测 综合/销量/价格/品牌) | 命中 |
|
||||
| 重试弹窗 | `请对准商品或码,保持手机稳定` + `取消` + `再试一次` | **未验证** |
|
||||
|
||||
`[必须]` 入口判定必须同时要求 clickable:个人中心 tab 的**根节点**也带
|
||||
`content-desc='拍照搜索'`,覆盖全屏且不可点,而 `visibleTexts()` 会把 `contentDescription`
|
||||
当文本收集。只按文案匹配会把个人中心判成首页,随后几何兜底会点到该页顶部最靠右的可点
|
||||
节点——实测那是「设置」按钮。
|
||||
|
||||
已知偏差:取景提示真机为「对准商品/条形码/二维码,自动识别」,历史常量
|
||||
`即可进行自动识别` 不命中,靠 `点击拍照` 兜住。相册网格同时包含视频(实测见到带时长的
|
||||
格子),这是上面「最新一项须跨图片与视频比较」的由来。
|
||||
|
||||
## 采集颜色图片上传(#133)
|
||||
|
||||
结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用:
|
||||
@@ -985,3 +1182,166 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## Agent 采购订单批量回填(#241)
|
||||
|
||||
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
|
||||
|
||||
`POST /api/agent/v1/purchase-tasks/order-backfill`
|
||||
|
||||
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
|
||||
|
||||
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
|
||||
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
|
||||
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
|
||||
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
|
||||
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
|
||||
|
||||
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
|
||||
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
|
||||
|
||||
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
|
||||
|
||||
| 条目 code | result | 含义 |
|
||||
|---|---|---|
|
||||
| `BACKFILLED` | backfilled | 本条完成回填 |
|
||||
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
|
||||
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
|
||||
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
|
||||
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
|
||||
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
|
||||
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
|
||||
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
|
||||
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
|
||||
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
|
||||
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
|
||||
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
|
||||
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
|
||||
|
||||
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
|
||||
|
||||
### 状态、幂等与并发
|
||||
|
||||
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
|
||||
|
||||
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
|
||||
|
||||
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
|
||||
|
||||
新回填路径禁用包含绑定参数的 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` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
|
||||
|
||||
## 人工订单回填的可选实付金额(#306)
|
||||
|
||||
实现绑定:8c01329(2026-09-18,feat/306-order-paid-amount,待验收,尚未合并/部署/执行业务库迁移)。本节补充并覆盖 #241 中“相同订单号一律无写入”的金额相关描述;订单号、时间、设备归属和状态校验保持原契约。
|
||||
|
||||
POST /api/agent/v1/purchase-tasks/order-backfill 的 items 可增加 pddOrderAmountCent:可空/可省略的 int64,单位人民币分,表示订单详情明确“实付”标签的整单金额。缺失或 null 保留原值;负整数局部忽略,不影响订单号和时间回填。字符串、非整数或 int64 溢出属于 JSON 契约错误,仍按既有解码器返回批级 422;Agent 对识别异常只省略字段,不能发送异常值。
|
||||
|
||||
- unknown 首次回填:可同时填充空金额;不会改变支付、物流或 SYB 回写字段。
|
||||
- order_created 且订单号相同:原金额为空时可补写,statusVersion 增加一次;订单号、下单时间不变。result/code 仍为 already_backfilled/ALREADY_BACKFILLED。
|
||||
- 同值或本次缺失金额:幂等,不增加版本;已有不同金额:保留原金额和正确订单事实,成功结果附 warningCode=PURCHASE_BACKFILL_AMOUNT_CONFLICT、warningMessage。不触发重新采购。
|
||||
- 条目响应可增加 pddOrderAmountCent(已提交数据库值)及上述 warning 字段。跨设备/不存在任务仍不返回业务字段。Android 缓存仅采纳服务端确认值,成功 warning 单独保存,不写为任务 errorCode。
|
||||
- Admin 采购任务查询/详情提供同名可选字段,详情按 CNY 展示,空值显示“尚未获取”。旧 Agent 省略字段仍可使用新 Server。
|
||||
|
||||
Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实付:,金额元,(免运费)及中英文冒号/逗号/空格,金额整数或最多两位小数,以十进制定点转换。不使用裸人民币数字、不跨节点拼金额;缺失、无效、溢出或多个不同金额时只省略金额。同一订单跨视口去重,切换订单清空。并不据此判断已付款。
|
||||
|
||||
追加迁移:1789800100000_purchase_order_amount.go,仅扩展 purchase_task 的可空非负 pdd_order_amount_cent;旧行 NULL,不推算单价×数量。先授权迁移并发布新 Server,再升级 Agent;旧 Server 严格拒绝未知字段,不能先装新 Agent 后向旧 Server 发送金额。本单不扩展普通采购 readOrderResult,也不增加 SYB cost 数据流。
|
||||
|
||||
@@ -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/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
|
||||
@@ -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: a8da51a554b9c43da4b72104d707d3c72c5cd534
|
||||
synchronized_at: 2026-09-17T08:57:51Z
|
||||
<!-- 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,23 @@ 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不需升级。
|
||||
|
||||
## 本地 Supervisor 与局域网访问
|
||||
|
||||
本机开发/联调环境由 `D:\supervisor\programs\goauto.conf` 分别启动 GoAuto API 与 Admin Web;实例配置只负责调用项目脚本,端口读取 `D:\OPC\goauto\config.yaml`。
|
||||
|
||||
- API 由 `scripts/start-server.ps1` 启动并监听所有网卡的 `ports.server`。
|
||||
- Admin Web 由 `scripts/start-web.ps1` 启动,监听 `0.0.0.0` 的 `ports.web`,因此同一局域网设备可通过 `http://<本机局域网IP>:<web端口>/` 访问。
|
||||
- 浏览器使用同源相对 API;Vite 将 `/api` 与 `/static` 代理到本机 `127.0.0.1:<server端口>`,避免局域网客户端错误访问自身的 `127.0.0.1`。
|
||||
- 修改脚本或配置后,只需重启 Supervisor 的 `goauto-admin-ui`;API 未变化时无需重启 `goauto-admin-api`。若局域网仍无法连接,检查 Windows 防火墙是否允许 `ports.web` 的 TCP 入站。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# PDD 首页控件摘要(设备 192.168.0.9:35727)
|
||||
|
||||
- 采集时间:2026-09-15
|
||||
- 包名:`com.xunmeng.pinduoduo`
|
||||
- 发现语义入口:`content-desc=拍照搜索`
|
||||
- 发现搜索入口:`content-desc=搜索`
|
||||
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
|
||||
- 未保存坐标、完整控件树、截图、账号或个人数据。
|
||||
@@ -0,0 +1,38 @@
|
||||
param(
|
||||
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
|
||||
[int]$BatchSize = 100,
|
||||
[int]$PageSize = 500,
|
||||
[int]$SleepSeconds = 1,
|
||||
[switch]$Force,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
|
||||
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
|
||||
}
|
||||
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
|
||||
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
|
||||
$ids = [System.Collections.Generic.List[UInt64]]::new()
|
||||
for ($page = 1; ; $page++) {
|
||||
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
|
||||
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
|
||||
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
|
||||
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
|
||||
if ($items.Count -lt $PageSize) { break }
|
||||
}
|
||||
Write-Host "发现失败记录 $($ids.Count) 条"
|
||||
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
|
||||
$ok = 0; $failed = 0
|
||||
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
|
||||
$end = [Math]::Min($i + $BatchSize, $ids.Count)
|
||||
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
|
||||
$rows = @($result.data.results)
|
||||
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
|
||||
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
|
||||
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
|
||||
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
|
||||
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
|
||||
}
|
||||
@@ -97,6 +97,31 @@ function Read-DatabaseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function Read-ErpGoConfig {
|
||||
param([string]$Path)
|
||||
|
||||
# Optional section. Absent or incomplete means the Shopee spec sync stays
|
||||
# off; the server treats that as "skip", not as an error (#290).
|
||||
$values = @{}
|
||||
$insideSection = $false
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
if ($line -match '^\s*(#.*)?$') {
|
||||
continue
|
||||
}
|
||||
if ($line -match '^erpgo\s*:\s*$') {
|
||||
$insideSection = $true
|
||||
continue
|
||||
}
|
||||
if ($insideSection -and $line -match '^\S') {
|
||||
break
|
||||
}
|
||||
if ($insideSection -and $line -match '^\s+(baseUrl|apikey)\s*:\s*(.*?)\s*$') {
|
||||
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
||||
}
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Read-PortConfig {
|
||||
param([string]$Path)
|
||||
|
||||
@@ -169,6 +194,7 @@ try {
|
||||
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
||||
$databaseConfig = Read-DatabaseConfig $ConfigPath
|
||||
$portConfig = Read-PortConfig $ConfigPath
|
||||
$erpgoConfig = Read-ErpGoConfig $ConfigPath
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
|
||||
$DatabaseHost = $databaseConfig.Host
|
||||
@@ -219,6 +245,14 @@ try {
|
||||
$env:GOAUTO_DB_DRIVER = "mysql"
|
||||
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||
$env:GOAUTO_SERVER_PORT = [string]$portConfig.Server
|
||||
# Shopee spec service. The key never reaches the repo: config.yaml is
|
||||
# gitignored and the server only ever reads the environment (#290).
|
||||
if ($erpgoConfig.ContainsKey('baseUrl') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.baseUrl)) {
|
||||
$env:GOAUTO_ERPGO_BASE_URL = [string]$erpgoConfig.baseUrl
|
||||
}
|
||||
if ($erpgoConfig.ContainsKey('apikey') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.apikey)) {
|
||||
$env:GOAUTO_ERPGO_APIKEY = [string]$erpgoConfig.apikey
|
||||
}
|
||||
|
||||
Push-Location $serverDirectory
|
||||
try {
|
||||
@@ -272,5 +306,7 @@ finally {
|
||||
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_APIKEY -ErrorAction SilentlyContinue
|
||||
$plainPassword = $null
|
||||
}
|
||||
|
||||
@@ -168,18 +168,22 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
$env:VUE_APP_BASE_API = "http://127.0.0.1:$($ports.Server)"
|
||||
Write-Host "Starting GoAuto web UI at http://127.0.0.1:$($ports.Web)" -ForegroundColor Green
|
||||
# Keep browser requests same-origin. Vite proxies API/static requests to the
|
||||
# local backend, so a LAN client never tries to call its own 127.0.0.1.
|
||||
$env:VUE_APP_BASE_API = ""
|
||||
$env:VITE_DEV_PROXY_TARGET = "http://127.0.0.1:$($ports.Server)"
|
||||
Write-Host "Starting GoAuto web UI at http://0.0.0.0:$($ports.Web)" -ForegroundColor Green
|
||||
$viteCli = Join-Path $webDirectory "node_modules\vite\bin\vite.js"
|
||||
if (-not (Test-Path -LiteralPath $viteCli -PathType Leaf)) {
|
||||
throw "Vite was not found after dependency installation: $viteCli"
|
||||
}
|
||||
& $nodePath $viteCli --host 127.0.0.1 --port $ports.Web --strictPort
|
||||
& $nodePath $viteCli --host 0.0.0.0 --port $ports.Web --strictPort
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Frontend exited with code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:VUE_APP_BASE_API -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_DEV_PROXY_TARGET -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ package access
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBProductFilters = "syb_product_filters"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
Sort: 51,
|
||||
ModuleKeys: []string{
|
||||
ModuleSYBShops,
|
||||
ModuleSYBProductFilters,
|
||||
ModuleCollectionRules,
|
||||
ModulePurchaseRules,
|
||||
ModuleDevices,
|
||||
@@ -91,13 +93,14 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
|
||||
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
|
||||
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
|
||||
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -144,6 +147,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleSYBProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
|
||||
return ModuleSYBShops
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
|
||||
return ModuleSYBProductFilters
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
|
||||
return ModuleSYBInnerCodes
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 12 {
|
||||
t.Fatalf("got %d modules, want 12", len(modules))
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for groupIndex, group := range groups {
|
||||
|
||||
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
|
||||
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
|
||||
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
|
||||
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
|
||||
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
|
||||
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
|
||||
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
|
||||
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
|
||||
|
||||
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
|
||||
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
|
||||
@@ -94,8 +98,11 @@ 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/cancel", "POST", true},
|
||||
{"批量取消采集任务", "/api/admin/v1/collection-tasks/batch-cancel", "POST", true},
|
||||
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
|
||||
|
||||
{"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true},
|
||||
|
||||
@@ -15,12 +15,14 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/syb-product-filters": true,
|
||||
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
}
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if denied[permission.Method+" "+permission.Path] {
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
traditionalToSimplified *gocc.OpenCC
|
||||
traditionalErr error
|
||||
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
|
||||
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
|
||||
)
|
||||
|
||||
// Normalize makes a deterministic comparison key. It intentionally removes
|
||||
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
|
||||
// deliberately treated as no match instead of picking the first label.
|
||||
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
|
||||
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
|
||||
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
|
||||
if !colorOK || !sizeOK {
|
||||
return MatchResult{}, false
|
||||
}
|
||||
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
return result, true
|
||||
}
|
||||
|
||||
func exactSizeCandidate(target string, candidates []string) (string, bool) {
|
||||
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
|
||||
if key == "" {
|
||||
return exactCandidate(target, candidates)
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ManualMatch records an already-confirmed mapping without reinterpreting it.
|
||||
// The caller owns the human-confirmation check; this helper only creates the
|
||||
// same bounded task snapshot shape used by automatic decisions.
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
if len(s.Modules) != 13 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
|
||||
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
|
||||
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
|
||||
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
|
||||
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
|
||||
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -41,6 +42,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.SYBInnerCodeRecord{},
|
||||
&models.SYBInnerCodeItem{},
|
||||
@@ -85,7 +87,55 @@ func Migrate(db *gorm.DB) error {
|
||||
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
//
|
||||
// `[必须]` 结构过滤自 #286 起只有**一条**,keyword 存的是必需字符集合
|
||||
// `-#`,意思是“同时包含 - 和 #”。这里不能再播种旧的两条:本函数
|
||||
// 每次启动都会 FirstOrCreate,播了就会把迁移 1789500000000 合并掉的
|
||||
// `-` 和 `#` 重新建回来,过滤静默退回或关系。
|
||||
seed := []struct{ kind, keyword string }{{"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
|
||||
}
|
||||
}
|
||||
if err := ensureMySQLDirectSelectConstraint(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return EnsureCollectionTaskCancelledConstraint(db)
|
||||
}
|
||||
|
||||
// ensureMySQLCollectionTaskCancelledConstraint 放开 collection_task.status 的
|
||||
// CHECK 约束以接受 'cancelled'(#297)。GORM 的 AutoMigrate 在 MySQL 上不会
|
||||
// 改写已存在的 CHECK 约束(同 ensureMySQLDirectSelectConstraint 的已知限制),
|
||||
// 所以新增取消状态必须像 direct_select 那次一样手动 DROP/ADD,否则老库的约束
|
||||
// 仍然只认旧的五个状态,取消写入会被数据库直接拒绝。
|
||||
// EnsureCollectionTaskCancelledConstraint is exported so the version-local
|
||||
// migration can call it: that file is the only path that reaches an existing
|
||||
// database (see 1789700000000_collection_task_cancelled.go).
|
||||
func EnsureCollectionTaskCancelledConstraint(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
const name = "ck_collection_task_status"
|
||||
var constraints []struct {
|
||||
CheckClause string `gorm:"column:check_clause"`
|
||||
}
|
||||
if err := db.Raw(mysqlCheckConstraintQuery, "collection_task", name).Scan(&constraints).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(constraints) > 0 && strings.Contains(strings.ToLower(constraints[0].CheckClause), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
if len(constraints) > 0 {
|
||||
if err := db.Exec("ALTER TABLE collection_task DROP CHECK " + name).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec("ALTER TABLE collection_task ADD CONSTRAINT " + name +
|
||||
" CHECK (status IN ('pending','running','completed','completed_partial','failed','cancelled'))").Error
|
||||
}
|
||||
|
||||
func ensureMySQLDirectSelectConstraint(db *gorm.DB) error {
|
||||
|
||||
@@ -138,12 +138,17 @@ type PurchaseTask struct {
|
||||
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
|
||||
|
||||
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
|
||||
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
|
||||
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty" gorm:"check:ck_purchase_task_order_amount,pdd_order_amount_cent IS NULL OR pdd_order_amount_cent >= 0"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt"`
|
||||
// PaymentPageObservedAt 仅在 order_result_unknown 时可能有值:采样期间见过
|
||||
// 待付款/支付相关文案,说明大概率已经在 PDD 建了订单,只是没能读全证据。
|
||||
// 用于给积压的 order_result_unknown 记录分优先级,不改变判定结果本身(#302)。
|
||||
PaymentPageObservedAt *time.Time `json:"paymentPageObservedAt"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
|
||||
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
|
||||
|
||||
TrackingNo *string `json:"trackingNo" gorm:"size:120"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt"`
|
||||
@@ -202,7 +207,15 @@ func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
|
||||
return task.syncPurchaseGuardSlots()
|
||||
}
|
||||
|
||||
func (task *PurchaseTask) BeforeSave(_ *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) SetStatus(status string) error {
|
||||
task.Status = status
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
@@ -17,9 +17,13 @@ const (
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusCompletedPartial = "completed_partial"
|
||||
TaskStatusFailed = "failed"
|
||||
// TaskStatusCancelled 与 PurchaseTaskStatusCancelled 的既有约定对齐(#297):
|
||||
// 只有 pending 任务允许取消,取消后是终态,不再被任何设备领取。
|
||||
TaskStatusCancelled = "cancelled"
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
CollectionTaskSourceImageSearch = "image_search"
|
||||
)
|
||||
|
||||
// AgentDevice identifies one Agent installation. InstallID is generated by the
|
||||
@@ -181,13 +185,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')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
|
||||
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','cancelled')"`
|
||||
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"`
|
||||
@@ -275,7 +280,7 @@ func (task *CollectionTask) syncGuardSlots() error {
|
||||
}
|
||||
task.ActiveSlot = &one
|
||||
task.DeviceRunSlot = &one
|
||||
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed:
|
||||
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed, TaskStatusCancelled:
|
||||
task.ActiveSlot = nil
|
||||
task.DeviceRunSlot = nil
|
||||
default:
|
||||
@@ -416,7 +421,17 @@ 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"`
|
||||
// SpecSyncPDDProductID records which PDD product the full Shopee spec list
|
||||
// was synced and matched against (#290).
|
||||
//
|
||||
// `[必须]` 存 PDD 商品 id 而不是布尔值:映射是对着某个 PDD 商品的规格值建的,
|
||||
// 一旦重新关联到别的 PDD 商品,旧映射全部失效。跟踪具体是哪个商品,则跳过
|
||||
// 判据变成 SpecSyncPDDProductID == PDDProductID,重新关联时标记自动失效,
|
||||
// 不依赖“记得去清”。
|
||||
SpecSyncPDDProductID *uint64 `json:"specSyncPddProductId,omitempty" gorm:"index"`
|
||||
SpecSyncAt *time.Time `json:"specSyncAt,omitempty"`
|
||||
// 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 +470,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 +615,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" }
|
||||
|
||||
@@ -54,6 +54,7 @@ type AdminTaskItem struct {
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
@@ -76,6 +77,7 @@ type AdminTaskItem struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAttemptItem struct {
|
||||
@@ -163,15 +165,48 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
items := make([]AdminTaskItem, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
item.Matching = matching[task.ID]
|
||||
if task.ShopeeProductID != nil {
|
||||
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
|
||||
}
|
||||
|
||||
// loadImageSearchLinked bulk-loads the read-only "linked via image search, not
|
||||
// manually verified" flag for purchase-task list/detail views (#279 item 5).
|
||||
// It is display-only and must never influence eligibility or matching.
|
||||
func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) {
|
||||
result := make(map[uint64]bool)
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, task := range tasks {
|
||||
if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] {
|
||||
ids = append(ids, *task.ShopeeProductID)
|
||||
seen[*task.ShopeeProductID] = true
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var shopeeProducts []models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, shopee := range shopeeProducts {
|
||||
result[shopee.ID] = shopee.ImageSearchLinked
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) {
|
||||
var task models.PurchaseTask
|
||||
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
@@ -203,6 +238,13 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
item.Matching = matching
|
||||
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
if task.ShopeeProductID != nil {
|
||||
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
||||
}
|
||||
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
|
||||
}
|
||||
|
||||
@@ -241,7 +283,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr
|
||||
SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency,
|
||||
PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix,
|
||||
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, IrreversibleAt: task.IrreversibleAt,
|
||||
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, PDDOrderAmountCent: task.PDDOrderAmountCent, IrreversibleAt: task.IrreversibleAt,
|
||||
PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt,
|
||||
TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt,
|
||||
LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ArchiveSpecMatchResult counts what one archive-wide matching pass did.
|
||||
//
|
||||
// NoMatch 与 Unavailable 分开计数:前者是 AI 明确说没有可靠对应(PDD 确实没有该
|
||||
// 颜色,或候选有歧义),后者是服务不可用。两者的处置完全不同,此前都记成 failed,
|
||||
// 排查时分不开(#295)。
|
||||
type ArchiveSpecMatchResult struct {
|
||||
Confirmed int
|
||||
Skipped int
|
||||
NoMatch int
|
||||
Unavailable int
|
||||
// Aborted 是本端 context 取消或超时导致的中断,与 Provider 故障无关(#300)。
|
||||
Aborted int
|
||||
}
|
||||
|
||||
// Failed is every value that ended without a mapping, whatever the reason.
|
||||
func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable + r.Aborted }
|
||||
|
||||
// MatchArchiveSpecs matches every still-unmapped value in a Shopee product's
|
||||
// archive against its linked PDD product (#293).
|
||||
//
|
||||
// 存在的理由:BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮
|
||||
// 的完整颜色尺码同步进档案后,那些还没有订单的值一个都不会被尝试——等订单真来了
|
||||
// 仍然要人工点一次匹配,正是 #290 想消除的动作。
|
||||
//
|
||||
// `[必须]` 这里只写规格映射,不创建任何采购。匹配只把明细推到“采购就绪”,创建
|
||||
// 采购仍由人点击——那是整条链上唯一的人工检查点。
|
||||
func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) (ArchiveSpecMatchResult, error) {
|
||||
var result ArchiveSpecMatchResult
|
||||
var shopee models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).First(&shopee, shopeeProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return result, nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return result, nil
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, "", "")
|
||||
if !usable {
|
||||
return result, nil
|
||||
}
|
||||
combinations, err := s.archiveSKUCombinations(ctx, pdd.ID)
|
||||
if err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil && value.Mapping.Status == shopeeproduct.MappingStatusConfirmed {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) {
|
||||
case specMatchAborted:
|
||||
// ctx 已死,后面的值一个也做不成,早停以免刷出一串同样的失败。
|
||||
result.Aborted++
|
||||
return result, nil
|
||||
case specMatchUnavailable:
|
||||
result.Unavailable++
|
||||
case specMatchNoMatch:
|
||||
result.NoMatch++
|
||||
default:
|
||||
result.Confirmed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// matchOneArchiveValue resolves a single archive value and persists it when the
|
||||
// answer survives every check. An empty return means a mapping was written.
|
||||
func (s *Service) matchOneArchiveValue(
|
||||
ctx context.Context,
|
||||
shopeeProductID uint64,
|
||||
dimension shopeeproduct.SpecDimension,
|
||||
valueName string,
|
||||
candidates specCandidates,
|
||||
combinations []pddSKUCombination,
|
||||
) specMatchFailure {
|
||||
isColor := dimension.Role == shopeeproduct.RoleColor
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if isColor {
|
||||
request.TargetColor = valueName
|
||||
} else {
|
||||
request.TargetSize = valueName
|
||||
}
|
||||
matched, ok := aimatching.DeterministicMatch(request)
|
||||
if !ok {
|
||||
resolved, failure, err := s.resolveSpecMatch(ctx, request)
|
||||
if err != nil {
|
||||
return failure
|
||||
}
|
||||
// #200 的口径:AI 只要给出带理由的结果就可保存,是否放行交给下面的候选与
|
||||
// 可售组合校验;置信度只作审计信息。
|
||||
if resolved.Source != aimatching.SourceAI || strings.TrimSpace(resolved.Decision.Reason) == "" {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
matched = resolved
|
||||
}
|
||||
|
||||
pddValue := matched.MappedSize
|
||||
allowed := candidates.Sizes
|
||||
if isColor {
|
||||
pddValue, allowed = matched.MappedColor, candidates.Colors
|
||||
}
|
||||
pddValue = strings.TrimSpace(pddValue)
|
||||
if pddValue == "" || !containsValue(allowed, pddValue) {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合。至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里,否则会把一个“看起来合理但根本买不到”的值写成确认映射。
|
||||
// 完整组合仍由采购预检把关。
|
||||
if !archiveValueIsSellable(combinations, isColor, pddValue) {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
|
||||
item := shopeeproduct.ResolvedMappingItem{
|
||||
Dimension: dimension.Name, ValueName: valueName, PDDValue: pddValue,
|
||||
Source: matched.Source, Status: shopeeproduct.MappingStatusConfirmed,
|
||||
Confidence: matched.Decision.Confidence, Reason: strings.TrimSpace(matched.Decision.Reason),
|
||||
}
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item}); err != nil {
|
||||
return specMatchUnavailable
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// archiveValueIsSellable reports whether the PDD value appears in at least one
|
||||
// available SKU combination. No combinations means no evidence either way, so
|
||||
// the value passes — hand-entered or imported PDD archives have no SKU rows.
|
||||
func archiveValueIsSellable(combinations []pddSKUCombination, isColor bool, pddValue string) bool {
|
||||
if len(combinations) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, combination := range combinations {
|
||||
if isColor && combination.Color == pddValue {
|
||||
return true
|
||||
}
|
||||
if !isColor && combination.Size == pddValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) archiveSKUCombinations(ctx context.Context, pddProductID uint64) ([]pddSKUCombination, error) {
|
||||
dataset := batchPreviewDataset{skuCombinationsByPDD: make(map[uint64][]pddSKUCombination)}
|
||||
if err := s.loadLatestSKUCombinations(ctx, []uint64{pddProductID}, &dataset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataset.skuCombinationsByPDD[pddProductID], nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package purchase
|
||||
|
||||
import "testing"
|
||||
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合,所以至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里。否则会把“看起来合理但根本买不到”的值写成确认映射(#293)。
|
||||
func TestArchiveValueMustAppearInASellableSKU(t *testing.T) {
|
||||
combinations := []pddSKUCombination{
|
||||
{Color: "紫色-冰块猫", Size: "M 建议86-95斤"},
|
||||
{Color: "黑色-冰块猫", Size: "L 建议96-105斤"},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
isColor bool
|
||||
pddValue string
|
||||
want bool
|
||||
}{
|
||||
{"在售颜色", true, "黑色-冰块猫", true},
|
||||
{"不在售颜色", true, "奶黄-百花", false},
|
||||
{"在售尺码", false, "L 建议96-105斤", true},
|
||||
{"不在售尺码", false, "4XL 建议136-150斤", false},
|
||||
{"颜色值不能拿去顶尺码", false, "黑色-冰块猫", false},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
if got := archiveValueIsSellable(combinations, item.isColor, item.pddValue); got != item.want {
|
||||
t.Fatalf("got %v, want %v", got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 人工录入或外部导入的 PDD 档案没有采集 SKU 证据。没有证据时不能一律拒绝,
|
||||
// 否则这些商品永远匹配不上——与 aiMatchQualificationForDataset 的既有口径一致。
|
||||
func TestArchiveValuePassesWhenThereIsNoSKUEvidence(t *testing.T) {
|
||||
if !archiveValueIsSellable(nil, true, "任意颜色") {
|
||||
t.Fatal("没有 SKU 证据时不应拒绝")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type BatchPreviewItem struct {
|
||||
PDDProductID *uint64 `json:"pddProductId,omitempty"`
|
||||
PDDGoodsID string `json:"pddGoodsId,omitempty"`
|
||||
PDDTitle string `json:"pddTitle,omitempty"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
TargetColor string `json:"targetColor,omitempty"`
|
||||
TargetSize string `json:"targetSize,omitempty"`
|
||||
MappedColor string `json:"mappedColor,omitempty"`
|
||||
@@ -136,6 +137,11 @@ type batchPreviewDataset struct {
|
||||
activeCollectionByPDD map[uint64]uint64
|
||||
collectionRuleAvailable bool
|
||||
skuCombinationsByPDD map[uint64][]pddSKUCombination
|
||||
// collapsedKeysByShopee 记录该虾皮商品下哪些 target_color / target_size
|
||||
// 是不安全的键——多个不同的虾皮原始规格剥离 【...】 后塌缩到了同一个值。
|
||||
// 见 #289 与 sybimport.RawSpecHalves。
|
||||
collapsedColorByShopee map[uint64]map[string]bool
|
||||
collapsedSizeByShopee map[uint64]map[string]bool
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||
@@ -144,13 +150,15 @@ type batchPreviewDataset struct {
|
||||
// Creation still revalidates each item.
|
||||
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
|
||||
dataset := batchPreviewDataset{
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
activeCollectionByPDD: make(map[uint64]uint64),
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
activeCollectionByPDD: make(map[uint64]uint64),
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
collapsedColorByShopee: make(map[uint64]map[string]bool),
|
||||
collapsedSizeByShopee: make(map[uint64]map[string]bool),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
@@ -164,6 +172,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
}
|
||||
}
|
||||
if len(shopeeIDs) > 0 {
|
||||
if err := s.loadCollapsedSpecKeys(ctx, shopeeIDs, &dataset); err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
var shopeeProducts []models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
|
||||
return dataset, err
|
||||
@@ -388,6 +399,7 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
||||
return item
|
||||
}
|
||||
item.ImageSearchLinked = shopee.ImageSearchLinked
|
||||
if !aiConfirmedSpecsCurrent(syb, shopee) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
|
||||
return item
|
||||
@@ -422,6 +434,22 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
// `[必须]` 塌缩的键不是这条明细独有的:同一个虾皮商品下,多个不同的虾皮原始
|
||||
// 规格剥离 【...】 后落到了同一个 target_color / target_size,于是共用同一份
|
||||
// 映射,Agent 会为不同规格点击同一个 PDD 值。此时必须明确失败,不能让它显示
|
||||
// 为采购就绪——静默买错比不能采购严重得多(#289)。
|
||||
if reason := collapsedSpecReason(dataset, syb); reason != "" {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeSpecKeyAmbiguous, reason, "open_mapping"
|
||||
return item
|
||||
}
|
||||
// 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"
|
||||
|
||||
@@ -73,7 +73,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
matched = *qualification.Deterministic
|
||||
} else {
|
||||
var matchErr error
|
||||
matched, matchErr = s.matcher().Resolve(ctx, request)
|
||||
// 一次抽风不该让这条明细永久留空,重试一次(#295)。
|
||||
matched, _, matchErr = s.resolveSpecMatch(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.Reason = batchSpecMatchErrorMessage(matchErr)
|
||||
response.FailedCount++
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,11 +310,14 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
if matcher.calls != 0 {
|
||||
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
||||
}
|
||||
if queries != 7 {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
|
||||
// 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字;
|
||||
// 只有新增的查询确实有界时才允许上调。
|
||||
if queries != 8 {
|
||||
t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", 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,11 +332,96 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// #279 item 5: purchase preview must surface (read-only) that a shopee<->PDD
|
||||
// link was established by image search and not yet human-verified, without
|
||||
// changing eligibility, reason codes or next actions in any way.
|
||||
func TestBatchPreviewMarksImageSearchLinkedRowsWithoutAffectingEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if !item.ImageSearchLinked {
|
||||
t.Fatalf("expected imageSearchLinked=true for a link created by image search: %+v", item)
|
||||
}
|
||||
if !item.Eligible || item.ReasonCode != "" || item.Reason != "" || item.NextAction != "" {
|
||||
t.Fatalf("imageSearchLinked marker must be display-only and must not change eligibility: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewDoesNotMarkManuallyLinkedRows(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
// image_search_linked defaults to false for manually established links.
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
if response.Items[0].ImageSearchLinked {
|
||||
t.Fatalf("manual link must not be marked imageSearchLinked: %+v", response.Items[0])
|
||||
}
|
||||
if !response.Items[0].Eligible {
|
||||
t.Fatalf("manual link must remain eligible: %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the imageSearchLinked flag must not change any existing
|
||||
// eligibility outcome, including the well-covered ineligible paths.
|
||||
func TestBatchPreviewImageSearchLinkedDoesNotChangeIneligibleOutcome(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
failed := fixture.syb
|
||||
failed.ID = 0
|
||||
failed.OrderCode = "SYB-FAILED-IMG"
|
||||
failed.DetailID++
|
||||
failed.ParseStatus = models.SYBParseStatusFailed
|
||||
failed.TargetColor, failed.TargetSize = "", ""
|
||||
if err := db.Create(&failed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{failed.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.Eligible || item.ReasonCode != "SYB_PARSE_FAILED" || item.NextAction != "reparse" {
|
||||
t.Fatalf("image search linkage must not alter the existing ineligible outcome: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybspec"
|
||||
)
|
||||
|
||||
// CodeSpecKeyAmbiguous marks a detail whose target colour or size is shared by
|
||||
// several different Shopee specs, so the stored mapping cannot say which one
|
||||
// the agent should click.
|
||||
const CodeSpecKeyAmbiguous = "SPEC_KEY_AMBIGUOUS"
|
||||
|
||||
// loadCollapsedSpecKeys finds, per Shopee product, the target colours and sizes
|
||||
// that more than one raw Shopee spec collapses onto (#289).
|
||||
//
|
||||
// `[必须]` 必须按虾皮商品加载**全部**明细,而不是本次请求的那几条:塌缩是商品级
|
||||
// 的属性,只看请求内的明细会漏判——另一条同键不同原文的明细可能不在本次选择里。
|
||||
func (s *Service) loadCollapsedSpecKeys(ctx context.Context, shopeeIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
var rows []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).
|
||||
Select("id", "shopee_product_id", "target_color", "target_size", "raw_json").
|
||||
Where("shopee_product_id IN ?", shopeeIDs).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
colorRaws := map[uint64]map[string][]string{}
|
||||
sizeRaws := map[uint64]map[string][]string{}
|
||||
for _, row := range rows {
|
||||
if row.ShopeeProductID == nil {
|
||||
continue
|
||||
}
|
||||
rawColor, rawSize := sybspec.RawSpecHalves(rawProductSpec(row))
|
||||
collect(colorRaws, *row.ShopeeProductID, row.TargetColor, rawColor)
|
||||
collect(sizeRaws, *row.ShopeeProductID, row.TargetSize, rawSize)
|
||||
}
|
||||
dataset.collapsedColorByShopee = collapsedKeys(colorRaws)
|
||||
dataset.collapsedSizeByShopee = collapsedKeys(sizeRaws)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawProductSpec 取出存储的 SYB 原始 productSpec。解不开时返回空串,
|
||||
// 由调用方当作“无原文可比”处理,而不是报错阻断整个预览。
|
||||
func rawProductSpec(row models.SYBProduct) string {
|
||||
var payload struct {
|
||||
ProductSpec string `json:"productSpec"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return payload.ProductSpec
|
||||
}
|
||||
|
||||
func collect(into map[uint64]map[string][]string, shopeeID uint64, key, raw string) {
|
||||
if key == "" || raw == "" {
|
||||
return
|
||||
}
|
||||
if into[shopeeID] == nil {
|
||||
into[shopeeID] = map[string][]string{}
|
||||
}
|
||||
into[shopeeID][key] = append(into[shopeeID][key], raw)
|
||||
}
|
||||
|
||||
func collapsedKeys(raws map[uint64]map[string][]string) map[uint64]map[string]bool {
|
||||
result := make(map[uint64]map[string]bool, len(raws))
|
||||
for shopeeID, byKey := range raws {
|
||||
for key, halves := range byKey {
|
||||
if !sybspec.CollapsedSpecKey(halves) {
|
||||
continue
|
||||
}
|
||||
if result[shopeeID] == nil {
|
||||
result[shopeeID] = map[string]bool{}
|
||||
}
|
||||
result[shopeeID][key] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// collapsedSpecReason returns a human-readable reason when this detail's colour
|
||||
// or size key is shared by several different Shopee specs, or "" when it is safe.
|
||||
func collapsedSpecReason(dataset batchPreviewDataset, syb models.SYBProduct) string {
|
||||
if syb.ShopeeProductID == nil {
|
||||
return ""
|
||||
}
|
||||
if dataset.collapsedColorByShopee[*syb.ShopeeProductID][syb.TargetColor] {
|
||||
return "同一蝦皮商品下有多个不同规格的颜色被识别成「" + syb.TargetColor + "」,无法确定该采购哪一个,请人工确认规格"
|
||||
}
|
||||
if dataset.collapsedSizeByShopee[*syb.ShopeeProductID][syb.TargetSize] {
|
||||
return "同一蝦皮商品下有多个不同规格的尺码被识别成「" + syb.TargetSize + "」,无法确定该采购哪一个,请人工确认规格"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// `[必须]` 塌缩的键不得显示为采购就绪。剥离 【...】 后两个不同的虾皮规格落到同一个
|
||||
// target_color,就会共用同一份映射,Agent 会为不同规格点击同一个 PDD 值。线上真实
|
||||
// 样本:虾皮 1355 的 6 个白色(白色【207A】/【209A】/…)全部塌缩成「白色」(#289)。
|
||||
func TestBatchPreviewRefusesCollapsedSpecKey(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
// 第一条明细的原文带款号;既有 fixture 的 RawJSON 是 {},先补成真实形态。
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同一虾皮商品的第二条明细:不同款号,剥离后同样是「黑色」。
|
||||
sibling := f.syb
|
||||
sibling.ID = 0
|
||||
sibling.OrderCode = "SYB-2"
|
||||
sibling.DetailID = 2
|
||||
sibling.RawJSON = `{"productSpec":"黑色【208A】,XL"}`
|
||||
if err := db.Create(&sibling).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := NewService(db).BatchPreview(context.Background(),
|
||||
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("expected one preview item, got %+v", response.Items)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.ReasonCode != CodeSpecKeyAmbiguous {
|
||||
t.Fatalf("collapsed key must be refused, got code=%q reason=%q eligible=%v",
|
||||
item.ReasonCode, item.Reason, item.Eligible)
|
||||
}
|
||||
if item.Eligible {
|
||||
t.Fatal("a detail whose spec key is ambiguous must not stay eligible")
|
||||
}
|
||||
}
|
||||
|
||||
// 同一条规格重复出现(同商品多条订单买同一个规格)不算塌缩,行为必须不变。
|
||||
func TestBatchPreviewAllowsRepeatedIdenticalSpec(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling := f.syb
|
||||
sibling.ID = 0
|
||||
sibling.OrderCode = "SYB-2"
|
||||
sibling.DetailID = 2
|
||||
sibling.RawJSON = `{"productSpec":"黑色【204A】,XL"}`
|
||||
if err := db.Create(&sibling).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := NewService(db).BatchPreview(context.Background(),
|
||||
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Items[0].ReasonCode == CodeSpecKeyAmbiguous {
|
||||
t.Fatalf("identical repeated specs are not a collapse: %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 尺码塌缩同样要检出(线上 24 个键)。
|
||||
func TestBatchPreviewRefusesCollapsedSizeKey(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("raw_json", `{"productSpec":"黑色,XL【建議60-65公斤】"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling := f.syb
|
||||
sibling.ID = 0
|
||||
sibling.OrderCode = "SYB-2"
|
||||
sibling.DetailID = 2
|
||||
sibling.RawJSON = `{"productSpec":"黑色,XL【建議65-70公斤】"}`
|
||||
if err := db.Create(&sibling).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := NewService(db).BatchPreview(context.Background(),
|
||||
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Items[0].ReasonCode != CodeSpecKeyAmbiguous {
|
||||
t.Fatalf("collapsed size key must be refused: %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed:
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/device"
|
||||
@@ -378,6 +380,22 @@ 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, "当前任务不能标记订单结果未知")
|
||||
@@ -394,9 +412,17 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
a.ErrorCode, a.ErrorMessage = &failureCode, &failureMessage
|
||||
t.ErrorCode, t.ErrorMessage = &failureCode, &failureMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
if req.PaymentPageObserved {
|
||||
t.PaymentPageObservedAt = &now
|
||||
}
|
||||
case "failed":
|
||||
next = models.PurchaseTaskStatusFailed
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
// `[必须]` Write the failure onto the attempt too, not only the task.
|
||||
// Without it every attempt produced by this branch carries a NULL
|
||||
// error_code, so attempt-level statistics silently omit them — on
|
||||
// production the spec-panel failures were invisible that way (#271).
|
||||
a.ErrorCode, a.ErrorMessage = &req.ErrorCode, &req.ErrorMessage
|
||||
t.ErrorCode = &req.ErrorCode
|
||||
t.ErrorMessage = &req.ErrorMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
@@ -447,6 +473,26 @@ var allowedOrderUnknownFailures = map[string]string{
|
||||
CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据",
|
||||
}
|
||||
|
||||
// orderFailureMessageLimit matches the error_message column width on both
|
||||
// purchase_task and purchase_task_attempt (size:1000). The canonical sentence is
|
||||
// never sacrificed to fit: only the appended agent evidence is truncated.
|
||||
const orderFailureMessageLimit = 1000
|
||||
|
||||
// normalizeOrderUnknownFailure keeps the canonical sentence as the message
|
||||
// people read, and appends the agent's own diagnostic behind it.
|
||||
//
|
||||
// `[必须]` The canonical text stays authoritative. The agent supplies free text,
|
||||
// so it must not become the primary description of what happened, and the UI
|
||||
// wording has to stay stable per error code.
|
||||
//
|
||||
// `[必须]` The agent's evidence is nevertheless kept. Discarding it left the
|
||||
// PAYMENT_REPEATED failures unanalysable: #210 computes paymentBackAttempts and
|
||||
// consecutivePaymentSamplesAfterBack, the agent reports them, and none of the 61
|
||||
// production failures between 2026-09-07 and 09-11 carried either value, so
|
||||
// "Back did not work", "the page rendered slower than the ~1.1s window" and "the
|
||||
// page was the order page but no marker matched" could not be told apart. The
|
||||
// sibling "failed" branch already stores agent text verbatim, so keeping it here
|
||||
// introduces no new trust assumption (#271).
|
||||
func normalizeOrderUnknownFailure(code, message string) (string, string, error) {
|
||||
code, message = strings.TrimSpace(code), strings.TrimSpace(message)
|
||||
if code == "" && message == "" {
|
||||
@@ -456,7 +502,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) {
|
||||
@@ -525,7 +617,10 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec
|
||||
}
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = s.Now()
|
||||
return tx.Save(&t).Error
|
||||
if e := tx.Save(&t).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return t, replayed, err
|
||||
}
|
||||
@@ -728,6 +823,10 @@ 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, "设备或拼多多账号已有运行任务")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ
|
||||
}
|
||||
out.WritebackStatus = models.PurchaseWritebackStatusPending
|
||||
out.WritebackSelectRequestID = &req.RequestID
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
@@ -166,7 +169,10 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
}
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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"
|
||||
CodeBackfillAmountConflict = "PURCHASE_BACKFILL_AMOUNT_CONFLICT"
|
||||
)
|
||||
|
||||
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"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,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"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
WarningCode string `json:"warningCode,omitempty"`
|
||||
WarningMessage string `json:"warningMessage,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 item.PDDOrderAmountCent != nil && *item.PDDOrderAmountCent < 0 {
|
||||
item.PDDOrderAmountCent = nil
|
||||
}
|
||||
if task.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
|
||||
}
|
||||
if item.PDDOrderAmountCent != nil {
|
||||
if task.PDDOrderAmountCent == nil {
|
||||
task.PDDOrderAmountCent = item.PDDOrderAmountCent
|
||||
task.StatusVersion++
|
||||
task.StatusChangedAt = s.Now()
|
||||
if err := tx.Save(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
|
||||
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
|
||||
}
|
||||
}
|
||||
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 item.PDDOrderAmountCent != nil {
|
||||
if task.PDDOrderAmountCent == nil {
|
||||
task.PDDOrderAmountCent = item.PDDOrderAmountCent
|
||||
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
|
||||
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
|
||||
}
|
||||
}
|
||||
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, r.PDDOrderAmountCent = saved.PDDOrderNo, saved.OrderSubmittedAt, saved.PDDOrderAmountCent
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
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 TestOrderBackfillAmountFillIdempotencyAndConflictWarning(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
amount := int64(2298)
|
||||
item := backfillItem(task.ID, "AMOUNT-ORDER")
|
||||
item.PDDOrderAmountCent = &amount
|
||||
first := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
|
||||
if first.Code != "BACKFILLED" || first.PDDOrderAmountCent == nil || *first.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("first: %+v", first)
|
||||
}
|
||||
saved := loadBackfillTask(t, db, task.ID)
|
||||
version := saved.StatusVersion
|
||||
|
||||
same := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
|
||||
if same.Result != "already_backfilled" || same.WarningCode != "" {
|
||||
t.Fatalf("same: %+v", same)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, task.ID); got.StatusVersion != version {
|
||||
t.Fatal("same amount changed version")
|
||||
}
|
||||
|
||||
different := int64(2300)
|
||||
item.PDDOrderAmountCent = &different
|
||||
conflict := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
|
||||
if conflict.Result != "already_backfilled" || conflict.WarningCode != CodeBackfillAmountConflict || conflict.PDDOrderAmountCent == nil || *conflict.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("conflict: %+v", conflict)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.StatusVersion != version || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount || got.PDDOrderNo == nil || *got.PDDOrderNo != "AMOUNT-ORDER" {
|
||||
t.Fatalf("conflict changed facts: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillAddsMissingAmountToExistingOrder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
base := backfillItem(task.ID, "LATE-AMOUNT")
|
||||
runBackfill(t, s, f.token, uuid.NewString(), base)
|
||||
before := loadBackfillTask(t, db, task.ID)
|
||||
amount := int64(100)
|
||||
base.PDDOrderAmountCent = &amount
|
||||
result := runBackfill(t, s, f.token, uuid.NewString(), base)[0]
|
||||
if result.Result != "already_backfilled" || result.WarningCode != "" || result.PDDOrderAmountCent == nil || *result.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("late fill: %+v", result)
|
||||
}
|
||||
after := loadBackfillTask(t, db, task.ID)
|
||||
if after.StatusVersion != before.StatusVersion+1 || after.PDDOrderAmountCent == nil || *after.PDDOrderAmountCent != amount || !after.OrderSubmittedAt.Equal(*before.OrderSubmittedAt) {
|
||||
t.Fatalf("late fill changed wrong facts: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillIgnoresNegativeOptionalAmount(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
negative := int64(-1)
|
||||
item := backfillItem(task.ID, "NEGATIVE-AMOUNT")
|
||||
item.PDDOrderAmountCent = &negative
|
||||
result := runBackfill(t, testService(db), f.token, uuid.NewString(), item)[0]
|
||||
if result.Code != "BACKFILLED" || result.PDDOrderAmountCent != nil {
|
||||
t.Fatalf("negative amount affected order backfill: %+v", result)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.PDDOrderNo == nil || *got.PDDOrderNo != "NEGATIVE-AMOUNT" || got.PDDOrderAmountCent != nil {
|
||||
t.Fatalf("unexpected persisted facts: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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,209 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 采样期间见过待付款/支付页时必须落库,这是后续按优先级处理积压的
|
||||
// order_result_unknown 记录的唯一依据(#302)。
|
||||
func TestSubmitOrderResultUnknownRecordsPaymentPageObserved(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,
|
||||
PaymentPageObserved: true,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var saved models.PurchaseTask
|
||||
if err := db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.PaymentPageObservedAt == nil {
|
||||
t.Fatal("paymentPageObserved=true 却没有落库时间戳")
|
||||
}
|
||||
}
|
||||
|
||||
// 没见过支付页时不能编造一个时间戳——这个字段的价值就在于它只在真见过时才有值。
|
||||
func TestSubmitOrderResultUnknownLeavesPaymentPageObservedEmptyByDefault(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)
|
||||
}
|
||||
if saved.PaymentPageObservedAt != nil {
|
||||
t.Fatal("未上报 paymentPageObserved 时不该写入时间戳")
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
}
|
||||
return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd")
|
||||
}
|
||||
// `[必须]` 塔缩的键不能走 AI 匹配这条路:再匹配一次也只会为同一个键写一份
|
||||
// 映射,而问题恰恰是多个不同规格共用了这个键。必须人工处理(#289)。
|
||||
if preview.ReasonCode == CodeSpecKeyAmbiguous {
|
||||
return stage(ProcessStageManualAction, preview.Reason, preview.NextAction)
|
||||
}
|
||||
if preview.ReasonCode == CodeMappingRequired {
|
||||
if preview.AIMatchEligible {
|
||||
return stage(ProcessStageColorMapping, "采购规格尚未匹配并保存", "open_mapping")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ type BatchRetryItem struct {
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// AttemptNumber 仅原地重试时有值:该任务本次是第几次执行(#303)。
|
||||
AttemptNumber int `json:"attemptNumber,omitempty"`
|
||||
}
|
||||
|
||||
type BatchRetryResponse struct {
|
||||
@@ -69,7 +71,71 @@ type preparedRetry struct {
|
||||
// BatchRetry creates new pending purchase tasks from failed task identities.
|
||||
// The failed rows remain immutable history; the current product, mapping,
|
||||
// price guard and server-owned rule are resolved again at retry time.
|
||||
// BatchRetry re-executes the selected failed tasks in place (#303).
|
||||
//
|
||||
// `[必须]` 不新建任务。采购员在管理页勾选失败任务点「重试」,要的是这条任务本身
|
||||
// 再跑一次:任务号不变,尝试记入 purchase_task_attempt。安全判据完全复用 Reset——
|
||||
// 碰过下单边界、同一 SYB 明细已有更新任务、规格快照不完整的都会被拒绝并返回原因,
|
||||
// 拒绝后也不退回新建,否则等于绕过了 Reset 的保护。
|
||||
func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
|
||||
return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效")
|
||||
}
|
||||
ids, err := validateRetryTaskIDs(req.TaskIDs)
|
||||
if err != nil {
|
||||
return BatchRetryResponse{}, err
|
||||
}
|
||||
response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))}
|
||||
for index, taskID := range ids {
|
||||
item := BatchRetryItem{SourceTaskID: taskID, SourceTaskNo: taskNumber(taskID)}
|
||||
var source models.PurchaseTask
|
||||
if queryErr := s.DB.WithContext(ctx).First(&source, taskID).Error; queryErr != nil {
|
||||
if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||
return BatchRetryResponse{}, internal(queryErr)
|
||||
}
|
||||
item.ReasonCode, item.Reason = CodeTaskNotFound, "采购任务不存在或已被删除"
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
itemRequestID := retryItemRequestID(req.RequestID, taskID)
|
||||
// `[必须]` 先识别重放,再做资格预检。第一次重试后任务已是 pending,
|
||||
// 若先预检会被判「只有失败任务可以重试」拒掉,同一 requestId 重复提交就
|
||||
// 不再幂等。已有对应尝试记录时直接交给 Reset,由它返回 Replayed。
|
||||
var replayAttempt models.PurchaseTaskAttempt
|
||||
replayErr := s.DB.WithContext(ctx).Where("attempt_id = ?", purchaseResetAttemptID(itemRequestID, taskID)).First(&replayAttempt).Error
|
||||
if replayErr != nil && !errors.Is(replayErr, gorm.ErrRecordNotFound) {
|
||||
return BatchRetryResponse{}, internal(replayErr)
|
||||
}
|
||||
isReplay := replayErr == nil
|
||||
// 预检与原批量入口一致(状态、档案可采购性、设备),但 checkDeviceBusy 关闭:
|
||||
// 同一设备上勾选的多条任务要能排队,真正的占用判断在 Reset 的事务里做。
|
||||
if decision := s.retryEligibility(ctx, source, false); !isReplay && !decision.Allowed {
|
||||
item.ReasonCode, item.Reason = decision.ReasonCode, decision.Reason
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
reset, resetErr := s.Reset(ctx, taskID, PurchaseResetRequest{RequestID: itemRequestID})
|
||||
if resetErr != nil {
|
||||
item.ReasonCode, item.Reason, _ = serviceErrorFields(resetErr)
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
sameID := taskID
|
||||
item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &sameID, taskNumber(taskID), reset.Replayed
|
||||
item.AttemptNumber = reset.AttemptNumber
|
||||
response.Items[index] = item
|
||||
response.CreatedCount++
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// batchRetryCreate creates replacement tasks. Only AgentRetry uses it: once a
|
||||
// replacement product has been matched the original task cannot be reused,
|
||||
// because its product and specification snapshots describe the old product.
|
||||
func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
|
||||
return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效")
|
||||
}
|
||||
@@ -179,7 +245,7 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR
|
||||
return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason)
|
||||
}
|
||||
}
|
||||
result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
|
||||
result, err := s.batchRetryCreate(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
|
||||
if err != nil {
|
||||
return AgentRetryResponse{}, err
|
||||
}
|
||||
|
||||
@@ -36,13 +36,16 @@ func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) mode
|
||||
return task
|
||||
}
|
||||
|
||||
func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
// `[必须]` #303:采购管理页勾选失败任务点重试,要的是这条任务本身再跑一次——
|
||||
// 任务号不变、不产生新任务,本次执行记入 purchase_task_attempt。
|
||||
func TestBatchRetryReexecutesSelectedTaskInPlaceAndReplays(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil {
|
||||
var before int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
|
||||
@@ -51,36 +54,69 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || !first.Items[0].Created || first.Items[0].TaskID == nil {
|
||||
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || first.Items[0].TaskID == nil {
|
||||
t.Fatalf("unexpected retry result: %+v", first)
|
||||
}
|
||||
if *first.Items[0].TaskID == failed.ID {
|
||||
t.Fatalf("retry reused failed task: %+v", first.Items[0])
|
||||
if *first.Items[0].TaskID != failed.ID {
|
||||
t.Fatalf("retry must reuse the selected task, got %+v", first.Items[0])
|
||||
}
|
||||
var oldTask, newTask models.PurchaseTask
|
||||
if err = db.First(&oldTask, failed.ID).Error; err != nil {
|
||||
var after int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil {
|
||||
if after != before {
|
||||
t.Fatalf("retry created a new purchase task: before=%d after=%d", before, after)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, failed.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID {
|
||||
t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask)
|
||||
if task.Status != models.PurchaseTaskStatusPending || task.ErrorCode != nil || task.AddressSuffix != failed.AddressSuffix {
|
||||
t.Fatalf("task was not reset in place: %+v", task)
|
||||
}
|
||||
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
|
||||
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
|
||||
if first.Items[0].AttemptNumber < 1 {
|
||||
t.Fatalf("attempt number missing: %+v", first.Items[0])
|
||||
}
|
||||
|
||||
replay, err := service.BatchRetry(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || replay.Items[0].TaskID == nil || *replay.Items[0].TaskID != newTask.ID {
|
||||
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || *replay.Items[0].TaskID != failed.ID || replay.Items[0].AttemptNumber != first.Items[0].AttemptNumber {
|
||||
t.Fatalf("retry replay was not stable: first=%+v replay=%+v", first, replay)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatalf("retry created duplicate tasks: count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
// `[必须]` 碰过下单边界的失败任务可能已在 PDD 建了订单,必须拒绝,而且拒绝后
|
||||
// 不能退回新建——否则等于绕过了 Reset 的保护,重复下单。
|
||||
func TestBatchRetryRefusesTaskPastOrderBoundaryWithoutCreating(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", service.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int64
|
||||
db.Model(&models.PurchaseTask{}).Count(&before)
|
||||
|
||||
result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode == "" {
|
||||
t.Fatalf("task past the order boundary must be refused: %+v", result)
|
||||
}
|
||||
var after int64
|
||||
db.Model(&models.PurchaseTask{}).Count(&after)
|
||||
if after != before {
|
||||
t.Fatalf("refused retry must not create a task: before=%d after=%d", before, after)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
db.First(&task, failed.ID)
|
||||
if task.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("refused task changed status: %q", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +210,8 @@ func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
|
||||
// 用仍会新建的替代路径制造「同一明细已有更新任务」的前提。
|
||||
created, err := service.batchRetryCreate(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
|
||||
if err != nil || created.CreatedCount != 1 {
|
||||
t.Fatalf("create newer task: %+v %v", created, err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
)
|
||||
|
||||
// specMatchFailure says why one AI matching attempt did not produce a mapping.
|
||||
type specMatchFailure string
|
||||
|
||||
const (
|
||||
// specMatchNoMatch: AI 明确回答「没有可靠对应」。同样的输入下次多半还是这个答案,
|
||||
// 但实测并非一定——见 retryableSpecMatch 的说明。
|
||||
specMatchNoMatch specMatchFailure = "no_match"
|
||||
// specMatchUnavailable: Provider 不可用、配置缺失等外部故障。
|
||||
specMatchUnavailable specMatchFailure = "unavailable"
|
||||
// specMatchAborted: 本端 context 被取消或超时。
|
||||
//
|
||||
// `[必须]` 必须与 unavailable 分开。它是本端调度问题而非 Provider 故障,重试
|
||||
// 毫无意义(同一个 ctx 已经死了),把它读成「AI 服务不可用」会一直误导排查——
|
||||
// 线上 2026-09-16 就出现过 unavailable=7 而 Provider 完全正常的情况(#300)。
|
||||
specMatchAborted specMatchFailure = "aborted"
|
||||
)
|
||||
|
||||
// specMatchAttempts is the total number of AI calls allowed for one value.
|
||||
//
|
||||
// `[必须]` 只重试一次,不要更多。AI 对同一输入会给出不同答案(实测 deepseek-v4-flash
|
||||
// 在 Temperature=0 下仍然如此:紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答
|
||||
// 「唯一匹配」),所以一次重试很值。但真失败——PDD 确实没有该颜色、或 `白色-冰块猫`
|
||||
// 与 `白色-空白版` 有歧义——每次都会失败,重试只是浪费调用和采购员的时间。实测连续
|
||||
// 三轮全量匹配,失败数稳定在 12,正是这两类(#295)。
|
||||
const specMatchAttempts = 2
|
||||
|
||||
// resolveSpecMatch calls the matcher, retrying once so a single flaky answer
|
||||
// does not leave a value permanently unmapped.
|
||||
//
|
||||
// 返回的 failure 只在 err != nil 时有意义,供调用方区分「AI 说没有」和「AI 没答上来」——
|
||||
// 此前两者都记成 failed,排查时分不开(#294 的遗留问题,本次一并修)。
|
||||
func (s *Service) resolveSpecMatch(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, specMatchFailure, error) {
|
||||
var lastErr error
|
||||
var lastFailure specMatchFailure
|
||||
for attempt := 0; attempt < specMatchAttempts; attempt++ {
|
||||
matched, err := s.matcher().Resolve(ctx, request)
|
||||
if err == nil {
|
||||
return matched, "", nil
|
||||
}
|
||||
lastErr, lastFailure = err, classifySpecMatchFailure(err)
|
||||
if ctx.Err() != nil {
|
||||
// ctx 已死,重试用的是同一个 ctx,再试一次只是多一条误导性日志。
|
||||
return aimatching.MatchResult{}, specMatchAborted, lastErr
|
||||
}
|
||||
}
|
||||
return aimatching.MatchResult{}, lastFailure, lastErr
|
||||
}
|
||||
|
||||
func classifySpecMatchFailure(err error) specMatchFailure {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return specMatchAborted
|
||||
}
|
||||
var matchErr *aimatching.Error
|
||||
if errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeNoMatch {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
return specMatchUnavailable
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
)
|
||||
|
||||
type scriptedMatcher struct {
|
||||
calls int
|
||||
answers []error
|
||||
}
|
||||
|
||||
func (m *scriptedMatcher) Resolve(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||||
index := m.calls
|
||||
m.calls++
|
||||
if index < len(m.answers) && m.answers[index] != nil {
|
||||
return aimatching.MatchResult{}, m.answers[index]
|
||||
}
|
||||
return aimatching.MatchResult{Source: aimatching.SourceAI, MappedColor: "紫色-冰块猫"}, nil
|
||||
}
|
||||
|
||||
func noMatch() error {
|
||||
return &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "没有找到可采购的 PDD 颜色或尺码"}
|
||||
}
|
||||
|
||||
// `[必须]` 实测同一输入第一次答「未找到」、第二次答「唯一匹配」(#295)。一次抽风
|
||||
// 不该让这个值永久留空。
|
||||
func TestFlakyFirstAnswerIsRetriedOnce(t *testing.T) {
|
||||
matcher := &scriptedMatcher{answers: []error{noMatch()}}
|
||||
service := &Service{Matcher: matcher}
|
||||
matched, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"})
|
||||
if err != nil {
|
||||
t.Fatalf("重试后应当成功,实际 %v", err)
|
||||
}
|
||||
if failure != "" || matched.MappedColor != "紫色-冰块猫" {
|
||||
t.Fatalf("failure=%q mapped=%q", failure, matched.MappedColor)
|
||||
}
|
||||
if matcher.calls != 2 {
|
||||
t.Fatalf("调用了 %d 次,应为 2", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 真失败每次都会失败——PDD 确实没有该颜色,或候选有歧义。重试次数必须有
|
||||
// 上限,否则只是浪费调用和采购员的时间。
|
||||
func TestARealNoMatchIsNotRetriedForever(t *testing.T) {
|
||||
matcher := &scriptedMatcher{answers: []error{noMatch(), noMatch(), noMatch()}}
|
||||
service := &Service{Matcher: matcher}
|
||||
_, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "墨綠色"})
|
||||
if err == nil {
|
||||
t.Fatal("应当失败")
|
||||
}
|
||||
if failure != specMatchNoMatch {
|
||||
t.Fatalf("failure=%q,应为 no_match", failure)
|
||||
}
|
||||
if matcher.calls != specMatchAttempts {
|
||||
t.Fatalf("调用了 %d 次,应为上限 %d", matcher.calls, specMatchAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务不可用与「AI 说没有」的处置完全不同,必须分得开(#294 的遗留问题)。
|
||||
func TestTransportFailureIsNotReportedAsNoMatch(t *testing.T) {
|
||||
matcher := &scriptedMatcher{answers: []error{errors.New("dial tcp: connection refused"), errors.New("dial tcp: connection refused")}}
|
||||
service := &Service{Matcher: matcher}
|
||||
_, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"})
|
||||
if err == nil {
|
||||
t.Fatal("应当失败")
|
||||
}
|
||||
if failure != specMatchUnavailable {
|
||||
t.Fatalf("failure=%q,应为 unavailable", failure)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` ctx 被取消是本端调度问题,不是 Provider 故障。归成 unavailable 会让
|
||||
// 排查一直看错方向——线上 2026-09-16 就出现过 unavailable=7 而 AI 服务完全正常,
|
||||
// 真正的原因是匹配挂在 Agent 提交请求的 ctx 上、请求断开即中断(#300)。
|
||||
func TestCancelledContextIsAbortedNotUnavailable(t *testing.T) {
|
||||
matcher := &scriptedMatcher{answers: []error{context.Canceled, context.Canceled}}
|
||||
service := &Service{Matcher: matcher}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, failure, err := service.resolveSpecMatch(ctx, aimatching.MatchRequest{TargetColor: "桔紅色"})
|
||||
if err == nil {
|
||||
t.Fatal("应当失败")
|
||||
}
|
||||
if failure != specMatchAborted {
|
||||
t.Fatalf("failure=%q,应为 aborted", failure)
|
||||
}
|
||||
// ctx 已死,重试用的是同一个 ctx,不该再问一次。
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("调用了 %d 次,ctx 已取消时不应重试", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineExceededIsAlsoAborted(t *testing.T) {
|
||||
if got := classifySpecMatchFailure(context.DeadlineExceeded); got != specMatchAborted {
|
||||
t.Fatalf("got %q, want aborted", got)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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"
|
||||
@@ -159,6 +160,9 @@ type ResultRequest struct {
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`
|
||||
// PaymentPageObserved 仅 order_result_unknown 使用;见 models.PurchaseTask
|
||||
// 的 PaymentPageObservedAt 说明(#302)。
|
||||
PaymentPageObserved bool `json:"paymentPageObserved,omitempty"`
|
||||
}
|
||||
|
||||
type SpecDecisionRequest struct {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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,6 +9,7 @@ import (
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -360,6 +361,18 @@ 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"
|
||||
@@ -184,17 +190,48 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
|
||||
return AISuggestResponse{}, aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
|
||||
}
|
||||
|
||||
result, err := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
|
||||
suggestReq := aimatching.SuggestRequest{
|
||||
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
|
||||
Sources: sources, Candidates: candidates,
|
||||
})
|
||||
}
|
||||
result, err := aiService.SuggestBatch(ctx, suggestReq)
|
||||
suggestCalls := 1
|
||||
if err != nil {
|
||||
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
|
||||
result, err = aiService.SuggestBatch(ctx, suggestReq)
|
||||
suggestCalls++
|
||||
if err != nil {
|
||||
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
decisions := result.Decisions
|
||||
if decisions == nil {
|
||||
decisions = map[string]aimatching.SuggestDecision{}
|
||||
}
|
||||
var retrySources []aimatching.SuggestSource
|
||||
for _, source := range sources {
|
||||
decision, ok := decisions[source.ID]
|
||||
if unreliableSuggestDecision(decision, ok, candidateLabelByID, candidateValues) {
|
||||
retrySources = append(retrySources, source)
|
||||
}
|
||||
}
|
||||
if len(retrySources) > 0 && suggestCalls < 2 {
|
||||
retryResult, retryErr := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
|
||||
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
|
||||
Sources: retrySources, Candidates: candidates,
|
||||
})
|
||||
if retryErr == nil {
|
||||
for _, source := range retrySources {
|
||||
if decision, ok := retryResult.Decisions[source.ID]; ok {
|
||||
decisions[source.ID] = decision
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, p := range placeholders {
|
||||
sourceID := fmt.Sprintf("s%d", i+1)
|
||||
decision, ok := result.Decisions[sourceID]
|
||||
decision, ok := decisions[sourceID]
|
||||
item := &response.Items[p.itemIndex]
|
||||
if !ok || decision.CandidateID == "" {
|
||||
item.Reason = "AI 未给出可靠建议,请人工选择"
|
||||
@@ -237,6 +274,26 @@ 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
|
||||
}
|
||||
|
||||
// unreliableSuggestDecision is true when SuggestBatch gave no usable pick:
|
||||
// missing decision, empty candidate, or a candidate that cannot be translated
|
||||
// back onto the live PDD selectable set.
|
||||
func unreliableSuggestDecision(decision aimatching.SuggestDecision, ok bool, candidateLabelByID map[string]string, candidateValues map[string]bool) bool {
|
||||
if !ok || decision.CandidateID == "" {
|
||||
return true
|
||||
}
|
||||
pddValue, ok := candidateLabelByID[decision.CandidateID]
|
||||
return !ok || !candidateValues[pddValue]
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -3,10 +3,13 @@ package shopeeproduct
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -25,6 +28,49 @@ func seedEnabledAISetting(t *testing.T, baseURL string, minConfidence float64) m
|
||||
return models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: baseURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: minConfidence}
|
||||
}
|
||||
|
||||
type capturedSuggestPrompt struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
} `json:"sources"`
|
||||
Candidates []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
|
||||
func parseSuggestPrompt(t *testing.T, body []byte) capturedSuggestPrompt {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode chat request: %v", err)
|
||||
}
|
||||
var user string
|
||||
for _, message := range payload.Messages {
|
||||
if message.Role == "user" {
|
||||
user = message.Content
|
||||
}
|
||||
}
|
||||
var prompt capturedSuggestPrompt
|
||||
if err := json.Unmarshal([]byte(user), &prompt); err != nil {
|
||||
t.Fatalf("decode suggest prompt: %v", err)
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func sourceIDsOf(prompt capturedSuggestPrompt) []string {
|
||||
ids := make([]string, len(prompt.Sources))
|
||||
for i, source := range prompt.Sources {
|
||||
ids[i] = source.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestSuggestColorMappingsRejectsStaleContextVersion(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
@@ -158,3 +204,213 @@ func TestSuggestMappingsSkipConfirmedValuesStillValid(t *testing.T) {
|
||||
t.Fatalf("expected preserved confirmed mapping without calling AI: %+v", preview.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingsRetriesOnlyUnreliableSources(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var prompts []capturedSuggestPrompt
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
http.Error(w, "read", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prompt := parseSuggestPrompt(t, body)
|
||||
mu.Lock()
|
||||
call := len(prompts)
|
||||
prompts = append(prompts, prompt)
|
||||
mu.Unlock()
|
||||
|
||||
content := `{"suggestions":[
|
||||
{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"first call match"},
|
||||
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"no pick"}
|
||||
]}`
|
||||
if call >= 1 {
|
||||
content = `{"suggestions":[
|
||||
{"sourceId":"s2","candidateId":"c1","confidence":0.96,"reason":"retry match"}
|
||||
]}`
|
||||
}
|
||||
chatCompletionResponder(content)(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, server.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-PARTIAL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
|
||||
{Name: "深黑", Source: ValueSourceImport},
|
||||
{Name: "浅黑", Source: ValueSourceImport},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
|
||||
}
|
||||
if got := sourceIDsOf(prompts[0]); len(got) != 2 || got[0] != "s1" || got[1] != "s2" {
|
||||
t.Fatalf("first call sources: %v", got)
|
||||
}
|
||||
if got := sourceIDsOf(prompts[1]); len(got) != 1 || got[0] != "s2" {
|
||||
t.Fatalf("second call must retry only the unreliable source, got %v", got)
|
||||
}
|
||||
if len(prompts[0].Candidates) == 0 {
|
||||
t.Fatal("first call missing candidates")
|
||||
}
|
||||
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
|
||||
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
|
||||
if string(firstCandidates) != string(secondCandidates) {
|
||||
t.Fatalf("retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
|
||||
}
|
||||
if len(preview.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %+v", preview.Items)
|
||||
}
|
||||
if preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" || preview.Items[0].Reason != "first call match" {
|
||||
t.Fatalf("concluded source must keep first-call result: %+v", preview.Items[0])
|
||||
}
|
||||
if preview.Items[1].Status != "ai_matched" || preview.Items[1].PDDValue != "黑色" || preview.Items[1].Reason != "retry match" {
|
||||
t.Fatalf("unreliable source must be filled from retry: %+v", preview.Items[1])
|
||||
}
|
||||
if preview.MatchedCount != 2 || preview.PendingCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingsRetriesSuggestBatchErrorOnce(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var prompts []capturedSuggestPrompt
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
http.Error(w, "read", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prompt := parseSuggestPrompt(t, body)
|
||||
mu.Lock()
|
||||
call := len(prompts)
|
||||
prompts = append(prompts, prompt)
|
||||
mu.Unlock()
|
||||
if call == 0 {
|
||||
http.Error(w, "provider down", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
chatCompletionResponder(`{"suggestions":[
|
||||
{"sourceId":"s1","candidateId":"c1","confidence":0.97,"reason":"recovered"}
|
||||
]}`)(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, server.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-ERR", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
|
||||
{Name: "深黑", Source: ValueSourceImport},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("second SuggestBatch success should recover: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
|
||||
}
|
||||
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
|
||||
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
|
||||
if string(firstCandidates) != string(secondCandidates) {
|
||||
t.Fatalf("error retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
|
||||
}
|
||||
if got, want := sourceIDsOf(prompts[0]), sourceIDsOf(prompts[1]); len(got) != len(want) || (len(got) > 0 && got[0] != want[0]) {
|
||||
t.Fatalf("error retry must resend the same sources: first=%v second=%v", got, want)
|
||||
}
|
||||
if len(preview.Items) != 1 || preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" {
|
||||
t.Fatalf("unexpected preview: %+v", preview.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingsDoesNotExceedTwoSuggestBatchCalls(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
chatCompletionResponder(`{"suggestions":[
|
||||
{"sourceId":"s1","candidateId":"","confidence":0,"reason":"none"},
|
||||
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"none"}
|
||||
]}`)(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, server.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-CAP", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
|
||||
{Name: "深黑", Source: ValueSourceImport},
|
||||
{Name: "浅黑", Source: ValueSourceImport},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls != 2 {
|
||||
t.Fatalf("SuggestBatch must be called at most twice, got %d", calls)
|
||||
}
|
||||
if preview.PendingCount != 2 || preview.MatchedCount != 0 {
|
||||
t.Fatalf("still-unreliable after retry should stay pending: %+v", preview)
|
||||
}
|
||||
for _, item := range preview.Items {
|
||||
if item.Reason != "AI 未给出可靠建议,请人工选择" || item.Status != "pending" {
|
||||
t.Fatalf("unexpected pending item: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreliableSuggestDecision(t *testing.T) {
|
||||
labels := map[string]string{"c1": "黑色"}
|
||||
values := map[string]bool{"黑色": true}
|
||||
if !unreliableSuggestDecision(aimatching.SuggestDecision{}, false, labels, values) {
|
||||
t.Fatal("missing decision must be retried")
|
||||
}
|
||||
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: ""}, true, labels, values) {
|
||||
t.Fatal("empty candidate must be retried")
|
||||
}
|
||||
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c9"}, true, labels, values) {
|
||||
t.Fatal("unknown candidate id must be retried")
|
||||
}
|
||||
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, map[string]string{"c1": "红色"}, values) {
|
||||
t.Fatal("label outside candidate set must be retried")
|
||||
}
|
||||
if unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, labels, values) {
|
||||
t.Fatal("in-set candidate must not be retried")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package shopeespec fetches a Shopee product's complete colour and size list
|
||||
// from the ERP-Go side service (#290).
|
||||
//
|
||||
// 存在的理由:虾皮档案里的颜色尺码是从 SYB 明细增量累积的(sybimport.mergeParsedSpec),
|
||||
// SYB 送来什么才有什么,因此永远滞后于真实商品。新订单带来新组合时又变成“未匹配”,
|
||||
// 采购员得反复去点 AI 匹配。拉取完整清单后可以一次性匹配完,之后新订单直接可采购。
|
||||
package shopeespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// 返回体是颜色与尺码两个字符串数组,实测 462 字节;上限留足冗余即可。
|
||||
maxResponseBytes = 1 << 20
|
||||
requestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ErrNotConfigured means the base URL or API key is absent, so the caller
|
||||
// should skip the sync instead of failing the surrounding operation.
|
||||
var ErrNotConfigured = errors.New("shopee spec service is not configured")
|
||||
|
||||
// Spec is the complete colour and size list of one Shopee product, exactly as
|
||||
// the platform spells them — 【...】 annotations included.
|
||||
type Spec struct {
|
||||
Colors []string
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
// Client reads its endpoint and credential from the environment.
|
||||
//
|
||||
// `[必须]` 凭据只从环境变量读取,不接受参数传入、不落日志。本地由
|
||||
// scripts/start-server.ps1 从 config.yaml 的 erpgo 段转换;线上在
|
||||
// /etc/goauto/goauto.env 配置。
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: requestTimeout}} }
|
||||
|
||||
// Fetch returns the full spec list for one Shopee item id.
|
||||
func (c *Client) Fetch(ctx context.Context, shopeeItemID string) (Spec, error) {
|
||||
shopeeItemID = strings.TrimSpace(shopeeItemID)
|
||||
if shopeeItemID == "" {
|
||||
return Spec{}, errors.New("shopee item id is empty")
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_BASE_URL"))
|
||||
key := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_APIKEY"))
|
||||
if base == "" || key == "" {
|
||||
return Spec{}, ErrNotConfigured
|
||||
}
|
||||
endpoint, err := url.Parse(strings.TrimRight(base, "/") + "/api/v1/shopee/product/spec/" + url.PathEscape(shopeeItemID))
|
||||
if err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("apikey", key)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: requestTimeout}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
// `[必须]` 不要把 err 直接往外带:net/url 的错误会把完整 URL(含 apikey)
|
||||
// 写进错误文本,那会让凭据流进日志和工单。
|
||||
return Spec{}, fmt.Errorf("shopee spec request failed for item %s", shopeeItemID)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return Spec{}, fmt.Errorf("shopee spec service returned %d for item %s", response.StatusCode, shopeeItemID)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return Spec{}, fmt.Errorf("shopee spec response unreadable for item %s", shopeeItemID)
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Color []string `json:"color"`
|
||||
Size []string `json:"size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return Spec{}, fmt.Errorf("shopee spec response is not decodable for item %s", shopeeItemID)
|
||||
}
|
||||
spec := Spec{Colors: clean(payload.Data.Color), Sizes: clean(payload.Data.Size)}
|
||||
if len(spec.Colors) == 0 && len(spec.Sizes) == 0 {
|
||||
return Spec{}, fmt.Errorf("shopee spec service returned no specs for item %s", shopeeItemID)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func clean(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package shopeespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testKey = "unit-test-key-not-a-real-credential"
|
||||
|
||||
func configure(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", base)
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", testKey)
|
||||
}
|
||||
|
||||
func TestFetchReturnsPlatformStringsVerbatim(t *testing.T) {
|
||||
var gotPath, gotKey string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotKey = r.URL.Path, r.URL.Query().Get("apikey")
|
||||
w.Write([]byte(`{"data":{"color":["香芋紫 【雙梅花】純棉","",""],"size":["2XL 60.0-67.5公斤","S","S"]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
configure(t, server.URL)
|
||||
|
||||
spec, err := NewClient().Fetch(context.Background(), " 56715929322 ")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if gotPath != "/api/v1/shopee/product/spec/56715929322" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotKey != testKey {
|
||||
t.Fatalf("apikey not sent")
|
||||
}
|
||||
// `[必须]` 客户端不做剥离——剥离规则属于 sybspec,必须与 SYB 明细同源。
|
||||
if len(spec.Colors) != 1 || spec.Colors[0] != "香芋紫 【雙梅花】純棉" {
|
||||
t.Fatalf("colors = %#v, want the platform string untouched", spec.Colors)
|
||||
}
|
||||
// 空值与重复值在这里去掉,避免下游把它们当成真实规格。
|
||||
if len(spec.Sizes) != 2 {
|
||||
t.Fatalf("sizes = %#v, want blanks and duplicates dropped", spec.Sizes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSkipsWhenNotConfigured(t *testing.T) {
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", "")
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", "")
|
||||
if _, err := NewClient().Fetch(context.Background(), "1"); err != ErrNotConfigured {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 传输失败时 net/url 会把完整 URL(含 apikey)写进错误文本,而调用方
|
||||
// 会把这个错误打进日志。错误里绝不能出现凭据(#290)。
|
||||
func TestFetchErrorsNeverCarryTheCredential(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
base := server.URL
|
||||
server.Close() // 关掉,制造传输层失败
|
||||
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
base string
|
||||
}{
|
||||
{"传输失败", base},
|
||||
{"URL 不合法", "http://[::1"},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
configure(t, item.base)
|
||||
_, err := NewClient().Fetch(context.Background(), "56715929322")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), testKey) || strings.Contains(err.Error(), "apikey") {
|
||||
t.Fatalf("error text leaks the credential: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsResponsesWithoutSpecs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"color":[],"size":[]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
configure(t, server.URL)
|
||||
|
||||
// 空清单不能当成“该商品没有规格”而覆盖档案,只能算失败并跳过同步。
|
||||
if _, err := NewClient().Fetch(context.Background(), "1"); err == nil {
|
||||
t.Fatal("expected an error for an empty spec list")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -97,6 +100,17 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
return err
|
||||
}
|
||||
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
|
||||
// `[必须]` 先消歧再入档。同一虾皮商品下 `黑色【短袖】` 与 `黑色【長袖】`
|
||||
// 剥离后会塌缩成同一个 `黑色`,两个不同商品共用一份映射必然买错一半
|
||||
// (#301)。消歧要看到同组全部原始规格,所以放在拿到 shopeeProduct
|
||||
// 之后、写档案之前。
|
||||
resolvedKeys, err := resyncShopeeProductKeys(tx, shopeeProduct.ID, rawProductSpecOf(rawJSON))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolvedKeys.Color != "" || resolvedKeys.Size != "" {
|
||||
parsed.Color, parsed.Size = resolvedKeys.Color, resolvedKeys.Size
|
||||
}
|
||||
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,98 +4,18 @@
|
||||
// purchase tasks or stores order/logistics fields (#41).
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
import "go-admin/app/goauto/sybspec"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
// `[必须]` 解析原语现在住在叶子包 sybspec,因为采购侧也要用同一份拆分与角色反转
|
||||
// 逻辑来检出规格塌缩(#289),而 sybimport 已依赖 purchase,直接引用会成环。
|
||||
// 这里只做别名转发,不复制实现——两份实现必然漂移。
|
||||
type ParseResult = sybspec.ParseResult
|
||||
|
||||
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
|
||||
// Parse implements the #41/#216 rule; see sybspec.Parse for the full contract.
|
||||
func Parse(raw string) ParseResult { return sybspec.Parse(raw) }
|
||||
|
||||
// explicitSizePattern recognizes only values whose spelling carries a strong
|
||||
// size signal. SYB has now been observed returning both "color,size" and
|
||||
// "size,color". A color dictionary would inevitably guess at product-specific
|
||||
// labels, so role reversal is allowed only when exactly one side matches this
|
||||
// deliberately narrow pattern.
|
||||
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
|
||||
// RawSpecHalves returns the productSpec halves with 【...】 still in place.
|
||||
func RawSpecHalves(raw string) (rawColor, rawSize string) { return sybspec.RawSpecHalves(raw) }
|
||||
|
||||
// ambiguousPattern flags leftover separators or multi-token noise after
|
||||
// bracket stripping — the signal that a "clean" split still isn't reliable.
|
||||
// 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.
|
||||
type ParseResult struct {
|
||||
Color string
|
||||
Size string
|
||||
Status string
|
||||
Note string
|
||||
}
|
||||
|
||||
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
|
||||
// annotations, and classify the result. It never guesses a
|
||||
// missing value and never invents a color or size that is not literally
|
||||
// present in the input.
|
||||
//
|
||||
// Rules, derived from real SYB samples (demo/shunyunbaoerp_stock_list.har)
|
||||
// plus the boundary cases already confirmed in the #41 prototype:
|
||||
// - empty/whitespace-only input -> failed, nothing to extract.
|
||||
// - no comma present (e.g. "均碼") -> uncertain: the whole string, with
|
||||
// brackets stripped, becomes the size candidate; color stays empty. A
|
||||
// single token with no separator cannot be split into two dimensions
|
||||
// without guessing which one it is.
|
||||
// - comma present and exactly one side has an explicit size signal -> that
|
||||
// side is size and the other side is color. This supports both observed
|
||||
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
|
||||
// - comma present and neither side has an explicit size signal -> retain the
|
||||
// established SYB color,size contract for backward compatibility.
|
||||
// - comma present and both sides have explicit size signals -> uncertain;
|
||||
// there is no safe color decision.
|
||||
// - comma present but either side is empty after stripping, or the color
|
||||
// candidate still carries a leftover '+' or internal whitespace -> the
|
||||
// split happened but is not trustworthy -> uncertain.
|
||||
func Parse(raw string) ParseResult {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 为空,无法拆分颜色尺码"}
|
||||
}
|
||||
|
||||
lastComma := strings.LastIndex(trimmed, ",")
|
||||
// SYB samples use the ASCII comma; a full-width Chinese comma has not been
|
||||
// observed, so it is deliberately not treated as a separator here rather
|
||||
// than guessed at.
|
||||
if lastComma < 0 {
|
||||
size := stripBrackets(trimmed)
|
||||
if size == "" {
|
||||
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"}
|
||||
}
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
secondPart := stripBrackets(trimmed[lastComma+1:])
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
func stripBrackets(part string) string {
|
||||
return strings.TrimSpace(bracketPattern.ReplaceAllString(part, ""))
|
||||
}
|
||||
// CollapsedSpecKey reports whether different raw halves share one parsed key.
|
||||
func CollapsedSpecKey(rawHalves []string) bool { return sybspec.CollapsedSpecKey(rawHalves) }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// #284: 空格分隔的「颜色 尺码」曾被整条当成颜色,尺码丢失且状态判为
|
||||
// success。success 的行不进 AI 解析队列(ai_parse_batch.go 只选 uncertain/failed),
|
||||
// 因此没有任何机制会纠正它们。本组用例锁住修复后的完整对照表,包括
|
||||
// #274 “接受描述性颜色”的意图必须保持不变。
|
||||
func TestParseSplitsWhitespaceWhenExactlyOneTokenIsASize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
color string
|
||||
size string
|
||||
}{
|
||||
{"颜色在前", "黑色 XL", "黑色", "XL"},
|
||||
{"单字母尺码", "紅色 M", "紅色", "M"},
|
||||
// 与逗号分支一致:角色反转只看哪一侧带明确尺码特征,不看位置。
|
||||
{"尺码在前", "XL 黑色", "黑色", "XL"},
|
||||
{"颜色含多个词", "黑色 XL 加厚", "黑色 加厚", "XL"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
result := sybimport.Parse(item.raw)
|
||||
if result.Status != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("status = %q, want success", result.Status)
|
||||
}
|
||||
if result.Color != item.color || result.Size != item.size {
|
||||
t.Fatalf("got color=%q size=%q, want color=%q size=%q",
|
||||
result.Color, result.Size, item.color, item.size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` #274 的意图是让描述性颜色被接受而不是被拒绝。#284 只能在
|
||||
// “恰好一个 token 带明确尺码特征”时介入;没有尺码 token 的字串必须原样保留为
|
||||
// 颜色,否则就把 #274 退回去了。
|
||||
func TestParseKeepsDescriptiveColorsWithoutASizeToken(t *testing.T) {
|
||||
// 来自真实 SYB 样本:“黑色+白色【純棉兩件裝】 簡約親膚”剥离备注后的形态。
|
||||
result := sybimport.Parse("黑色+白色 簡約親膚")
|
||||
if result.Status != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("status = %q, want success", result.Status)
|
||||
}
|
||||
if result.Color != "黑色+白色 簡約親膚" || result.Size != "" {
|
||||
t.Fatalf("descriptive colour was altered: color=%q size=%q", result.Color, result.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// 多个 token 都像尺码时无法安全地挑出颜色,与逗号分支「两侧均具尺码特征」
|
||||
// 同一道理,保守处理:不拆。实际数据中该形态的占比尚无证据(见 #284 风险节)。
|
||||
func TestParseDoesNotSplitWhenSeveralTokensLookLikeSizes(t *testing.T) {
|
||||
result := sybimport.Parse("S M L")
|
||||
if result.Size != "" {
|
||||
t.Fatalf("ambiguous multi-size spec must not be split, got size=%q", result.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// 单 token 的行为是 #274 定下的,#284 不得触碰。
|
||||
func TestParseSingleTokenBehaviourIsUnchanged(t *testing.T) {
|
||||
if result := sybimport.Parse("均碼"); result.Size != "均碼" || result.Color != "" {
|
||||
t.Fatalf("size-only token changed: color=%q size=%q", result.Color, result.Size)
|
||||
}
|
||||
if result := sybimport.Parse("黑色"); result.Color != "黑色" || result.Size != "" {
|
||||
t.Fatalf("colour-only token changed: color=%q size=%q", result.Color, result.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// 逗号仍然优先,空格规则不得干扰已有的逗号拆分。
|
||||
func TestParseCommaStillTakesPrecedenceOverWhitespace(t *testing.T) {
|
||||
result := sybimport.Parse("黑色,XL")
|
||||
if result.Color != "黑色" || result.Size != "XL" {
|
||||
t.Fatalf("comma split changed: color=%q size=%q", result.Color, result.Size)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,20 @@ 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
|
||||
}
|
||||
// `[必须]` 重解析也要走消歧,否则它会把键写回剥离后的塌缩形式,悄悄
|
||||
// 撤销导入时做的拆分(#301)。两条路径必须产出同一个键。
|
||||
if parsed.Status == models.SYBParseStatusSuccess && record.ShopeeProductID != nil {
|
||||
resolved, err := resyncShopeeProductKeys(tx, *record.ShopeeProductID, raw.ProductSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved.Color != "" || resolved.Size != "" {
|
||||
parsed.Color, parsed.Size = resolved.Color, resolved.Size
|
||||
}
|
||||
}
|
||||
outcome.NewStatus = parsed.Status
|
||||
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user