Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e257ca153 | ||
|
|
e89de1a085 | ||
|
|
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 | ||
|
|
ba0e48bcea | ||
|
|
edd1cb15df | ||
|
|
482ba3408a | ||
|
|
ca7f768a79 | ||
|
|
72b8b5d4d0 | ||
|
|
290a17ea15 |
@@ -128,9 +128,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 首次启动时仍然弹一次系统授权框(体验最佳位置),但这不是唯一入口:拒绝后
|
||||
// (包括"不再询问")用户可以在"状态"页的相册权限卡片里重新发起授权或跳转应用详情页,
|
||||
// 见 cn.ilapage.goauto.agent.ui.MediaPermissionPolicy / AgentStatusFragment。
|
||||
private fun requestMediaPermission() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
val permission = cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.requiredPermission()
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
|
||||
}
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+151
-7
@@ -1,24 +1,65 @@
|
||||
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 {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
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 sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
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) {
|
||||
@@ -26,19 +67,122 @@ class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
|
||||
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, "goauto-search-${UUID.randomUUID()}.jpg")
|
||||
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, "Pictures/GoAutoSearch")
|
||||
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 {
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
|
||||
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 }
|
||||
} catch (_: Exception) {
|
||||
resolver.delete(uri, null, null)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, 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,
|
||||
|
||||
@@ -211,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))
|
||||
}
|
||||
@@ -334,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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+246
-3
@@ -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)
|
||||
@@ -709,7 +840,88 @@ class AgentForegroundService : Service() {
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} else if (task.source == "image_search") {
|
||||
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
|
||||
// #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(
|
||||
@@ -1040,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()
|
||||
@@ -1054,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
|
||||
@@ -1069,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: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-11T09:09:07Z
|
||||
wiki_revision: c8dd6b1f25bf367d065e0969a4de858d857a3181
|
||||
synchronized_at: 2026-09-18T02:18:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -460,3 +460,28 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 有交接时,执行器恢复限定的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 迁移和发布须另行授权,不能以本地测试替代。
|
||||
|
||||
## SYB 单号回填持久队列(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,未部署),仅 Server/Web;Android 复用现有普通结果与人工订单回填。
|
||||
|
||||
- purchase/lifecycle.go、purchase/order_backfill.go → ensureOrderWriteback:与订单事实同事务建立 pending,purchase_task_id 唯一;外部失败不撤销已提交的订单事实。
|
||||
- models/purchase_order_writeback.go:purchase_order_writeback 保存不可变 stock/detail/单号快照、状态、attempt_count、write_started、租约、脱敏错误和完成时间;purchase_order_writeback_command 保存 requestId/输入摘要/逐项接受结果;purchase_order_writeback_lease 保存全局单例租约。追加迁移 1789800200000,不改旧物流字段,不批量排入历史订单。
|
||||
- purchase/order_writeback.go:批量命令事务/幂等/条件校验和 Admin 状态投影;order_writeback_handler.go/router.go 暴露 Admin 补偿;access/purchaser.go 纳入原采购权限启动对账目录。
|
||||
- purchase/order_writeback_worker.go:由 cmd/api/server.go 每数据库启动一个3秒轮询 worker;数据库2分钟单例租约使跨进程及不同任务的相同SYB明细写入串行。写前核对当前任务/关联快照,持久化写入起始标记,并验证租约所有者/剩余时间;不持有数据库事务跨网络请求。失联running恢复只回读,unknown不自动调度;其他同目标未确认写入阻止新写。
|
||||
- sybclient/order_number.go:一次 POST updateDetailPurchaseCode,不跟随重定向、不使用读接口重试策略。会话仅从现有 SessionStore 恢复,不在 worker 自动登录/OCR;外部原始错误不写入任务原因。
|
||||
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
||||
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
||||
|
||||
@@ -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: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-11T09:09:16Z
|
||||
wiki_revision: f353b476add5081d4d7a1493e5454b6400518923
|
||||
synchronized_at: 2026-09-18T02:18:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -66,7 +66,7 @@ synchronized_at: 2026-09-11T09:09:16Z
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号、可选下单时间,以及 #306 的可选实付金额。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -610,3 +610,23 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 有交接时,执行器恢复限定的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 现有订单详情卡片增加“实付价格”,缺失显示“尚未获取”,不新增列表列、入口或支付动作。安全点击集合及不保存原始控件树/个人信息的规则不变。
|
||||
|
||||
## PDD 订单号自动回填 SYB(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18),代码与隔离测试已完成,真实迁移/发布/SYB写入尚未验证。以下补充 #306 的单号后续流程,不改变其金额规则。
|
||||
|
||||
正式 SYB 订单采购取得 PDD 单号(普通结果或人工遍历回填)后,在订单事实的同一个数据库事务中保存独立回填记录,后台异步写入对应 stock_id + detail_id。已人工回填相同单号也幂等确保记录存在;不重新采购,不等待支付复核。备货、演练、未取得单号或关联失效不回填;不自动扫描历史单据,历史补偿由采购管理勾选发起。
|
||||
|
||||
写前精确回读唯一目标明细:同单号且 purchasePlatform=pdd 直接成功;非空不同单号/不兼容平台标记 conflict,不覆盖。远端为空才允许一次写请求,写后回读确认才成功。未知结果不得自动重复写;重启恢复的 running 只回读,不能因租约过期盲重发。失败/unknown 可人工补偿,仍先回读,且在途租约未到期时不能再发起。
|
||||
|
||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<!-- 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: 5a61e4f2cf2bb33b824ca722e9cee03071e1dffd
|
||||
synchronized_at: 2026-09-18T02:18:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
@@ -819,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 可为任务已采集的颜色逐张调用:
|
||||
@@ -1169,3 +1329,33 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
|
||||
- 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 数据流。
|
||||
|
||||
## SYB 采购单号回填(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
|
||||
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + order_created + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录;网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
|
||||
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
|
||||
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
|
||||
- Admin 列表/详情 task 新增 orderWriteback={status,reason?,completedAt?,canSubmit}。无队列时符合资格显示 not_started,否则 not_applicable;队列状态 pending/running/succeeded/failed/conflict/unknown。仅未发起、failed/unknown 且任务仍有资格、租约已过期时 canSubmit=true;running/pending/succeeded/conflict 不提供重复发起。
|
||||
- 回填只写 PDD 订单号;cost 固定0,created为空,绝不传 pddOrderAmountCent 或 actualUnitPriceCent。旧 writebackStatus/支付/物流字段不复用,旧物流 paid 规则不扩展到本接口。
|
||||
- 批量勾选取“可采购重试 OR 可单号回填”的并集,两个按钮各自过滤子集;不得把 order_created 任务送入采购重试。冲突只查看,不提供覆盖。unknown 人工补偿仍先回读,成功仅指回读确认相同单号与 pdd 平台。
|
||||
|
||||
追加迁移 1789800200000_purchase_order_writeback.go 建立独立记录、命令和单例租约表。先授权迁移及权限对账后再运行新版 Server/Web;本实现基于 #306,发布时同时核验其金额列迁移依赖。历史记录不在迁移时自动入队。
|
||||
|
||||
@@ -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: f1792b7fe35925480c25b890e85fa5e551744812
|
||||
synchronized_at: 2026-09-11T09:11:21Z
|
||||
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
|
||||
synchronized_at: 2026-09-18T02:18:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -679,3 +679,15 @@ settings:
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## PDD 采购单号写入(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,未部署)。协议依据用户本地 update_syb_pdd_order_number.har 单次样本;HAR/真实标识/Cookie不进入源码、Wiki或工单。本节不宣称远端幂等或原子比较写入。
|
||||
|
||||
POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stock.id>、detailId=<details[].id>、code=<PDD单号>、type=pdd、created=""、cost=0。ID必须为正整数,单号非空、无首尾空白/控制换行、最多100字符。cost是抓包确认的固定参数,不是实际金额;不得传Agent实付价格。
|
||||
|
||||
样本成功信封 HTTP200、status=true;随后 listByStock?hist=0 对应明细 purchaseCode 等于提交单号、purchasePlatform=pdd、purchaseStatus=1,purchaseTime由SYB生成。更新存在采购状态/时间副作用。
|
||||
|
||||
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
|
||||
|
||||
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
|
||||
|
||||
@@ -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: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
|
||||
synchronized_at: 2026-09-10T01:42:05Z
|
||||
wiki_revision: a630bab8f1c775ce9ed29cac990777b6760eeda4
|
||||
synchronized_at: 2026-09-18T02:18:19Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -104,3 +104,23 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 建议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 入站。
|
||||
|
||||
## SYB 订单号回填 worker 部署与排错(#305)
|
||||
|
||||
代码绑定 e89de1a(2026-09-18),基于 #306 的07a3817;本节是部署要求,不表示已发布或业务库已迁移。
|
||||
|
||||
1. 另行取得明确目标环境的数据库迁移、权限对账和发布授权。核对 #306 的1789800100000金额列前置;本单追加1789800200000_purchase_order_writeback.go,建立purchase_order_writeback、purchase_order_writeback_command、purchase_order_writeback_lease三表和单例行,不改任务/付款事实,不排入历史回填、不修改定时任务开关。
|
||||
2. 配套构建并发布Server/Web,沿用现有启动权限对账为管理员/采购员登记POST /api/admin/v1/purchase-tasks/syb-order-writeback。不新增角色、菜单或支付门槛。旧常驻实例使用SkipMigration时,仅重启并不能替代授权迁移。
|
||||
3. API启动后每数据库恢复一个3秒轮询的持久队列worker,独立于Admin定时任务列表;只处理已排入pending及租约过期running。正式采购结果、人工订单回填成功/同号回放会排队;历史补偿仅用户勾选发起。部署重启后已有pending会执行真实SYB写入,必须包含在发布授权范围内。
|
||||
4. 全局租约2分钟;会话恢复预算25秒,每次外部读/写20秒(只读客户端重试亦受该context限制),写前再次核验租约。崩溃恢复只回读,不盲目重写;unknown不自动重试,等租约到期再人工核对/补偿。同目标其他未确认写入先解决,不能删除记录规避保护。
|
||||
5. 排错先看采购管理的“SYB 单号回填”状态、脱敏原因、完成时间;需要结构诊断时查询记录的taskId/status/attempt_count/error_code/lease_expires_at,不输出单号、Cookie或完整原始响应。SYB会话失败应先恢复既有登录,再勾选失败任务补偿;按钮受理不等于SYB成功。conflict只核对,不强制覆盖。
|
||||
6. 不执行真实支付;实付金额只存Admin,SYB请求cost固定0。停止新版API可停止新轮询,但不保证已发送远端请求取消;回滚保留追加表和未知状态,不删除记录、不回退采购事实。迁移/多实例MySQL/真实SYB端到端仍需授权验收。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ var AdminAPIs = []APIPermission{
|
||||
{"批量创建图搜采集任务", "/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},
|
||||
@@ -108,6 +110,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
|
||||
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
|
||||
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
|
||||
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
|
||||
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
|
||||
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
|
||||
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
|
||||
|
||||
@@ -2,6 +2,15 @@ package access
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPurchaserMayWritebackOrderNumber(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/syb-order-writeback" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser order number writeback permission")
|
||||
}
|
||||
|
||||
func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, permission := range AdminAPIs {
|
||||
|
||||
@@ -56,6 +56,9 @@ func MigratedModels() []any {
|
||||
&models.PDDAccount{},
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseOrderWriteback{},
|
||||
&models.PurchaseOrderWritebackLease{},
|
||||
&models.PurchaseOrderWritebackCommand{},
|
||||
&models.PurchaseSpecMatchWorkItem{},
|
||||
&models.CollectionRule{},
|
||||
&models.PurchaseRule{},
|
||||
@@ -88,7 +91,12 @@ func Migrate(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
//
|
||||
// `[必须]` 结构过滤自 #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}
|
||||
@@ -96,7 +104,41 @@ func Migrate(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PurchaseOrderWriteback is independent of the legacy logistics writeback fields.
|
||||
type PurchaseOrderWriteback struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
PurchaseTaskID uint64 `gorm:"not null;uniqueIndex"`
|
||||
StockID int64 `gorm:"not null;index:idx_order_writeback_target,priority:1"`
|
||||
DetailID int64 `gorm:"not null;index:idx_order_writeback_target,priority:2"`
|
||||
OrderNo string `json:"-" gorm:"size:100;not null"`
|
||||
Status string `gorm:"size:24;not null;index"`
|
||||
AttemptCount int `gorm:"not null;default:0"`
|
||||
WriteStarted bool `gorm:"not null;default:false"`
|
||||
LeaseOwner string `gorm:"size:36"`
|
||||
LeaseExpiresAt *time.Time
|
||||
ErrorCode string `gorm:"size:80"`
|
||||
ErrorMessage string `gorm:"size:300"`
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWriteback) TableName() string { return "purchase_order_writeback" }
|
||||
|
||||
// A singleton lease serializes all SYB order-number writers, including different
|
||||
// tasks that resolve to the same remote stock/detail identity.
|
||||
type PurchaseOrderWritebackLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Owner string `gorm:"size:36"`
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackLease) TableName() string { return "purchase_order_writeback_lease" }
|
||||
|
||||
type PurchaseOrderWritebackCommand struct {
|
||||
RequestID string `gorm:"primaryKey;size:36"`
|
||||
InputHash string `gorm:"size:64;not null"`
|
||||
ResultJSON string `json:"-" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackCommand) TableName() string { return "purchase_order_writeback_command" }
|
||||
@@ -17,6 +17,9 @@ const (
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusCompletedPartial = "completed_partial"
|
||||
TaskStatusFailed = "failed"
|
||||
// TaskStatusCancelled 与 PurchaseTaskStatusCancelled 的既有约定对齐(#297):
|
||||
// 只有 pending 任务允许取消,取消后是终态,不再被任何设备领取。
|
||||
TaskStatusCancelled = "cancelled"
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
@@ -183,7 +186,7 @@ type CollectionTask struct {
|
||||
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','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')"`
|
||||
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"`
|
||||
@@ -277,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:
|
||||
@@ -420,6 +423,15 @@ type ShopeeProduct struct {
|
||||
// PDD product.
|
||||
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.
|
||||
|
||||
@@ -25,57 +25,60 @@ type AdminListRequest struct {
|
||||
}
|
||||
|
||||
type AdminTaskItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
OrderWriteback OrderWritebackView `json:"orderWriteback"`
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
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"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAttemptItem struct {
|
||||
@@ -163,15 +166,53 @@ 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))
|
||||
writebacks, err := s.OrderWritebackViews(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
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 {
|
||||
@@ -202,7 +243,19 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
return AdminDetailResponse{}, matchErr
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
||||
if wbErr != nil {
|
||||
return AdminDetailResponse{}, wbErr
|
||||
}
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
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 +294,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,
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
|
||||
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} {
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry, (Handler{}).AdminOrderWriteback} {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
|
||||
|
||||
@@ -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,14 @@ 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.
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -310,8 +310,11 @@ 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 remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||
@@ -334,6 +337,91 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
|
||||
}
|
||||
}
|
||||
|
||||
// #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,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -379,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, "当前任务不能标记订单结果未知")
|
||||
@@ -395,6 +412,9 @@ 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
|
||||
@@ -428,6 +448,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
if e := tx.Save(t).Error; e != nil {
|
||||
return TaskPayload{}, conflictOrInternal(e)
|
||||
}
|
||||
if e := ensureOrderWriteback(tx, *t); e != nil {
|
||||
return TaskPayload{}, internal(e)
|
||||
}
|
||||
return valuePayload(s, t, a, false)
|
||||
})
|
||||
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
|
||||
@@ -597,7 +620,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
|
||||
}
|
||||
@@ -800,6 +826,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 ensureOrderWriteback(tx, task)
|
||||
}
|
||||
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 ensureOrderWriteback(tx, task)
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -153,3 +153,57 @@ func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
|
||||
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 时不该写入时间戳")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type OrderWritebackView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CanSubmit bool `json:"canSubmit"`
|
||||
}
|
||||
type OrderWritebackRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
PurchaseTaskIDs []uint64 `json:"purchaseTaskIds"`
|
||||
}
|
||||
type OrderWritebackAcceptance struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
Result string `json:"result"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type OrderWritebackResponse struct {
|
||||
Items []OrderWritebackAcceptance `json:"items"`
|
||||
}
|
||||
|
||||
func orderWritebackEligible(t models.PurchaseTask) bool {
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
// Called within the same transaction as the order fact. Never contacts SYB.
|
||||
func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
if !orderWritebackEligible(t) {
|
||||
return nil
|
||||
}
|
||||
var syb models.SYBProduct
|
||||
if err := tx.First(&syb, *t.SYBProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.StockID <= 0 || syb.DetailID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
out := map[uint64]OrderWritebackView{}
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
ids = append(ids, t.ID)
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
|
||||
if orderWritebackEligible(t) {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
}
|
||||
out[t.ID] = v
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []models.PurchaseOrderWriteback
|
||||
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
v := out[r.PurchaseTaskID]
|
||||
v.Status = r.Status
|
||||
v.Reason = r.ErrorMessage
|
||||
v.CompletedAt = r.CompletedAt
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackRequest) (OrderWritebackResponse, error) {
|
||||
out := OrderWritebackResponse{Items: []OrderWritebackAcceptance{}}
|
||||
if uuid.Validate(req.RequestID) != nil || len(req.PurchaseTaskIDs) == 0 || len(req.PurchaseTaskIDs) > 100 {
|
||||
return out, fail(CodeInvalidRequest, "请选择1~100条采购任务")
|
||||
}
|
||||
ids := append([]uint64(nil), req.PurchaseTaskIDs...)
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for i, id := range ids {
|
||||
if id == 0 || (i > 0 && id == ids[i-1]) {
|
||||
return out, fail(CodeInvalidRequest, "任务编号无效或重复")
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(ids)
|
||||
sum := sha256.Sum256(data)
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
db := s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Serializes batch command replay without holding a lock across remote IO.
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var guard models.PurchaseOrderWritebackLease
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&guard, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var command models.PurchaseOrderWritebackCommand
|
||||
err := tx.First(&command, "request_id = ?", req.RequestID).Error
|
||||
if err == nil {
|
||||
if command.InputHash != fingerprint {
|
||||
return fail(CodeInvalidRequest, "同一requestId不能改变任务集合")
|
||||
}
|
||||
return json.Unmarshal([]byte(command.ResultJSON), &out)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
a := OrderWritebackAcceptance{TaskID: id, Result: "skipped", Reason: "任务不存在或不符合回填条件"}
|
||||
var task models.PurchaseTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, id).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if !orderWritebackEligible(task) {
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if err := ensureOrderWriteback(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
var row models.PurchaseOrderWriteback
|
||||
if err := tx.Where("purchase_task_id = ?", id).First(&row).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
a.Reason = "SYB明细关联已失效"
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case row.OrderNo != *task.PDDOrderNo:
|
||||
a.Result, a.Reason = "conflict", "订单号快照不一致,请人工核对"
|
||||
case row.Status == "succeeded":
|
||||
a.Result, a.Reason = "succeeded", "已回填"
|
||||
case row.Status == "conflict":
|
||||
a.Result, a.Reason = "conflict", row.ErrorMessage
|
||||
case row.Status == "running" || (row.LeaseExpiresAt != nil && row.LeaseExpiresAt.After(s.Now())):
|
||||
a.Reason = "正在回填或等待在途请求结束"
|
||||
case row.Status == "pending":
|
||||
a.Result, a.Reason = "pending", "已加入回填"
|
||||
default:
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
result, _ := json.Marshal(out)
|
||||
return tx.Create(&models.PurchaseOrderWritebackCommand{RequestID: req.RequestID, InputHash: fingerprint, ResultJSON: string(result)}).Error
|
||||
})
|
||||
if err != nil {
|
||||
var e *ServiceError
|
||||
if errors.As(err, &e) {
|
||||
return out, err
|
||||
}
|
||||
return out, internal(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h Handler) AdminOrderWriteback(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
}
|
||||
var req OrderWritebackRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.RequestOrderWriteback(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
writeAdminData(c, out)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeOrderNumberClient struct {
|
||||
code, platform string
|
||||
reads, writes int
|
||||
writeErr, readErr error
|
||||
apply bool
|
||||
duplicate bool
|
||||
beforeWrite func()
|
||||
}
|
||||
|
||||
func (f *fakeOrderNumberClient) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
|
||||
f.reads++
|
||||
if f.readErr != nil {
|
||||
return nil, f.readErr
|
||||
}
|
||||
d := sybclient.DetailItem{ID: 1, Raw: map[string]any{"purchaseCode": f.code, "purchasePlatform": f.platform}}
|
||||
details := []sybclient.DetailItem{{ID: 99, Raw: map[string]any{"purchaseCode": "UNRELATED", "purchasePlatform": "pdd"}}, d}
|
||||
if f.duplicate {
|
||||
details = append(details, d)
|
||||
}
|
||||
return []sybclient.StockDetail{{ID: ids[0], Details: details}}, nil
|
||||
}
|
||||
func (f *fakeOrderNumberClient) UpdateDetailPurchaseCode(_ context.Context, stock, detail int64, code string) error {
|
||||
if stock != 2 || detail != 1 {
|
||||
panic("wrong remote identity")
|
||||
}
|
||||
f.writes++
|
||||
if f.beforeWrite != nil {
|
||||
f.beforeWrite()
|
||||
}
|
||||
if f.apply {
|
||||
f.code, f.platform = code, "pdd"
|
||||
}
|
||||
return f.writeErr
|
||||
}
|
||||
func orderWritebackFixture(t *testing.T) (*Service, models.PurchaseTask) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "EXAMPLE-ORDER"))[0]
|
||||
if result.Code != "BACKFILLED" {
|
||||
t.Fatalf("backfill=%s", result.Code)
|
||||
}
|
||||
return s, loadBackfillTask(t, db, task.ID)
|
||||
}
|
||||
func loadOrderWriteback(t *testing.T, db *gorm.DB, id uint64) models.PurchaseOrderWriteback {
|
||||
t.Helper()
|
||||
var r models.PurchaseOrderWriteback
|
||||
if err := db.Where("purchase_task_id = ?", id).First(&r).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
func wbWorker(s *Service, f *fakeOrderNumberClient) *OrderWritebackWorker {
|
||||
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return f, nil }}
|
||||
}
|
||||
|
||||
func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fake fakeOrderNumberClient
|
||||
want string
|
||||
writes int
|
||||
}{
|
||||
{"write_and_verify", fakeOrderNumberClient{apply: true}, "succeeded", 1},
|
||||
{"same_value", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "pdd"}, "succeeded", 0},
|
||||
{"different_value", fakeOrderNumberClient{code: "OTHER", platform: "pdd"}, "conflict", 0},
|
||||
{"different_platform", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "other"}, "conflict", 0},
|
||||
{"empty_code_other_platform", fakeOrderNumberClient{platform: "other"}, "conflict", 0},
|
||||
{"unknown_applied", fakeOrderNumberClient{apply: true, writeErr: sybclient.ErrWriteResultUnknown}, "succeeded", 1},
|
||||
{"unknown_unapplied", fakeOrderNumberClient{writeErr: sybclient.ErrWriteResultUnknown}, "unknown", 1},
|
||||
{"success_not_visible", fakeOrderNumberClient{}, "unknown", 1},
|
||||
{"explicit_rejection", fakeOrderNumberClient{writeErr: sybclient.ErrSessionInvalid}, "failed", 1},
|
||||
{"read_failure", fakeOrderNumberClient{readErr: errors.New("offline")}, "failed", 0},
|
||||
{"ambiguous_target", fakeOrderNumberClient{duplicate: true}, "failed", 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := tc.fake
|
||||
w := wbWorker(s, &f)
|
||||
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
if row.Status != tc.want || f.writes != tc.writes {
|
||||
t.Fatalf("status=%s writes=%d", row.Status, f.writes)
|
||||
}
|
||||
if _, err := w.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.writes != tc.writes {
|
||||
t.Fatal("automatically repeated write")
|
||||
}
|
||||
after := loadBackfillTask(t, s.DB, task.ID)
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
||||
t.Fatal("changed purchase/payment/logistics facts")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackRecoveryNeverBlindWrites(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
expired := s.Now().Add(-time.Minute)
|
||||
if err := s.DB.Model(&row).Updates(map[string]any{"status": "running", "write_started": true, "lease_owner": "crashed", "lease_expires_at": expired}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || f.writes != 0 {
|
||||
t.Fatal("crash recovery wrote remotely")
|
||||
}
|
||||
// Manual compensation remains disabled while the previous lease could be alive.
|
||||
r, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || r.Items[0].Result != "skipped" {
|
||||
t.Fatalf("in-flight manual retry: %v %+v", err, r)
|
||||
}
|
||||
s.Now = func() time.Time { return expired.Add(5 * time.Minute) }
|
||||
r, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || r.Items[0].Result != "pending" {
|
||||
t.Fatal("manual compensation rejected")
|
||||
}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "succeeded" || f.writes != 1 {
|
||||
t.Fatal("manual compensation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackBatchReplayAndPartialAcceptance(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
req := OrderWritebackRequest{uuid.NewString(), []uint64{task.ID, 9999}}
|
||||
first, err := s.RequestOrderWriteback(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Items[0].Result != "pending" || first.Items[1].Result != "skipped" {
|
||||
t.Fatalf("%+v", first)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := s.RequestOrderWriteback(context.Background(), req)
|
||||
if err != nil || again.Items[0].Result != "pending" || f.writes != 1 {
|
||||
t.Fatal("replay changed acceptance or wrote")
|
||||
}
|
||||
req.PurchaseTaskIDs = []uint64{task.ID}
|
||||
if _, err = s.RequestOrderWriteback(context.Background(), req); err == nil {
|
||||
t.Fatal("changed requestId content accepted")
|
||||
}
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || views[task.ID].Status != "succeeded" || views[task.ID].CanSubmit {
|
||||
t.Fatal("incorrect admin view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackGlobalClaimSerializesWriters(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
other := &fakeOrderNumberClient{apply: true}
|
||||
f.beforeWrite = func() {
|
||||
if ok, err := wbWorker(s, other).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("parallel claim %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.writes != 1 || other.writes != 0 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
||||
t.Fatal("concurrent writer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if err := s.DB.Where("purchase_task_id = ?", task.ID).Delete(&models.PurchaseOrderWriteback{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("rollback")
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if e := ensureOrderWriteback(tx, task); e != nil {
|
||||
return e
|
||||
}
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
s.DB.Model(&models.PurchaseOrderWriteback{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("queue escaped transaction")
|
||||
}
|
||||
var dev models.AgentDevice
|
||||
s.DB.First(&dev, *task.DeviceID)
|
||||
// Directly exercise the already_backfilled branch without needing a raw token.
|
||||
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
|
||||
if r.Result != "already_backfilled" {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "pending" {
|
||||
t.Fatal("same-order replay did not ensure queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackAdminEnvelopeAndOperator(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s, task := orderWritebackFixture(t)
|
||||
for _, role := range []string{"admin", "purchaser"} {
|
||||
body, _ := json.Marshal(OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
r := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(r)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/syb-order-writeback", strings.NewReader(string(body)))
|
||||
(Handler{DB: s.DB}).AdminOrderWriteback(c)
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data OrderWritebackResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &response); err != nil || r.Code != 200 || response.Code != 200 || len(response.Data.Items) != 1 || response.Data.Items[0].Result != "pending" {
|
||||
t.Fatalf("admin contract rejected role=%s status=%d", role, r.Code)
|
||||
}
|
||||
if r.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("missing cache policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackOtherUnknownTargetBlocksNewWrite(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
other := models.PurchaseOrderWriteback{PurchaseTaskID: task.ID + 100, StockID: 2, DetailID: 1, OrderNo: "OTHER", Status: "unknown", WriteStarted: true}
|
||||
if err := s.DB.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || got.ErrorCode != "SYB_TARGET_IN_FLIGHT" || f.writes != 0 {
|
||||
t.Fatal("another unresolved target was overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSnapshotChangeDoesNotWrite(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if err := s.DB.Model(&task).Update("pdd_order_no", "CHANGED").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "conflict" || f.writes != 0 {
|
||||
t.Fatal("changed snapshot written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type OrderNumberClient interface {
|
||||
DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error)
|
||||
UpdateDetailPurchaseCode(context.Context, int64, int64, string) error
|
||||
}
|
||||
type OrderWritebackWorker struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
|
||||
}
|
||||
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// One short-lived claim at a time across processes. No business writes occur
|
||||
// during startup itself; only explicitly persisted pending records are handled.
|
||||
func RecoverOrderWritebacks(db *gorm.DB) {
|
||||
go func() {
|
||||
w := OrderWritebackWorker{DB: db, Now: func() time.Time { return time.Now().UTC() }, Factory: restoreOrderWritebackClient}
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _ = w.RunOnce(context.Background())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if w.Now == nil {
|
||||
w.Now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if w.Factory == nil {
|
||||
w.Factory = restoreOrderWritebackClient
|
||||
}
|
||||
db := w.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
owner := uuid.NewString()
|
||||
now := w.Now()
|
||||
expires := now.Add(2 * time.Minute)
|
||||
claim := db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND (expires_at IS NULL OR expires_at <= ?)", now).Updates(map[string]any{"owner": owner, "expires_at": expires})
|
||||
if claim.Error != nil {
|
||||
return false, claim.Error
|
||||
}
|
||||
if claim.RowsAffected != 1 {
|
||||
return false, nil
|
||||
}
|
||||
defer db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND owner = ?", owner).Updates(map[string]any{"owner": "", "expires_at": nil})
|
||||
var item models.PurchaseOrderWriteback
|
||||
recovering := false
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recovering = item.Status == "running"
|
||||
return tx.Model(&item).Updates(map[string]any{"status": "running", "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": owner, "lease_expires_at": expires}).Error
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
finish := func(status, code, message string) error {
|
||||
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
|
||||
if status != "unknown" {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
if status == "succeeded" {
|
||||
updates["completed_at"] = w.Now()
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
|
||||
}
|
||||
var syb models.SYBProduct
|
||||
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
|
||||
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
|
||||
}
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
client, err := w.Factory(callCtx, db)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
|
||||
}
|
||||
read := func() (string, string, error) {
|
||||
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer stop()
|
||||
rows, e := client.DetailListByStock(readCtx, []int64{item.StockID})
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
found := 0
|
||||
code, platform := "", ""
|
||||
for _, stock := range rows {
|
||||
if stock.ID != item.StockID {
|
||||
continue
|
||||
}
|
||||
for _, detail := range stock.Details {
|
||||
if detail.ID == item.DetailID {
|
||||
found++
|
||||
var ok bool
|
||||
code, ok = detail.Raw["purchaseCode"].(string)
|
||||
if !ok && detail.Raw["purchaseCode"] != nil {
|
||||
return "", "", errors.New("invalid purchase code")
|
||||
}
|
||||
platform, ok = detail.Raw["purchasePlatform"].(string)
|
||||
if !ok && detail.Raw["purchasePlatform"] != nil {
|
||||
return "", "", errors.New("invalid platform")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
return "", "", errors.New("ambiguous remote detail")
|
||||
}
|
||||
return code, platform, nil
|
||||
}
|
||||
code, platform, err := read()
|
||||
if err != nil {
|
||||
status := "failed"
|
||||
if recovering || item.WriteStarted {
|
||||
status = "unknown"
|
||||
}
|
||||
return true, finish(status, "SYB_READ_FAILED", "无法回读SYB目标明细,请恢复连接后重试")
|
||||
}
|
||||
if code == item.OrderNo && platform == "pdd" {
|
||||
return true, finish("succeeded", "", "")
|
||||
}
|
||||
if code != "" || (platform != "" && platform != "pdd") {
|
||||
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB已有不同单号或平台,未覆盖,请人工核对")
|
||||
}
|
||||
if recovering || item.WriteStarted {
|
||||
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "上次写入结果未确认,未自动重发,请人工核对后重试")
|
||||
}
|
||||
var blocked int64
|
||||
if err = db.Model(&models.PurchaseOrderWriteback{}).Where("id <> ? AND stock_id = ? AND detail_id = ? AND write_started = ? AND status IN ?", item.ID, item.StockID, item.DetailID, true, []string{"running", "unknown"}).Count(&blocked).Error; err != nil {
|
||||
return true, err
|
||||
}
|
||||
if blocked > 0 {
|
||||
return true, finish("unknown", "SYB_TARGET_IN_FLIGHT", "同一SYB明细有其他未确认写入,请先核对该记录")
|
||||
}
|
||||
// Fence immediately before the sole external write. A recovered owner never
|
||||
// writes; a durable marker survives crashes between request and acknowledgment.
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var lease models.PurchaseOrderWritebackLease
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&lease, 1).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if lease.Owner != owner || lease.ExpiresAt == nil || !lease.ExpiresAt.After(w.Now().Add(25*time.Second)) {
|
||||
return errors.New("write lease lost")
|
||||
}
|
||||
r := tx.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND lease_owner = ? AND status = 'running'", item.ID, owner).Update("write_started", true)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return errors.New("item lease lost")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
writeCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
writeErr := client.UpdateDetailPurchaseCode(writeCtx, item.StockID, item.DetailID, item.OrderNo)
|
||||
stop()
|
||||
code, platform, err = read()
|
||||
if err == nil && code == item.OrderNo && platform == "pdd" {
|
||||
return true, finish("succeeded", "", "")
|
||||
}
|
||||
if err == nil && (code != "" || (platform != "" && platform != "pdd")) {
|
||||
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB单号或平台与目标不一致,未覆盖")
|
||||
}
|
||||
if writeErr != nil && !errors.Is(writeErr, sybclient.ErrWriteResultUnknown) {
|
||||
return true, finish("failed", "SYB_WRITE_REJECTED", "SYB拒绝回填,请检查会话与明细后重试")
|
||||
}
|
||||
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "写入后尚未回读确认,请人工核对后重试")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -31,6 +32,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
|
||||
admin.POST("/batch", h.AdminBatchCreate)
|
||||
admin.POST("/batch-retry", h.AdminBatchRetry)
|
||||
admin.POST("/syb-order-writeback", h.AdminOrderWriteback)
|
||||
admin.POST("/stock", h.AdminCreateStock)
|
||||
admin.GET("/:taskId", h.AdminDetail)
|
||||
admin.POST("", h.AdminCreate)
|
||||
|
||||
@@ -289,6 +289,10 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued := loadOrderWriteback(t, db, task.ID)
|
||||
if queued.Status != "pending" || queued.OrderNo != "PDD-1" {
|
||||
t.Fatalf("order result did not enqueue writeback: status=%s", queued.Status)
|
||||
}
|
||||
replay, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||||
if err != nil || !replay.Replayed {
|
||||
t.Fatalf("result replay failed: %+v %v", replay, err)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -190,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 未给出可靠建议,请人工选择"
|
||||
@@ -252,6 +283,17 @@ func deterministicColorMatch(value string, candidates []string) string {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// UpdateDetailPurchaseCode sends exactly one write. No payment or amount is
|
||||
// inferred: cost=0 and created="" are the confirmed SYB protocol constants.
|
||||
func (c *Client) UpdateDetailPurchaseCode(ctx context.Context, stockID, detailID int64, code string) error {
|
||||
if stockID <= 0 || detailID <= 0 || strings.TrimSpace(code) == "" || code != strings.TrimSpace(code) || utf8.RuneCountInString(code) > 100 || strings.ContainsAny(code, "\r\n\t") {
|
||||
return fmt.Errorf("采购单号回填参数无效")
|
||||
}
|
||||
// A 307/308 redirect must not replay a mutation or forward credentials.
|
||||
client := *c
|
||||
httpClient := *c.http
|
||||
httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
client.http = &httpClient
|
||||
_, err := client.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailPurchaseCode", url.Values{
|
||||
"id": {strconv.FormatInt(stockID, 10)}, "detailId": {strconv.FormatInt(detailID, 10)},
|
||||
"code": {code}, "type": {"pdd"}, "created": {""}, "cost": {"0"},
|
||||
}, nil)
|
||||
return classifyInnerCodeWriteError(err)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPurchaseCodeSingleWriteContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
status int
|
||||
unknown bool
|
||||
}{
|
||||
{"success", `{"status":true,"data":null}`, 200, false},
|
||||
{"business", `{"status":false,"msg":"rejected"}`, 200, false},
|
||||
{"login", `{"status":false,"msg":"未登录"}`, 200, false},
|
||||
{"bad_json", `broken`, 200, true},
|
||||
{"server_error", ``, 502, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
q := r.URL.Query()
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if r.Method != "POST" || r.URL.Path != "/am/stock/detail/updateDetailPurchaseCode" || q.Get("id") != "2" || q.Get("detailId") != "1" || q.Get("code") != "EXAMPLE-ORDER" || q.Get("type") != "pdd" || q.Get("cost") != "0" || !q.Has("created") || q.Get("created") != "" || len(q) != 6 || len(body) != 0 {
|
||||
t.Error("invalid contract")
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER")
|
||||
if calls != 1 || errors.Is(err, ErrWriteResultUnknown) != tc.unknown {
|
||||
t.Fatalf("calls=%d unknown=%v", calls, errors.Is(err, ErrWriteResultUnknown))
|
||||
}
|
||||
if (err == nil) != (tc.name == "success") {
|
||||
t.Fatal("unexpected outcome")
|
||||
}
|
||||
if e := c.UpdateDetailPurchaseCode(context.Background(), 0, 1, "EXAMPLE-ORDER"); e == nil || calls != 1 {
|
||||
t.Fatal("invalid request sent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseCodeDoesNotFollowRedirect(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
http.Redirect(w, r, "/again", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := New(srv.URL)
|
||||
if err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER"); err == nil || calls != 1 {
|
||||
t.Fatalf("redirect must not repeat write: calls=%d", calls)
|
||||
}
|
||||
}
|
||||
@@ -100,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,107 +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.
|
||||
|
||||
// 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 剥离备注后为空"}
|
||||
}
|
||||
if explicitSizePattern.MatchString(size) {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
secondPart := stripBrackets(trimmed[lastComma+1:])
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
only := colorPart
|
||||
if only == "" {
|
||||
only = sizePart
|
||||
}
|
||||
if explicitSizePattern.MatchString(only) {
|
||||
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if only != "" {
|
||||
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
|
||||
}
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,17 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybspec"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// resyncShopeeProductKeys recomputes the purchase keys for every detail of one
|
||||
// Shopee product, including the one being imported, and writes back the
|
||||
// siblings whose keys changed (#301).
|
||||
//
|
||||
// `[必须]` 键的消歧需要同组全部原始规格,单条明细判断不了自己是否安全。新明细
|
||||
// 的到来可能让一个原本安全的键变成歧义——`黑色【短袖】` 独自存在时键就是 `黑色`,
|
||||
// 等 `黑色【長袖】` 进来才需要拆开。因此这里必须把兄弟明细一起重算并回写,否则
|
||||
// 同组内会出现一半旧键一半新键,比塌缩本身更难排查。
|
||||
func resyncShopeeProductKeys(tx *gorm.DB, shopeeProductID uint64, incomingRaw string) (sybspec.ParseResult, error) {
|
||||
var siblings []models.SYBProduct
|
||||
if err := tx.Where("shopee_product_id = ?", shopeeProductID).Find(&siblings).Error; err != nil {
|
||||
return sybspec.ParseResult{}, err
|
||||
}
|
||||
|
||||
raws := []string{incomingRaw}
|
||||
rawByID := make(map[uint64]string, len(siblings))
|
||||
for _, sibling := range siblings {
|
||||
raw, ok := productSpecOf(sibling.RawJSON)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawByID[sibling.ID] = raw
|
||||
raws = append(raws, raw)
|
||||
}
|
||||
|
||||
resolved := sybspec.ResolveKeys(raws)
|
||||
|
||||
for _, sibling := range siblings {
|
||||
raw, ok := rawByID[sibling.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keys := resolved[raw]
|
||||
// `[必须]` 不碰人工或 AI 已确认的明细。它们的键是人(或经人确认的 AI)
|
||||
// 定下的,消歧无权推翻——与 Reparse 不带 force 时跳过它们是同一条规则。
|
||||
// 解析未成功的行同理,它们的键不由本函数决定。
|
||||
if sibling.ParseStatus != models.SYBParseStatusSuccess ||
|
||||
sibling.ManuallyConfirmed || sibling.AIConfirmed {
|
||||
continue
|
||||
}
|
||||
if sibling.TargetColor != keys.Color || sibling.TargetSize != keys.Size {
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sibling.ID).
|
||||
Updates(map[string]any{"target_color": keys.Color, "target_size": keys.Size}).Error; err != nil {
|
||||
return sybspec.ParseResult{}, err
|
||||
}
|
||||
}
|
||||
// `[必须]` 无条件把键补进档案,不能只在键发生变化时补。映射是按档案里的
|
||||
// 值建的,档案缺了 `黑色【長袖】` 这个条目,采购查映射就查不到,明细永远
|
||||
// 停在「规格待匹配」——而采购员点「一键匹配」匹到的是档案里剩下的旧键
|
||||
// `黑色`,看起来成功了却没有任何明细在用它(线上 2026-09-17 实际发生)。
|
||||
//
|
||||
// 只在「键变化」时补是不够的:既有数据的键早已是对的,档案却是空的,
|
||||
// 那样永远补不上。无条件合并让这条路径可以自愈,mergeParsedSpec 本身
|
||||
// 对已存在的值是幂等的。
|
||||
if err := mergeParsedSpec(tx, shopeeProductID, keys); err != nil {
|
||||
return sybspec.ParseResult{}, err
|
||||
}
|
||||
}
|
||||
return resolved[incomingRaw], nil
|
||||
}
|
||||
|
||||
func productSpecOf(rawJSON string) (string, bool) {
|
||||
var payload struct {
|
||||
ProductSpec string `json:"productSpec"`
|
||||
}
|
||||
if json.Unmarshal([]byte(rawJSON), &payload) != nil {
|
||||
return "", false
|
||||
}
|
||||
return payload.ProductSpec, payload.ProductSpec != ""
|
||||
}
|
||||
|
||||
// rawProductSpecOf extracts the productSpec from an already-normalised raw JSON
|
||||
// payload, returning an empty string when it is absent.
|
||||
func rawProductSpecOf(rawJSON string) string {
|
||||
raw, _ := productSpecOf(rawJSON)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
func sleeveDetail(id uint64, spec string) sybimport.DetailInput {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"id": id, "productQty": 1, "productPrice": 100.0, "productThumb": 1,
|
||||
"productTitle": "襯衫", "productSpec": spec, "productId": 24377106095,
|
||||
})
|
||||
return sybimport.DetailInput{
|
||||
ID: id, ProductID: 24377106095, ProductQty: 1, ProductPrice: 100.0,
|
||||
ProductSpec: spec, ProductTitle: "襯衫", ProductThumb: 1, Raw: raw,
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 消歧改了明细的键,就必须把新键补进档案。映射是按档案里的值建的,
|
||||
// 档案没有 `黑色【長袖】` 这个条目,采购查映射就查不到,明细永远停在「规格待匹配」;
|
||||
// 而采购员点「一键匹配」匹到的是档案里剩下的旧键 `黑色`,看起来成功了却没有任何
|
||||
// 明细在用它——线上 2026-09-17 正是这样(#301)。
|
||||
func TestDisambiguatedKeysReachTheArchive(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, order,
|
||||
sleeveDetail(900001, "黑色【短袖】,M 【建議42.5-47.5公斤】")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := sybimport.ApplyDetail(context.Background(), db, order,
|
||||
sleeveDetail(900002, "黑色【長袖】,5XL 【建議72.5-77.5公斤】"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.SYBProduct.ShopeeProductID == nil {
|
||||
t.Fatal("未关联虾皮商品")
|
||||
}
|
||||
|
||||
var product models.ShopeeProduct
|
||||
if err := db.First(&product, *second.SYBProduct.ShopeeProductID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archived := map[string]bool{}
|
||||
for _, dimension := range specs {
|
||||
for _, value := range dimension.Values {
|
||||
archived[value.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 两个明细的键都必须能在档案里找到,否则映射无从建立。
|
||||
for _, want := range []string{"黑色【短袖】", "黑色【長袖】"} {
|
||||
if !archived[want] {
|
||||
t.Fatalf("档案缺少键 %q,现有键: %v", want, archived)
|
||||
}
|
||||
}
|
||||
|
||||
// 明细自己的键也必须是拆开后的形式。
|
||||
var rows []models.SYBProduct
|
||||
if err := db.Where("shopee_product_id = ?", *second.SYBProduct.ShopeeProductID).Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.TargetColor != "黑色【短袖】" && row.TargetColor != "黑色【長袖】" {
|
||||
t.Fatalf("明细 %d 的键未消歧: %q", row.ID, row.TargetColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,12 @@ func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
}{
|
||||
{"新寮-雅伊阁大码女装#301", "char"},
|
||||
{"广达-爆款大码女装#9018", "char"},
|
||||
{"DD#004", "char"},
|
||||
{"300斤牛奶絲圓領#A057", "char"},
|
||||
{"上寮-琪琪尚雅网批#2604##(&15)", "char"},
|
||||
// 只含 # 不含 -,#286 起不再被结构过滤。
|
||||
{"DD#004", ""},
|
||||
// 同样不再被结构过滤,但落到了关键词规则上——#269 把关键词称为
|
||||
// "forward safety net",这里正是它第一次真的接住东西。
|
||||
{"300斤牛奶絲圓領#A057", "keyword"},
|
||||
{"PDD0582", ""},
|
||||
{"my238", ""},
|
||||
{"le171", ""},
|
||||
@@ -51,7 +54,9 @@ func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
kwN++
|
||||
}
|
||||
}
|
||||
if charN != 5 || kwN != 0 || keptN != 6 {
|
||||
// #286:结构过滤由或改与,5 条 char 命中变为 3 条;其中一条转由关键词接住,
|
||||
// 另一条重新入库。
|
||||
if charN != 3 || kwN != 1 || keptN != 7 {
|
||||
t.Fatalf("统计不符:char=%d keyword=%d kept=%d", charN, kwN, keptN)
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
@@ -68,17 +73,32 @@ func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
}
|
||||
got[x.Keyword] = *x.LastHitCount
|
||||
}
|
||||
// "#" 命中 4 条(301/9018/DD#004/A057 ... 其中带 - 的先被 "-" 规则接走)
|
||||
if got["-"]+got["#"] != 5 {
|
||||
t.Fatalf("两条结构规则命中数之和应为 5,实际 - =%d # =%d", got["-"], got["#"])
|
||||
// #286:结构过滤只剩一条必需字符集合 `-#`,应记下 3 条命中。
|
||||
if got["-#"] != 3 {
|
||||
t.Fatalf("结构规则命中数应为 3,实际 %d", got["-#"])
|
||||
}
|
||||
if got["-"] == got["#"] {
|
||||
t.Fatalf("两条规则不应记成同一个数(这正是 #269 修的 bug): - =%d # =%d", got["-"], got["#"])
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("结构过滤应当只有一条,实际 %v", got)
|
||||
}
|
||||
// 关键词仍然各记各的(#269 修的那个 bug 不得回退):
|
||||
// 300斤牛奶絲圓領 这条接住了 1 条,其余关键词应为 0。
|
||||
kwHits := map[string]int{}
|
||||
for _, x := range after.Keywords {
|
||||
if x.LastHitCount == nil || *x.LastHitCount != 0 {
|
||||
t.Fatalf("未命中的关键词 %q 应记 0,实际 %v", x.Keyword, x.LastHitCount)
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("关键词 %q 必须被写入命中数", x.Keyword)
|
||||
}
|
||||
kwHits[x.Keyword] = *x.LastHitCount
|
||||
}
|
||||
if kwHits["300斤牛奶絲圓領"] != 1 {
|
||||
t.Fatalf("关键词 300斤牛奶絲圓領 应接住 1 条,实际 %d", kwHits["300斤牛奶絲圓領"])
|
||||
}
|
||||
for k, n := range kwHits {
|
||||
if k == "300斤牛奶絲圓領" {
|
||||
continue
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("未命中的关键词 %q 应记 0,实际 %d", k, n)
|
||||
}
|
||||
}
|
||||
t.Logf("结构规则命中:- =%d # =%d", got["-"], got["#"])
|
||||
t.Logf("结构规则 -# 命中 %d;关键词命中 %v", got["-#"], kwHits)
|
||||
}
|
||||
|
||||
@@ -198,22 +198,44 @@ func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
|
||||
// It returns the matching rule rather than a pair of booleans because the
|
||||
// caller has to attribute the hit to one specific rule: every rule keeps its
|
||||
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
|
||||
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
|
||||
// char/keyword booleans would force every rule of a kind to share one
|
||||
// aggregate, and the dialog would then warn with the wrong figure — on real
|
||||
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
|
||||
// reported as the same number.
|
||||
// number. Collapsing the result to char/keyword booleans would force every
|
||||
// rule of a kind to share one aggregate, and the dialog would then warn with
|
||||
// the wrong figure.
|
||||
//
|
||||
// 自 #286 起结构过滤只有一条(必需字符集合 `-#`),但按规则记数的机制保持不变:
|
||||
// 关键词规则仍然各自独立计数。
|
||||
//
|
||||
// Structural rules are checked before keyword rules so a hit is attributed the
|
||||
// way the page presents the two sections, and an empty variationSku never
|
||||
// matches: it is a product with no supplier code yet, not a 档口 item.
|
||||
// containsAllRunes reports whether every rune of set appears somewhere in v.
|
||||
//
|
||||
// `[必须]` 这是结构过滤与关键词过滤的关键差别。关键词是子串匹配;结构过滤的
|
||||
// keyword 存的是**必需字符集合**,`-#` 表示「同时包含 - 和 #」才算命中(#286)。
|
||||
// 原先两条 char 规则各自做 strings.Contains,是或关系:线上最近一次同步中
|
||||
// `-` 命中 384、`#` 命中 148,合计 532 条被过滤;改为与关系后只有两者都含的
|
||||
// 才过滤,其余重新入库。
|
||||
//
|
||||
// 空集合永远不命中——否则一条空规则会过滤掉所有商品。
|
||||
func containsAllRunes(v string, set string) bool {
|
||||
if set == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range set {
|
||||
if !strings.ContainsRune(v, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r RuleSet) Match(v string) *models.SYBProductFilter {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n := sybshop.Normalize(v)
|
||||
for i, x := range r.Chars {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
if containsAllRunes(n, x.NormalizedKeyword) {
|
||||
return &r.Chars[i]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,25 +21,35 @@ func testDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMatchORAndNormalization(t *testing.T) {
|
||||
func TestMatchRequiresEveryStructuralCharacter(t *testing.T) {
|
||||
r := RuleSet{
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-#"}},
|
||||
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
|
||||
}
|
||||
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
|
||||
// production data that difference is 2118 rows (#269).
|
||||
|
||||
// `[必须]` AND, not OR. 结构过滤的 keyword 存的是**必需字符集合**:`-#` 表示
|
||||
// 同时包含 - 和 # 才过滤(#286)。#269 时是两条规则各自 Contains 的或关系,
|
||||
// 线上最近一次同步里 `-` 命中 384、`#` 命中 148,合计 532 条被过滤;改成与
|
||||
// 关系后只剩两者都含的会被过滤,其余重新入库——这是本次改动的全部意义,
|
||||
// 任何把它退回或关系的改动都必须先推翻这条用例。
|
||||
if hit := r.Match("新寮-雅伊阁#A057"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("同时含 - 和 # 的应当命中结构过滤,got %v", hit)
|
||||
}
|
||||
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
|
||||
hit := r.Match(v)
|
||||
if hit == nil || hit.Kind != "char" {
|
||||
t.Fatalf("%q should match a structural rule, got %v", v, hit)
|
||||
}
|
||||
if hit.ID != 2 {
|
||||
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
|
||||
if hit := r.Match(v); hit != nil {
|
||||
t.Fatalf("%q 只含 #,不应再被结构过滤,got %v", v, hit)
|
||||
}
|
||||
}
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit != nil {
|
||||
t.Fatalf("只含 - 的不应再被结构过滤,got %v", hit)
|
||||
}
|
||||
|
||||
// 顺序无关:字符集合只要求都出现,不要求相邻或先后。
|
||||
if hit := r.Match("A#B-C"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("# 在 - 之前也应命中,got %v", hit)
|
||||
}
|
||||
|
||||
// 以下是 #269 定下、本次不变的规则。
|
||||
if hit := r.Match(""); hit != nil {
|
||||
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
|
||||
}
|
||||
@@ -57,6 +67,35 @@ func TestMatchORAndNormalization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词过滤仍然是子串匹配,不受结构过滤改为字符集合的影响。
|
||||
func TestKeywordFilterStaysSubstringMatching(t *testing.T) {
|
||||
r := RuleSet{Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "印花帽t"}}}
|
||||
if hit := r.Match("秋冬印花帽T加绒"); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("关键词应按子串匹配,got %v", hit)
|
||||
}
|
||||
// 字符集合语义若误用到关键词上,下面这个会被判成命中(每个字都出现过)。
|
||||
if hit := r.Match("印花衬衫 帽子 t恤"); hit != nil {
|
||||
t.Fatalf("关键词不是字符集合,不应命中,got %v", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 全新数据库的种子必须与迁移后的形态一致。migrations.Migrate 每次启动
|
||||
// 都会 FirstOrCreate 这批种子,若仍播种旧的两条 `-` 和 `#`,就会把迁移
|
||||
// 1789500000000 合并掉的行重新建回来,过滤静默退回或关系(#286)。
|
||||
func TestFreshDatabaseSeedsOneStructuralRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.Where("kind = ?", "char").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("结构过滤应当只有一条,实际 %d 条: %+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Keyword != "-#" {
|
||||
t.Fatalf("结构过滤的必需字符集合应为 -#,实际 %q", rows[0].Keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
|
||||
// aggregate per kind would make the disable-confirmation dialog quote the same
|
||||
// figure for "#" and "-" even though they match very different numbers of rows.
|
||||
@@ -67,8 +106,9 @@ func TestUpdateHitsIsPerRule(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(set.Chars) != 2 {
|
||||
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
|
||||
// #286:结构过滤合并为一条必需字符集合 `-#`。
|
||||
if len(set.Chars) != 1 {
|
||||
t.Fatalf("migration should seed 1 structural rule, got %d", len(set.Chars))
|
||||
}
|
||||
byKeyword := map[string]uint64{}
|
||||
for _, x := range set.Chars {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package sybspec owns the productSpec parsing primitives shared by the SYB
|
||||
// import and the purchase readiness check.
|
||||
//
|
||||
// `[必须]` 它必须是叶子包。拆分、剥离、角色反转这三件事既决定了 target_color/
|
||||
// target_size,也决定了哪些原始规格会塔缩到同一个键(#289)。两边各写一份
|
||||
// 镜像实现必然漂移,到时塔缩检测会拿错半边去比,结论反而不可信。
|
||||
//
|
||||
// 原属 sybimport;purchase 需要它而 sybimport 已依赖 purchase,直接引用会成环,
|
||||
// 因此下沉。sybimport 保留别名转发,调用方无需改动。
|
||||
package sybspec
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
|
||||
|
||||
// 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))$`)
|
||||
|
||||
// splitOnWhitespace applies the comma rule to a spec that has no comma.
|
||||
//
|
||||
// `[必须]` #274 removed the old ambiguity guard so that descriptive colors
|
||||
// like "黑色+白色 簡約親膚" are accepted instead of rejected. That is right, but
|
||||
// it also let "黑色 XL" through as one confident **color**, dropping the size and
|
||||
// marking the row success — and success rows never reach the AI parse queue
|
||||
// (ai_parse_batch.go selects parse_status IN ('uncertain','failed')), so nothing
|
||||
// ever corrects them. 152 such rows existed in production on 2026-09-15.
|
||||
//
|
||||
// Whitespace is therefore treated exactly like the comma: split only when
|
||||
// **exactly one** whitespace-delimited token carries an explicit size signal.
|
||||
// That is the same non-guessing rule the comma branch already uses, so
|
||||
// "黑色 XL" recovers as color+size while "黑色+白色 簡約親膚" (no size token)
|
||||
// keeps #274's descriptive-color behaviour untouched.
|
||||
func splitOnWhitespace(value string) (color string, size string, ok bool) {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
sizeIndex := -1
|
||||
for i, field := range fields {
|
||||
if explicitSizePattern.MatchString(field) {
|
||||
if sizeIndex >= 0 {
|
||||
// More than one size-looking token: no safe colour decision,
|
||||
// same as the comma branch's both-sides-are-size case.
|
||||
return "", "", false
|
||||
}
|
||||
sizeIndex = i
|
||||
}
|
||||
}
|
||||
if sizeIndex < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
remainder := append(append([]string{}, fields[:sizeIndex]...), fields[sizeIndex+1:]...)
|
||||
color = strings.Join(remainder, " ")
|
||||
if color == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return color, fields[sizeIndex], true
|
||||
}
|
||||
|
||||
// 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 -> a single token becomes size when it carries an
|
||||
// explicit size signal ("均碼") and colour otherwise (#274's descriptive
|
||||
// colours). Multiple whitespace-delimited tokens are split by the same
|
||||
// one-explicit-size rule the comma branch uses, so "黑色 XL" yields both
|
||||
// dimensions instead of one over-long colour (see splitOnWhitespace).
|
||||
// - 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 剥离备注后为空"}
|
||||
}
|
||||
if explicitSizePattern.MatchString(size) {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if color, sizeToken, ok := splitOnWhitespace(size); ok {
|
||||
return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
secondPart := stripBrackets(trimmed[lastComma+1:])
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
only := colorPart
|
||||
if only == "" {
|
||||
only = sizePart
|
||||
}
|
||||
if explicitSizePattern.MatchString(only) {
|
||||
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if color, sizeToken, ok := splitOnWhitespace(only); ok {
|
||||
return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
if only != "" {
|
||||
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
|
||||
}
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
|
||||
// StripAnnotations removes the 【...】 annotations exactly the way Parse does to
|
||||
// each half of a productSpec.
|
||||
//
|
||||
// `[必须]` 给虞皮完整规格清单用。接口返回的是虞皮原文,而档案里的键来自
|
||||
// SYB 明细经 Parse 剥离后的结果。两者必须用同一套规则,否则 confirmedMappings
|
||||
// 查不到,映射全部落空(#290)。
|
||||
//
|
||||
// 只能用剥离,不能用 Parse 拼一个假逗号去跑:单独的半边会走进无逗号分支,
|
||||
// 被空格规则再拆一次("2XL 60.0-67.5公斤" 会变成 "2XL"),与真实流程不同。
|
||||
func StripAnnotations(part string) string { return stripBrackets(part) }
|
||||
|
||||
func stripBrackets(part string) string {
|
||||
return strings.TrimSpace(bracketPattern.ReplaceAllString(part, ""))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sybspec
|
||||
|
||||
import "strings"
|
||||
|
||||
// RawSpecHalves returns the productSpec text that produced Parse's Color and
|
||||
// Size, with the 【...】 annotations still in place.
|
||||
//
|
||||
// `[必须]` 必须和 Parse 放在同一个包并镜像它的拆分与角色反转逻辑。两者一旦漂移,
|
||||
// 塌缩检测就会拿错半边去比较,结论反而不可信(#289)。
|
||||
//
|
||||
// 用途是检出塌缩:Parse 会剥掉 【...】,而括号里经常是真正的规格标识(款号
|
||||
// 白色【207A】、色号 黑色【M0059C1】、颜色组合 【深灰+淺灰】)。剥离后不同的
|
||||
// 虾皮规格会塌缩成同一个 target_color,而 target_color 正是采购查映射的键,
|
||||
// 同键即同映射,Agent 会为不同规格点击同一个 PDD 值。
|
||||
//
|
||||
// 返回空串表示该半边不存在(例如 productSpec 没有逗号时只有一个半边)。
|
||||
func RawSpecHalves(raw string) (rawColor, rawSize string) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", ""
|
||||
}
|
||||
parsed := Parse(raw)
|
||||
|
||||
lastComma := strings.LastIndex(trimmed, ",")
|
||||
if lastComma < 0 {
|
||||
// 无逗号:整串要么是尺码、要么是颜色,要么被空格规则拆成两半。
|
||||
// 拆开的情况下两半都来自同一串原文,没有各自独立的原文可比,
|
||||
// 因此把整串同时作为两边的原文返回:同一串原文不会与自己塌缩。
|
||||
if parsed.Color != "" && parsed.Size != "" {
|
||||
return trimmed, trimmed
|
||||
}
|
||||
if parsed.Size != "" {
|
||||
return "", trimmed
|
||||
}
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
first := strings.TrimSpace(trimmed[:lastComma])
|
||||
second := strings.TrimSpace(trimmed[lastComma+1:])
|
||||
|
||||
// Parse 在“恰好一侧带明确尺码特征”时会交换两半的角色,这里必须跟着换,
|
||||
// 否则会把尺码原文当成颜色原文去比。用剥离后的结果判断谁是谁。
|
||||
if parsed.Color != "" && stripBrackets(second) == parsed.Color {
|
||||
return second, first
|
||||
}
|
||||
return first, second
|
||||
}
|
||||
|
||||
// CollapsedSpecKey reports whether two different raw halves collapse onto the
|
||||
// same parsed value, i.e. the parsed value is not a safe key for that product.
|
||||
func CollapsedSpecKey(rawHalves []string) bool {
|
||||
seen := ""
|
||||
for _, half := range rawHalves {
|
||||
half = strings.TrimSpace(half)
|
||||
if half == "" {
|
||||
continue
|
||||
}
|
||||
if seen == "" {
|
||||
seen = half
|
||||
continue
|
||||
}
|
||||
if half != seen {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sybspec_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybspec"
|
||||
)
|
||||
|
||||
// 样本全部取自线上真实数据(2026-09-16 核对)。
|
||||
func TestRawSpecHalvesKeepsAnnotations(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, wantColor, wantSize string
|
||||
}{
|
||||
// 款号在颜色括号里:剥离后 6 个白色塌缩成一个键(虾皮 1355)。
|
||||
{"白色【207A】,XL", "白色【207A】", "XL"},
|
||||
{"黑色【M0059C1】,2XL", "黑色【M0059C1】", "2XL"},
|
||||
// 体重建议在尺码括号里:剥离正确,不塌缩(虾皮 2065)。
|
||||
// 这条同时覆盖角色反转——Parse 认出第二半才是颜色。
|
||||
{"S【建議40公斤以內】,圓領 彩藍色", "圓領 彩藍色", "S【建議40公斤以內】"},
|
||||
{"香芋紫 【雙梅花】純棉,2XL 60.0-67.5公斤", "香芋紫 【雙梅花】純棉", "2XL 60.0-67.5公斤"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
gotColor, gotSize := sybspec.RawSpecHalves(item.raw)
|
||||
if gotColor != item.wantColor || gotSize != item.wantSize {
|
||||
t.Fatalf("%q -> color=%q size=%q, want color=%q size=%q",
|
||||
item.raw, gotColor, gotSize, item.wantColor, item.wantSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 半边必须和 Parse 的输出对应。两者一旦漂移,塌缩检测会拿错半边去比。
|
||||
func TestRawSpecHalvesStayAlignedWithParse(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"白色【207A】,XL",
|
||||
"S【建議40公斤以內】,圓領 彩藍色",
|
||||
"黑色,2XL",
|
||||
"均碼",
|
||||
"黑色 XL",
|
||||
} {
|
||||
rawColor, rawSize := sybspec.RawSpecHalves(raw)
|
||||
parsed := sybspec.Parse(raw)
|
||||
if parsed.Color != "" && rawColor == "" {
|
||||
t.Fatalf("%q parsed a colour %q but reported no raw colour half", raw, parsed.Color)
|
||||
}
|
||||
if parsed.Size != "" && rawSize == "" {
|
||||
t.Fatalf("%q parsed a size %q but reported no raw size half", raw, parsed.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedSpecKey(t *testing.T) {
|
||||
// 虾皮 1355 的白色:6 个不同款号剥离后同键。
|
||||
if !sybspec.CollapsedSpecKey([]string{"白色【207A】", "白色【209A】"}) {
|
||||
t.Fatal("different raw halves must be reported as collapsed")
|
||||
}
|
||||
// 同一条规格重复出现(同商品多条订单)不算塌缩。
|
||||
if sybspec.CollapsedSpecKey([]string{"白色【207A】", "白色【207A】", "白色【207A】"}) {
|
||||
t.Fatal("repeats of one raw half are not a collapse")
|
||||
}
|
||||
if sybspec.CollapsedSpecKey([]string{"黑色"}) {
|
||||
t.Fatal("a single half cannot collapse")
|
||||
}
|
||||
if sybspec.CollapsedSpecKey(nil) {
|
||||
t.Fatal("no halves cannot collapse")
|
||||
}
|
||||
// 空串是“无原文可比”,不参与判定,不应与真实原文构成塌缩。
|
||||
if sybspec.CollapsedSpecKey([]string{"", "白色【207A】", ""}) {
|
||||
t.Fatal("blank halves must be ignored, not treated as a different spec")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package sybspec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ResolveKeys turns every raw productSpec of one Shopee product into its
|
||||
// purchase keys, keeping a 【...】 annotation only where dropping it would make
|
||||
// two different specs collide (#301).
|
||||
//
|
||||
// 存在的理由:stripBrackets 无差别剥离 【...】,而括号里经常是商品本身而不是备注
|
||||
// ——`黑色【短袖】` 与 `黑色【長袖】` 剥离后都成了 `黑色`,档案里只有一个条目,
|
||||
// 两个不同商品共用同一份映射,必然有一半买错。#289 因此拦截,但人工匹配救不了:
|
||||
// 坏的是键本身。
|
||||
//
|
||||
// `[必须]` 消歧必须看到同一虾皮商品下的全部原始规格,单条明细无法独立判断
|
||||
// `黑色` 是否安全。因此这里接收整组,而不是逐条处理。
|
||||
//
|
||||
// `[必须]` 不塌缩的键必须与今天的行为逐字一致。线上一万四千多条明细里绝大多数
|
||||
// 不涉及塌缩,本次改动不能波及它们。
|
||||
func ResolveKeys(raws []string) map[string]ParseResult {
|
||||
resolved := make(map[string]ParseResult, len(raws))
|
||||
colorRaws := map[string]map[string]bool{}
|
||||
sizeRaws := map[string]map[string]bool{}
|
||||
|
||||
for _, raw := range raws {
|
||||
parsed := Parse(raw)
|
||||
resolved[raw] = parsed
|
||||
rawColor, rawSize := RawSpecHalves(raw)
|
||||
collect(colorRaws, parsed.Color, rawColor)
|
||||
collect(sizeRaws, parsed.Size, rawSize)
|
||||
}
|
||||
|
||||
for raw, parsed := range resolved {
|
||||
rawColor, rawSize := RawSpecHalves(raw)
|
||||
if ambiguous(colorRaws, parsed.Color) {
|
||||
parsed.Color = normalizeKey(rawColor)
|
||||
}
|
||||
if ambiguous(sizeRaws, parsed.Size) {
|
||||
parsed.Size = normalizeKey(rawSize)
|
||||
}
|
||||
resolved[raw] = parsed
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func collect(index map[string]map[string]bool, key, rawHalf string) {
|
||||
key, rawHalf = strings.TrimSpace(key), normalizeKey(rawHalf)
|
||||
if key == "" || rawHalf == "" {
|
||||
return
|
||||
}
|
||||
if index[key] == nil {
|
||||
index[key] = map[string]bool{}
|
||||
}
|
||||
index[key][rawHalf] = true
|
||||
}
|
||||
|
||||
// ambiguous reports whether more than one distinct raw half collapses onto key.
|
||||
func ambiguous(index map[string]map[string]bool, key string) bool {
|
||||
return len(index[strings.TrimSpace(key)]) > 1
|
||||
}
|
||||
|
||||
// normalizeKey drops every space so `黑色 【短袖】` and `黑色【短袖】` become the
|
||||
// same key.
|
||||
//
|
||||
// `[必须]` 必须整体去空白,不能只是折叠成单个空格:SYB 对括号前的空格写法并不
|
||||
// 一致,同一个规格的两种写法若被当成两个不同的原始值,就会被误判为歧义,把本来
|
||||
// 安全的键也拆开。归一后的形式同时用于比较和产出,两边一致。
|
||||
func normalizeKey(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sybspec
|
||||
|
||||
import "testing"
|
||||
|
||||
// `[必须]` 这是本单的核心用例。`【短袖】`/`【長袖】` 是商品本身而不是备注,
|
||||
// 剥离后塌缩成同一个 `黑色`,两个不同商品会共用一份映射(#301)。
|
||||
func TestAnnotationIsKeptWhenItDistinguishesTwoProducts(t *testing.T) {
|
||||
short := "黑色【短袖】,3XL 【建議62.5-67.5公斤】"
|
||||
long := "黑色【長袖】,5XL 【建議72.5-77.5公斤】"
|
||||
got := ResolveKeys([]string{short, long})
|
||||
|
||||
if got[short].Color != "黑色【短袖】" || got[long].Color != "黑色【長袖】" {
|
||||
t.Fatalf("颜色键未拆开: %q / %q", got[short].Color, got[long].Color)
|
||||
}
|
||||
// 尺码不塌缩(3XL 与 5XL 本就不同),必须保持剥离后的短键。
|
||||
if got[short].Size != "3XL" || got[long].Size != "5XL" {
|
||||
t.Fatalf("尺码键被误改: %q / %q", got[short].Size, got[long].Size)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 不塌缩时必须与今天的 Parse 逐字一致,否则会波及线上绝大多数明细。
|
||||
func TestNonCollidingKeysAreUnchanged(t *testing.T) {
|
||||
raws := []string{
|
||||
"白色【短袖】,M 【建議42.5-47.5公斤】",
|
||||
"藍色【短袖】,L 【建議47.5-52.5公斤】",
|
||||
"香芋紫 【雙梅花】純棉,2XL 60.0-67.5公斤",
|
||||
}
|
||||
got := ResolveKeys(raws)
|
||||
for _, raw := range raws {
|
||||
want := Parse(raw)
|
||||
if got[raw].Color != want.Color || got[raw].Size != want.Size {
|
||||
t.Fatalf("%q 被改动了: 得到 %q/%q,应为 %q/%q",
|
||||
raw, got[raw].Color, got[raw].Size, want.Color, want.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一个原始规格出现多次不算歧义——那只是同款重复下单。
|
||||
func TestRepeatedIdenticalSpecIsNotAmbiguous(t *testing.T) {
|
||||
raw := "黑色【短袖】,M 【建議42.5-47.5公斤】"
|
||||
got := ResolveKeys([]string{raw, raw, raw})
|
||||
if got[raw].Color != "黑色" {
|
||||
t.Fatalf("重复的同一规格不该触发消歧,得到 %q", got[raw].Color)
|
||||
}
|
||||
}
|
||||
|
||||
// 尺码侧同样会塌缩,规则必须对称。
|
||||
func TestSizeAnnotationIsKeptWhenItDistinguishes(t *testing.T) {
|
||||
a := "黑色,均碼【薄款】"
|
||||
b := "黑色,均碼【加厚】"
|
||||
got := ResolveKeys([]string{a, b})
|
||||
if got[a].Size != "均碼【薄款】" || got[b].Size != "均碼【加厚】" {
|
||||
t.Fatalf("尺码键未拆开: %q / %q", got[a].Size, got[b].Size)
|
||||
}
|
||||
// 颜色两边相同,不该被消歧波及。
|
||||
if got[a].Color != "黑色" || got[b].Color != "黑色" {
|
||||
t.Fatalf("颜色键被误改: %q / %q", got[a].Color, got[b].Color)
|
||||
}
|
||||
}
|
||||
|
||||
// 括号前有无空格是 SYB 的不一致写法,不应产生两个不同的键。
|
||||
func TestWhitespaceBeforeBracketDoesNotSplitTheKey(t *testing.T) {
|
||||
a := "黑色 【短袖】,M"
|
||||
b := "黑色【短袖】,L"
|
||||
got := ResolveKeys([]string{a, b})
|
||||
if got[a].Color != "黑色" || got[b].Color != "黑色" {
|
||||
t.Fatalf("同一规格的两种写法不该被当成歧义: %q / %q", got[a].Color, got[b].Color)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (handler Handler) AdminList(c *gin.Context) {
|
||||
}
|
||||
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
|
||||
Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")),
|
||||
GoodsID: strings.TrimSpace(c.Query("goodsId")), DeviceID: deviceID,
|
||||
GoodsID: strings.TrimSpace(c.Query("goodsId")), Source: strings.TrimSpace(c.Query("source")), DeviceID: deviceID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -161,6 +161,47 @@ func (handler Handler) AdminDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminCancel(c *gin.Context) {
|
||||
id, err := taskID(c)
|
||||
if err != nil || id == 0 {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "taskId 无效"))
|
||||
return
|
||||
}
|
||||
var request ActionRequest
|
||||
if err := decodeStrict(c, &request); err != nil {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, _, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.Cancel(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
||||
var request BatchCancelRequest
|
||||
if err := decodeStrict(c, &request); err != nil {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, _, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCancel(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func decodeStrict(c *gin.Context, destination any) error {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAdminListFiltersBySource 验证 #298 新增的 source 过滤:只返回指定来源的任务,
|
||||
// 不影响其它来源。
|
||||
func TestAdminListFiltersBySource(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
adminTask := createTask(t, db, nil)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", adminTask.ID).
|
||||
Update("source", models.CollectionTaskSourceAdmin).Error; err != nil {
|
||||
t.Fatalf("set admin source: %v", err)
|
||||
}
|
||||
imageSearchTask := createTask(t, db, nil)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", imageSearchTask.ID).
|
||||
Update("source", models.CollectionTaskSourceImageSearch).Error; err != nil {
|
||||
t.Fatalf("set image_search source: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.AdminList(context.Background(), AdminListRequest{Source: models.CollectionTaskSourceAdmin})
|
||||
if err != nil {
|
||||
t.Fatalf("admin list: %v", err)
|
||||
}
|
||||
if response.Total != 1 || len(response.Items) != 1 || response.Items[0].ID != adminTask.ID {
|
||||
t.Fatalf("expected only admin task, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListRejectsInvalidSource 验证非法 source 直接报参数错误,不能静默忽略
|
||||
// 掉过滤条件返回全量列表。
|
||||
func TestAdminListRejectsInvalidSource(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
createTask(t, db, nil)
|
||||
|
||||
if _, err := service.AdminList(context.Background(), AdminListRequest{Source: "not-a-real-source"}); err == nil {
|
||||
t.Fatalf("expected invalid source error")
|
||||
} else if code := taskErrorCode(t, err); code != "INVALID_REQUEST" {
|
||||
t.Fatalf("expected INVALID_REQUEST, got %s", code)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type AdminListRequest struct {
|
||||
Page, PageSize int
|
||||
Status string
|
||||
GoodsID string
|
||||
Source string
|
||||
DeviceID uint64
|
||||
}
|
||||
|
||||
@@ -189,6 +190,14 @@ func (service *Service) AdminList(ctx context.Context, request AdminListRequest)
|
||||
if request.GoodsID != "" {
|
||||
query = query.Where("collection_task.goods_id_snapshot LIKE ?", "%"+strings.TrimSpace(request.GoodsID)+"%")
|
||||
}
|
||||
if request.Source != "" {
|
||||
// `[必须]` 来源必须是三个合法值之一才下发过滤条件;非法值直接报错,
|
||||
// 不能静默忽略——否则筛选框传错值会让采购员误以为看到的是全量列表。
|
||||
if request.Source != models.CollectionTaskSourceAdmin && request.Source != models.CollectionTaskSourceAgentCurrentPage && request.Source != models.CollectionTaskSourceImageSearch {
|
||||
return AdminListResponse{}, serviceError("INVALID_REQUEST", "source 无效")
|
||||
}
|
||||
query = query.Where("collection_task.source = ?", request.Source)
|
||||
}
|
||||
if request.DeviceID > 0 {
|
||||
query = query.Where("collection_task.device_id = ?", request.DeviceID)
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func normalizeCollectionTaskNo(raw string) (uint64, bool, error) {
|
||||
|
||||
func validCollectionStatus(status string) bool {
|
||||
switch status {
|
||||
case models.TaskStatusPending, models.TaskStatusRunning, models.TaskStatusCompleted, models.TaskStatusCompletedPartial, models.TaskStatusFailed:
|
||||
case models.TaskStatusPending, models.TaskStatusRunning, models.TaskStatusCompleted, models.TaskStatusCompletedPartial, models.TaskStatusFailed, models.TaskStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const maxBatchCancelItems = 500
|
||||
|
||||
// CancelResponse describes the outcome of cancelling one pending collection task.
|
||||
type CancelResponse struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
|
||||
// BatchCancelRequest scopes a batch cancel by source and/or status. Only
|
||||
// pending tasks within the scope are actually cancelled; everything else in
|
||||
// scope is reported as skipped rather than causing the whole call to fail.
|
||||
type BatchCancelRequest struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
// GoodsID 与列表页的同名筛选一致。
|
||||
//
|
||||
// `[必须]` 取消范围必须与采购员眼前看到的列表一致。少了这一维,按 goods_id
|
||||
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
|
||||
// 「筛选 + 一个按钮」想避免的事(#298)。
|
||||
GoodsID string `json:"goodsId,omitempty"`
|
||||
}
|
||||
|
||||
type BatchCancelSkippedItem struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type BatchCancelResponse struct {
|
||||
CancelledCount int `json:"cancelledCount"`
|
||||
CancelledIDs []uint64 `json:"cancelledIds"`
|
||||
Skipped []BatchCancelSkippedItem `json:"skipped"`
|
||||
// HasMore 表示范围内还有超出单批上限、本次未处理的任务。
|
||||
//
|
||||
// `[必须]` 必须如实上报,不能静默截断。调用方(采购员界面)会显示待取消
|
||||
// 的实时数量,截断而不告知会让「取消了 N 个」与看到的数字对不上,采购员
|
||||
// 以为全停了,实际上后面还在继续跑。
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
// Cancel stops one pending collection task so a batch build-out can be
|
||||
// interrupted mid-flight (#297). Only pending tasks can be cancelled: a
|
||||
// running task is mid-operation on the phone and cutting it off leaves the
|
||||
// device's page position unknown, which is exactly the "手机停在深层页面"
|
||||
// failure #292 already paid for once. The semantics are "stop what has not
|
||||
// started, let what is running finish".
|
||||
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
|
||||
if err := validateAction(taskID, request); err != nil {
|
||||
return CancelResponse{}, err
|
||||
}
|
||||
var response CancelResponse
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var record models.CollectionTask
|
||||
if err := tx.First(&record, taskID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if record.Status == models.TaskStatusCancelled {
|
||||
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
||||
return nil
|
||||
}
|
||||
if record.Status == models.TaskStatusRunning {
|
||||
return serviceError(CodeTaskStateConflict, "执行中的任务不能取消,请等待任务结束")
|
||||
}
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "只有待执行任务可以取消")
|
||||
}
|
||||
// `[必须]` 取消与设备领取(Claim)必须互斥,且不能靠"先读上面的
|
||||
// record.Status 再写"来判断——那一读一写之间设备完全可能已经把任务
|
||||
// 领走。真正的互斥点是这条条件更新,和 service.go 里 Claim 用的是
|
||||
// 同一模式:WHERE status = pending AND lease 未生效,并校验
|
||||
// RowsAffected。这里必须带 lease_expires_at 条件,因为 Claim 领取
|
||||
// 任务时并不改 status(仍是 pending,只是设了 device_id 和租约),
|
||||
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
|
||||
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
|
||||
now := service.Now()
|
||||
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
||||
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now).
|
||||
Updates(map[string]any{
|
||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return serviceError(CodeTaskStateConflict, "任务状态已变化,可能已被设备领取")
|
||||
}
|
||||
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
||||
return nil
|
||||
})
|
||||
return response, err
|
||||
}
|
||||
|
||||
// BatchCancel cancels every pending task within the requested source/status
|
||||
// scope. It never touches running, completed, failed or already-cancelled
|
||||
// tasks, and each row is cancelled through the same conditional update Cancel
|
||||
// uses, so a task claimed by a device between the scope query and the update
|
||||
// is safely skipped instead of being cancelled out from under it.
|
||||
//
|
||||
// `[必须]` 逐条更新、不包在一个事务里是刻意的:一条因并发领取而跳过,不应该
|
||||
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
|
||||
// 全无——批量越大,中途有一条被领走的概率越高。
|
||||
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
|
||||
source := strings.TrimSpace(request.Source)
|
||||
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
|
||||
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
|
||||
}
|
||||
status := strings.TrimSpace(request.Status)
|
||||
if status != "" && !validCollectionStatus(status) {
|
||||
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "status 无效")
|
||||
}
|
||||
response := BatchCancelResponse{CancelledIDs: []uint64{}, Skipped: []BatchCancelSkippedItem{}}
|
||||
query := service.DB.WithContext(ctx).Model(&models.CollectionTask{})
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
// 与 AdminList 的 goodsId 过滤保持同一种匹配方式,否则两边范围会悄悄错开。
|
||||
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
|
||||
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
|
||||
}
|
||||
var candidates []models.CollectionTask
|
||||
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
|
||||
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
|
||||
return BatchCancelResponse{}, internalError(err)
|
||||
}
|
||||
if len(candidates) > maxBatchCancelItems {
|
||||
candidates = candidates[:maxBatchCancelItems]
|
||||
response.HasMore = true
|
||||
}
|
||||
now := service.Now()
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Status != models.TaskStatusPending {
|
||||
response.Skipped = append(response.Skipped, BatchCancelSkippedItem{TaskID: candidate.ID, Status: candidate.Status})
|
||||
continue
|
||||
}
|
||||
// `[必须]` 与 Cancel 相同的互斥条件:不能只看 status,还要排除已被
|
||||
// Claim 生效租约(未过期)的任务,否则批量取消会把刚被设备领走、
|
||||
// 状态仍是 pending 的任务连带取消掉。
|
||||
result := service.DB.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
||||
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", candidate.ID, models.TaskStatusPending, now).
|
||||
Updates(map[string]any{
|
||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return BatchCancelResponse{}, internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
// 领取发生在查询候选和这次条件更新之间;不是误取消,而是如实上报跳过。
|
||||
response.Skipped = append(response.Skipped, BatchCancelSkippedItem{TaskID: candidate.ID, Status: models.TaskStatusPending})
|
||||
continue
|
||||
}
|
||||
response.CancelledCount++
|
||||
response.CancelledIDs = append(response.CancelledIDs, candidate.ID)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestCancelPendingTaskBlocksFutureClaim 验证 #297 的核心验收点:pending 任务
|
||||
// 可以取消,取消后这个任务不再能被任何设备领取。
|
||||
func TestCancelPendingTaskBlocksFutureClaim(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
_, token := registerTaskDevice(t, db, "device-one")
|
||||
record := createTask(t, db, nil)
|
||||
|
||||
response, err := service.Cancel(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()})
|
||||
if err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
if !response.Cancelled {
|
||||
t.Fatalf("expected cancelled=true, got %+v", response)
|
||||
}
|
||||
|
||||
var stored models.CollectionTask
|
||||
if err := db.First(&stored, record.ID).Error; err != nil {
|
||||
t.Fatalf("load task: %v", err)
|
||||
}
|
||||
if stored.Status != models.TaskStatusCancelled {
|
||||
t.Fatalf("expected status cancelled, got %q", stored.Status)
|
||||
}
|
||||
if stored.ActiveSlot != nil || stored.DeviceRunSlot != nil {
|
||||
t.Fatalf("expected guard slots cleared on cancelled task, got active=%v run=%v", stored.ActiveSlot, stored.DeviceRunSlot)
|
||||
}
|
||||
|
||||
if _, err := service.Claim(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err == nil {
|
||||
t.Fatalf("expected claim of cancelled task to fail")
|
||||
} else if code := taskErrorCode(t, err); code != CodeTaskStateConflict {
|
||||
t.Fatalf("expected TASK_STATE_CONFLICT, got %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelRunningTaskRejected 验证 running 任务禁止取消,错误可读(#292 教训:
|
||||
// 设备正在操作拼多多,中途打断会让手机停在不可控页面)。
|
||||
func TestCancelRunningTaskRejected(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
deviceRecord, _ := registerTaskDevice(t, db, "device-one")
|
||||
record := createTask(t, db, &deviceRecord.ID)
|
||||
if err := record.SetStatus(models.TaskStatusRunning); err != nil {
|
||||
t.Fatalf("set status: %v", err)
|
||||
}
|
||||
if err := db.Model(&record).Updates(map[string]any{"status": record.Status, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
|
||||
t.Fatalf("persist running status: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.Cancel(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()})
|
||||
if err == nil {
|
||||
t.Fatalf("expected cancel of running task to fail")
|
||||
}
|
||||
if code := taskErrorCode(t, err); code != CodeTaskStateConflict {
|
||||
t.Fatalf("expected TASK_STATE_CONFLICT, got %s", code)
|
||||
}
|
||||
|
||||
var stored models.CollectionTask
|
||||
if err := db.First(&stored, record.ID).Error; err != nil {
|
||||
t.Fatalf("load task: %v", err)
|
||||
}
|
||||
if stored.Status != models.TaskStatusRunning {
|
||||
t.Fatalf("running task status must be untouched, got %q", stored.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelAfterClaimFailsInsteadOfMiscancelling 是并发验收点:任务已被设备
|
||||
// 领取(不再是 pending)之后再取消,必须失败而不是误取消。取消用的条件更新
|
||||
// (WHERE status = pending + RowsAffected)和 Claim 是同一互斥模式,这里直接
|
||||
// 复现"先领取,后取消"的时序来证明它真的互斥。
|
||||
func TestCancelAfterClaimFailsInsteadOfMiscancelling(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
deviceRecord, token := registerTaskDevice(t, db, "device-one")
|
||||
record := createTask(t, db, &deviceRecord.ID)
|
||||
|
||||
if _, err := service.Claim(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.Cancel(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()})
|
||||
if err == nil {
|
||||
t.Fatalf("expected cancel to fail once the task has been claimed")
|
||||
}
|
||||
if code := taskErrorCode(t, err); code != CodeTaskStateConflict {
|
||||
t.Fatalf("expected TASK_STATE_CONFLICT, got %s", code)
|
||||
}
|
||||
|
||||
var stored models.CollectionTask
|
||||
if err := db.First(&stored, record.ID).Error; err != nil {
|
||||
t.Fatalf("load task: %v", err)
|
||||
}
|
||||
if stored.Status != models.TaskStatusPending {
|
||||
t.Fatalf("claimed task must remain pending (claim keeps status pending until start), got %q", stored.Status)
|
||||
}
|
||||
if stored.DeviceID == nil || *stored.DeviceID != deviceRecord.ID {
|
||||
t.Fatalf("claimed task must keep its device assignment, got %+v", stored.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelNotFoundAndAlreadyCancelledIsIdempotent(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
if _, err := service.Cancel(context.Background(), 999999, ActionRequest{RequestID: uuid.NewString()}); err == nil {
|
||||
t.Fatalf("expected not found error")
|
||||
} else if code := taskErrorCode(t, err); code != CodeTaskNotFound {
|
||||
t.Fatalf("expected TASK_NOT_FOUND, got %s", code)
|
||||
}
|
||||
|
||||
record := createTask(t, db, nil)
|
||||
if _, err := service.Cancel(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
|
||||
t.Fatalf("first cancel: %v", err)
|
||||
}
|
||||
response, err := service.Cancel(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()})
|
||||
if err != nil {
|
||||
t.Fatalf("second cancel on already-cancelled task should be idempotent, got error: %v", err)
|
||||
}
|
||||
if !response.Cancelled {
|
||||
t.Fatalf("expected cancelled=true on replay, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelScopesBySourceAndStatus(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
pendingAdmin := createTask(t, db, nil)
|
||||
otherPendingAdmin := createTask(t, db, nil)
|
||||
|
||||
imageSearchTask := createTask(t, db, nil)
|
||||
if err := db.Model(&imageSearchTask).Update("source", models.CollectionTaskSourceImageSearch).Error; err != nil {
|
||||
t.Fatalf("set source: %v", err)
|
||||
}
|
||||
|
||||
completedTask := createTask(t, db, nil)
|
||||
if err := db.Model(&completedTask).Updates(map[string]any{"status": models.TaskStatusCompleted, "active_slot": nil, "device_run_slot": nil}).Error; err != nil {
|
||||
t.Fatalf("set completed: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusPending})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != 2 {
|
||||
t.Fatalf("expected 2 cancelled, got %d (%+v)", response.CancelledCount, response)
|
||||
}
|
||||
cancelledSet := map[uint64]bool{}
|
||||
for _, id := range response.CancelledIDs {
|
||||
cancelledSet[id] = true
|
||||
}
|
||||
if !cancelledSet[pendingAdmin.ID] || !cancelledSet[otherPendingAdmin.ID] {
|
||||
t.Fatalf("expected both pending admin tasks cancelled, got %+v", response.CancelledIDs)
|
||||
}
|
||||
|
||||
// 范围外的任务必须原样不动:图搜任务未被 source=admin 命中,已完成任务不是 pending。
|
||||
var storedImageSearch, storedCompleted models.CollectionTask
|
||||
if err := db.First(&storedImageSearch, imageSearchTask.ID).Error; err != nil {
|
||||
t.Fatalf("load image search task: %v", err)
|
||||
}
|
||||
if storedImageSearch.Status != models.TaskStatusPending {
|
||||
t.Fatalf("image search task outside source scope must stay pending, got %q", storedImageSearch.Status)
|
||||
}
|
||||
if err := db.First(&storedCompleted, completedTask.ID).Error; err != nil {
|
||||
t.Fatalf("load completed task: %v", err)
|
||||
}
|
||||
if storedCompleted.Status != models.TaskStatusCompleted {
|
||||
t.Fatalf("completed task must be untouched, got %q", storedCompleted.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchCancelDoesNotAffectOtherTasksOfSameProduct 验证取消不影响同商品
|
||||
// 其它任务:取消一个商品下的 pending 任务,不应波及该商品其它独立任务。
|
||||
func TestCancelDoesNotAffectOtherTaskOfSameProduct(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
goodsID := uuid.NewString()
|
||||
product := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatalf("create product: %v", err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "rule", ContentJSON: `{"steps":[]}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatalf("create rule: %v", err)
|
||||
}
|
||||
pending := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, Status: models.TaskStatusPending, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
|
||||
if err := db.Create(&pending).Error; err != nil {
|
||||
t.Fatalf("create pending task: %v", err)
|
||||
}
|
||||
failed := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, Status: models.TaskStatusFailed, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
|
||||
if err := db.Create(&failed).Error; err != nil {
|
||||
t.Fatalf("create failed task: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Cancel(context.Background(), pending.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
|
||||
var storedFailed models.CollectionTask
|
||||
if err := db.First(&storedFailed, failed.ID).Error; err != nil {
|
||||
t.Fatalf("load failed task: %v", err)
|
||||
}
|
||||
if storedFailed.Status != models.TaskStatusFailed {
|
||||
t.Fatalf("failed task of same product must be untouched, got %q", storedFailed.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 超出单批上限时必须如实上报,不能静默截断。采购员界面会显示待取消的
|
||||
// 实时数量,截断而不告知会让「取消了 N 个」和眼前的数字对不上,人以为全停了,
|
||||
// 实际后面还在继续跑。
|
||||
func TestBatchCancelReportsWhenScopeExceedsTheLimit(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
for i := 0; i < maxBatchCancelItems+3; i++ {
|
||||
createTask(t, db, nil)
|
||||
}
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{Status: models.TaskStatusPending})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != maxBatchCancelItems {
|
||||
t.Fatalf("cancelled %d, want the batch limit %d", response.CancelledCount, maxBatchCancelItems)
|
||||
}
|
||||
if !response.HasMore {
|
||||
t.Fatal("范围内还有未处理的任务,HasMore 必须为 true")
|
||||
}
|
||||
|
||||
// 再来一次应当把剩下的收掉,并且不再报 HasMore。
|
||||
rest, err := service.BatchCancel(context.Background(), BatchCancelRequest{Status: models.TaskStatusPending})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel rest: %v", err)
|
||||
}
|
||||
if rest.CancelledCount != 3 || rest.HasMore {
|
||||
t.Fatalf("second pass cancelled=%d hasMore=%v, want 3 / false", rest.CancelledCount, rest.HasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelWithinLimitDoesNotReportMore(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
createTask(t, db, nil)
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{Status: models.TaskStatusPending})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != 1 || response.HasMore {
|
||||
t.Fatalf("cancelled=%d hasMore=%v, want 1 / false", response.CancelledCount, response.HasMore)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 取消范围必须与采购员眼前的列表一致。列表页的 Goods ID 搜索框若不参与
|
||||
// 取消范围,就会出现「筛出两条、按钮取消三十几条」——那正是当初放弃「两个固定
|
||||
// 按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。
|
||||
func TestBatchCancelHonoursTheGoodsIDFilter(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
|
||||
target := createTask(t, db, nil)
|
||||
other := createTask(t, db, nil)
|
||||
|
||||
var targetRecord models.CollectionTask
|
||||
if err := db.First(&targetRecord, target.ID).Error; err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{
|
||||
Status: models.TaskStatusPending, GoodsID: targetRecord.GoodsIDSnapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != 1 || len(response.CancelledIDs) != 1 || response.CancelledIDs[0] != target.ID {
|
||||
t.Fatalf("只应取消命中 goods_id 的那一条,实际 %+v", response)
|
||||
}
|
||||
|
||||
// 范围外的任务必须原样不动。
|
||||
var stored models.CollectionTask
|
||||
if err := db.First(&stored, other.ID).Error; err != nil {
|
||||
t.Fatalf("load other: %v", err)
|
||||
}
|
||||
if stored.Status != models.TaskStatusPending {
|
||||
t.Fatalf("goods_id 范围外的任务被取消了,status=%q", stored.Status)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user