Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6752b149d8 | ||
|
|
a38688cdd3 | ||
|
|
c6c7fbe2d4 | ||
|
|
1f3ce610b9 | ||
|
|
31ff045e8c | ||
|
|
b4186a9228 | ||
|
|
f9c48a018a | ||
|
|
bf06b2d500 | ||
|
|
fcb7dad878 | ||
|
|
6550b9fd45 | ||
|
|
7a2c2d1eac | ||
|
|
d01540b9fd | ||
|
|
ec010b42fc | ||
|
|
a56df9d908 | ||
|
|
cd4bd6acb1 | ||
|
|
baa20af530 | ||
|
|
f54ace7ccf | ||
|
|
1e582cd868 | ||
|
|
d72d77ddf4 | ||
|
|
ee8f80f987 | ||
|
|
4959f3d17f | ||
|
|
15d331bee9 | ||
|
|
897c1817d2 | ||
|
|
5729ba048f | ||
|
|
9c5e20e249 | ||
|
|
8c95790135 | ||
|
|
316d5aa412 | ||
|
|
e55645ff66 | ||
|
|
dd9160ec42 | ||
|
|
14231c62a7 | ||
|
|
4280115e11 | ||
|
|
a4c7b5e97b | ||
|
|
e7c049dbd6 | ||
|
|
3b95d4e879 | ||
|
|
bce4897390 | ||
|
|
c13dcd3ebc | ||
|
|
0b01b92c98 | ||
|
|
9dace6ac89 | ||
|
|
e99bbc2327 | ||
|
|
abdf90775c | ||
|
|
934a7beabc | ||
|
|
24100f04d4 | ||
|
|
266418ac47 | ||
|
|
5e5fdd797e | ||
|
|
58c8c077ad | ||
|
|
9f679f6403 | ||
|
|
977ac235e2 | ||
|
|
573f5b8cf3 | ||
|
|
1491a9677a | ||
|
|
d4d32c6e89 | ||
|
|
b7038633f0 | ||
|
|
844f611d77 | ||
|
|
d415971268 | ||
|
|
6fe588d5e3 | ||
|
|
17cbfaec85 | ||
|
|
30c6b2ab50 | ||
|
|
27d9560f0a | ||
|
|
b72fa105a8 | ||
|
|
018e3566c0 | ||
|
|
5ac8e4c8fd | ||
|
|
ddd875d989 | ||
|
|
ff6e87649c | ||
|
|
8b4db9c89e | ||
|
|
00fb274fd4 | ||
|
|
1e944b9d2e | ||
|
|
20a8144be0 | ||
|
|
f2c8307c17 | ||
|
|
92c42ba1fe | ||
|
|
fc99be0392 | ||
|
|
dec99cb81d | ||
|
|
fbde80cc20 | ||
|
|
9bebf3930d | ||
|
|
3516fb7edc | ||
|
|
d0b86092a6 | ||
|
|
5faf870d0f | ||
|
|
6773c88a64 | ||
|
|
248eae01ee | ||
|
|
ac5d8336ec | ||
|
|
8ba3470728 | ||
|
|
b1629b900a | ||
|
|
c33e83823a | ||
|
|
faac8bb155 | ||
|
|
e845651933 | ||
|
|
3bc4784402 | ||
|
|
ab292d6a17 | ||
|
|
02bddbf304 | ||
|
|
3e82ad6570 | ||
|
|
b015719947 | ||
|
|
4caed7d2dd | ||
|
|
e82762e6b7 | ||
|
|
edc956c430 | ||
|
|
1d302a9144 | ||
|
|
18353b2381 | ||
|
|
fc35d144c8 | ||
|
|
47f6377095 | ||
|
|
faad596d32 | ||
|
|
4f02e4dcfa | ||
|
|
66301e89ce | ||
|
|
433a254239 | ||
|
|
36810f3a71 | ||
|
|
3bf428acd7 | ||
|
|
d8382e6518 | ||
|
|
0edc78a943 | ||
|
|
5e0a9d108c | ||
|
|
08b7095cf1 | ||
|
|
01510a85dc | ||
|
|
4261a542ca | ||
|
|
beec630187 | ||
|
|
2491a857f7 | ||
|
|
0661b2205f | ||
|
|
bb1a410e8a | ||
|
|
8d9c3d47e0 | ||
|
|
368f2c2357 | ||
|
|
f66952f640 | ||
|
|
3a2472dd20 |
@@ -31,3 +31,7 @@
|
||||
/android/**/build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Third-party capture and device dumps: contain credentials, tokens and personal data.
|
||||
*.har
|
||||
/xml_data/
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。
|
||||
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
|
||||
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
|
||||
- 服务端顺云宝(SYB)登录:允许调用配置的线上 OCR 服务识别登录验证码(见 #48)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
- 规格匹配:Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;人工映射缺失、商品无规格数据或目标规格定位不到时,由服务端 AI 匹配接口决策(见 #46),AI 无结果时明确失败。
|
||||
- SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。
|
||||
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
versionCode = 77
|
||||
versionName = "0.9.64"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+13
-3
@@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
// Count and resolve against the same live root: a missing root or a
|
||||
// failed dispatch must never masquerade as an empty scrollable list.
|
||||
val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0)
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
val directional = candidates.filter { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() }
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
} ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size)
|
||||
val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) {
|
||||
PurchaseSwipeResult.SUCCESS
|
||||
} else PurchaseSwipeResult.FAILED
|
||||
return PurchaseSwipeOutcome(result, candidates.size)
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
|
||||
+288
-11
@@ -90,7 +90,28 @@ data class VisibleSpecValue(
|
||||
val rawText: String = text,
|
||||
)
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
enum class SpecPanelType {
|
||||
UNKNOWN,
|
||||
NORMAL_SCROLLABLE,
|
||||
NON_SCROLLABLE_CONFIRMATION,
|
||||
QUICK_CONFIRMATION,
|
||||
ORDER_CONFIRMATION,
|
||||
/**
|
||||
* #331 fallback: only reached when no stronger panel type matched. It
|
||||
* requires an address entry, a payment-method entry and one quantity input
|
||||
* box, plus at least one auxiliary signal. It is recognition only and never
|
||||
* relaxes any click target (the final order-submit click stays strict).
|
||||
*/
|
||||
REQUIRED_EVIDENCE,
|
||||
}
|
||||
|
||||
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
|
||||
object SpecPanelStabilityPolicy {
|
||||
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
|
||||
const val WAIT_BOUND_MS = 5_000L
|
||||
/** Sampling interval used after an address save returns to the purchase panel. */
|
||||
const val ADDRESS_SAVE_POLL_MS = 200L
|
||||
}
|
||||
|
||||
object PddSoldOutRecoveryDefaults {
|
||||
const val EXACT_TEXT = "商品已售罄"
|
||||
@@ -134,7 +155,54 @@ data class ParsedPddScreen(
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
// #331 evidence flags. Presence only: address text is never kept here.
|
||||
val hasAddressEntry: Boolean = false,
|
||||
val hasPaymentEntry: Boolean = false,
|
||||
val hasQuantityInput: Boolean = false,
|
||||
val hasQuantityAdjustControls: Boolean = false,
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
|
||||
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
|
||||
fun requiredEvidenceSummary(): String =
|
||||
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
|
||||
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
|
||||
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
|
||||
|
||||
/**
|
||||
* #331 purchaser-readable hints for missing required items. Only emitted
|
||||
* when the page shows at least one required item or parsed options, so an
|
||||
* unrelated page does not produce misleading environment hints. No page
|
||||
* text or personal data is included.
|
||||
*/
|
||||
fun requiredEvidenceHints(): List<String> {
|
||||
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
|
||||
if (!partial) return emptyList()
|
||||
return buildList {
|
||||
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
|
||||
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
|
||||
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
|
||||
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
|
||||
fun requiredEvidenceHintSuffix(): String =
|
||||
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
|
||||
|
||||
private fun Boolean.flag(): Int = if (this) 1 else 0
|
||||
|
||||
companion object {
|
||||
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
|
||||
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
|
||||
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
|
||||
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
|
||||
}
|
||||
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
fallbackTopText: String = "相似商品",
|
||||
@@ -186,6 +254,13 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
|
||||
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
/** An address/payment entry must be clickable itself or through a nearby row container. */
|
||||
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
@@ -203,6 +278,8 @@ object PddScreenParser {
|
||||
}
|
||||
}
|
||||
val labels = visible.map(SnapshotNode::label)
|
||||
val visibleByPath = visible.associateBy(SnapshotNode::path)
|
||||
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
|
||||
val textAliases = config.textAliases
|
||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||
val compactLabels = labels.map { it.replace(" ", "") }
|
||||
@@ -240,7 +317,23 @@ object PddScreenParser {
|
||||
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
||||
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
||||
// #331: the unique input box is required; the +/- buttons are only auxiliary.
|
||||
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
|
||||
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
|
||||
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
||||
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
|
||||
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
|
||||
val hasAddressEntry = addressEntryRows.isNotEmpty()
|
||||
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
|
||||
// #331: the three required items must sit in one panel container, not
|
||||
// merely somewhere on screen (e.g. an address dialog, a payment dialog
|
||||
// and an unrelated input box). Same area bound idea as boundedScrollables.
|
||||
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
|
||||
addressEntryRows.any { address ->
|
||||
paymentEntryRows.any { payment ->
|
||||
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
|
||||
}
|
||||
}
|
||||
val hasQuickBuy = visible.any { node ->
|
||||
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
||||
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
||||
@@ -276,27 +369,50 @@ object PddScreenParser {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
||||
val values = panelVisible.asSequence()
|
||||
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
|
||||
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
||||
fun inValueRegion(node: SnapshotNode) =
|
||||
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
}
|
||||
.map {
|
||||
val rawText = it.label
|
||||
.toList()
|
||||
// #332/task 535: one option block may expose several clickable
|
||||
// nodes (outer block, image, inner text with a badge such as
|
||||
// "零差评"). Nodes nested in the same labelled option block are
|
||||
// collapsed into one value named by the outer block. The click
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
|
||||
val selected = members.any(SnapshotNode::selected) || block?.selected == true
|
||||
val checked = members.any(SnapshotNode::checked) || block?.checked == true
|
||||
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
|
||||
}
|
||||
.asSequence()
|
||||
.map { (option, blockLabel, members) ->
|
||||
val rawText = blockLabel
|
||||
val normalizedText = when (dimensionKey) {
|
||||
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
||||
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
||||
else -> rawText
|
||||
}
|
||||
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
VisibleSpecValue(
|
||||
normalizedText,
|
||||
it.enabled && !stateText.containsUnavailableWord(),
|
||||
it,
|
||||
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
|
||||
option.enabled && !stateText.containsUnavailableWord(),
|
||||
option,
|
||||
if (dimensionKey == "color") {
|
||||
(listOf(option) + members.filter { it.path != option.path })
|
||||
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
|
||||
} else null,
|
||||
rawText,
|
||||
)
|
||||
}
|
||||
@@ -339,6 +455,14 @@ object PddScreenParser {
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// #331 fallback, evaluated only after every stronger branch failed.
|
||||
// Required: address entry + payment entry + one enabled quantity
|
||||
// input. Auxiliary (at least one): grouped options, selection
|
||||
// summary, close control, +/- controls or the lower submit action.
|
||||
requiredEvidenceSameContainer && (
|
||||
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
|
||||
hasQuantityAdjustControls || hasOrderSubmitAction
|
||||
) -> SpecPanelType.REQUIRED_EVIDENCE
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -439,6 +563,12 @@ object PddScreenParser {
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
hasAddressEntry = hasAddressEntry,
|
||||
hasPaymentEntry = hasPaymentEntry,
|
||||
hasQuantityInput = hasQuantityInput,
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -453,6 +583,93 @@ object PddScreenParser {
|
||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Outermost labelled clickable ancestor inside the value region whose label
|
||||
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||
* options never qualify because their members do not share its label.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
candidates: List<SnapshotNode>,
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
var parentPath = candidate.parentPath
|
||||
while (parentPath != null) {
|
||||
val raw = sourceByPath[parentPath] ?: break
|
||||
val resolved = visibleByPath[parentPath]
|
||||
// Only a block with its own label names the option; a blank row
|
||||
// container resolved from its first child never merges options.
|
||||
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
var depth = 0
|
||||
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
|
||||
if (current.clickable) {
|
||||
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
|
||||
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
|
||||
return current.takeIf { plausible }
|
||||
}
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
|
||||
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
|
||||
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: true when the lowest common ancestor of [nodes] is a real panel
|
||||
* container: it is not a window root (a node without parent) and, like
|
||||
* boundedScrollables, it does not cover the whole screen. Items taken from
|
||||
* separate dialogs/pages only meet at the root or a full-screen wrapper.
|
||||
*/
|
||||
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
|
||||
fun chain(node: SnapshotNode): List<SnapshotNode> {
|
||||
val result = mutableListOf(node)
|
||||
var current = node.parentPath?.let(sourceByPath::get)
|
||||
while (current != null) {
|
||||
result += current
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
}
|
||||
return result
|
||||
}
|
||||
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
|
||||
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
|
||||
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
|
||||
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
|
||||
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
|
||||
}
|
||||
|
||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
||||
val candidatePrefix = "${candidate.path}/"
|
||||
return source.none { node ->
|
||||
@@ -1385,6 +1602,41 @@ class PddProductDetailCollector(
|
||||
.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* #334: the panel already shows its topmost color heading (the first
|
||||
* parsed dimension is "color") with values visible, so no restore swipe
|
||||
* is required. Swiping an already-top panel drags the bottom sheet
|
||||
* further and can make the color/size headings vanish.
|
||||
*/
|
||||
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
|
||||
val first = screen.dimensions.firstOrNull() ?: return false
|
||||
return first.key == "color" && first.values.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun recordSpecPanelTopSwipe(
|
||||
reason: AgentDiagnosticReason,
|
||||
screen: ParsedPddScreen,
|
||||
swipeCount: Int,
|
||||
headingCountBefore: Int?,
|
||||
headingCountAfter: Int?,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
diagnostic(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
|
||||
// already carries the panel-open/restore diagnostics elsewhere,
|
||||
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
|
||||
stage = AgentDiagnosticStage.PAGE_STABILITY,
|
||||
reason = reason,
|
||||
attempt = swipeCount,
|
||||
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
|
||||
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
|
||||
clickableAncestorDepth = screen.panelHeadingCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun moveSpecPanelToTop(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
@@ -1394,17 +1646,40 @@ class PddProductDetailCollector(
|
||||
): PddCollectorResult? {
|
||||
var previous: List<String>? = null
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) {
|
||||
var priorHeadingCount = -1
|
||||
var priorDimensionCount = -1
|
||||
var swipes = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
if (pass == 0 && isSpecPanelAtTop(screen)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
|
||||
return null
|
||||
}
|
||||
val headingCount = screen.panelHeadingCount
|
||||
val dimensionCount = screen.dimensions.sumOf { it.values.size }
|
||||
// A normal scroll-to-top can legitimately drop the panel's
|
||||
// heading count (lower dimensions scroll out of view) without
|
||||
// losing any values, so heading count alone must never trigger
|
||||
// this. Only a full loss of every collected spec value, or the
|
||||
// panel no longer being recognized as open, indicates the sheet
|
||||
// was dragged too far.
|
||||
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
|
||||
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
|
||||
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
}
|
||||
priorHeadingCount = headingCount
|
||||
priorDimensionCount = dimensionCount
|
||||
val signature = viewportSignature(screen)
|
||||
stable = if (previous != null && signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
@@ -1572,6 +1847,8 @@ class PddProductDetailCollector(
|
||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
|
||||
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
|
||||
"changed=${screenChanged.toInt()};" +
|
||||
"recovery=${when {
|
||||
recoverySucceeded -> "success"
|
||||
|
||||
+240
-13
@@ -40,6 +40,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -523,7 +525,7 @@ class PurchaseLiveAutomation(
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
@@ -540,11 +542,7 @@ class PurchaseLiveAutomation(
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
return screen.specPanelType in setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
@@ -598,14 +596,14 @@ class PurchaseLiveAutomation(
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
||||
consecutiveExits++
|
||||
if (consecutiveExits >= 2) return snapshot
|
||||
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
|
||||
} else {
|
||||
consecutiveExits = 0
|
||||
}
|
||||
@@ -614,6 +612,68 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: after the editor closes PDD may still be rebuilding the purchase
|
||||
* sheet. Accept immediately when the final evidence or a stronger panel type
|
||||
* is already present. A sheet recognized only through the required-evidence
|
||||
* fallback must also be structurally stable for two consecutive samples. A
|
||||
* stable page without any purchase-sheet marker (quantity input or payment
|
||||
* entry), e.g. the address list, keeps the existing caller path. A partially
|
||||
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
|
||||
* without pressing Back and then fails explicitly.
|
||||
*/
|
||||
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
var waitedMs = 0L
|
||||
var samples = 0
|
||||
while (true) {
|
||||
samples++
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
|
||||
val signature = structureSignature(snapshot)
|
||||
val stable = editorGone && signature == previousSignature
|
||||
val outcome = when {
|
||||
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
|
||||
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
|
||||
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
|
||||
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
|
||||
else -> null
|
||||
}
|
||||
if (outcome != null) {
|
||||
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
|
||||
return snapshot
|
||||
}
|
||||
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
|
||||
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
|
||||
fail(
|
||||
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
previousSignature = signature
|
||||
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
|
||||
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
|
||||
panelDiagnostic(
|
||||
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
|
||||
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory structure only (path, class, bounds, flags); no text is included. */
|
||||
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
|
||||
.filter { it.visible }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
|
||||
.joinToString(":")
|
||||
}
|
||||
|
||||
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
||||
|
||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||
@@ -716,10 +776,159 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
/**
|
||||
* #335 (3rd revision, reviewer cross-check on real dumps — Samsung sample): a
|
||||
* two-step "row, then leaf" pick, not a single whole-panel leaf scan. A whole-panel
|
||||
* scan can pick a labelled leaf that lives in a DIFFERENT clickable row than the real
|
||||
* bottom bar — on the Samsung sample the bottom bar is a label-less 31px FrameLayout
|
||||
* whose only text node is zero-size, so a naive scan falls through to the next
|
||||
* lowest labelled leaf, which sits in the PAYMENT-METHOD row above it, and the climb
|
||||
* from there lands the click on "change payment method" instead of the order button.
|
||||
*
|
||||
* Step 1 — row: among visible/enabled/non-zero-size CLICKABLE nodes inside the
|
||||
* recognized panel's container (#331 judgment) with height ≤ [SUBMIT_ROW_MAX_HEIGHT_PERCENT]
|
||||
* of screen height (same guard as 2bc624f, keeps a full-sheet/full-body wrapper from
|
||||
* ever being "the row"), pick the one with the lowest bottom edge; ties go to the
|
||||
* rightmost. This row — and only this row — may hold the order button. If its own
|
||||
* subtree matches a payment-method alias (`textAliases.specPanel.paymentAreaAliases`,
|
||||
* e.g. 微信支付/先用后付/支付方式) or [SUBMIT_TARGET_BLOCKED_MARKERS], fail explicitly;
|
||||
* never fall back to a higher row — a higher row is never the order button either.
|
||||
*
|
||||
* Step 2 — leaf: within that row's own subtree (or the row itself), among
|
||||
* visible/non-zero-size nodes with a NON-BLANK OWN LABEL whose nearest clickable
|
||||
* ancestor is EXACTLY that row (not some nested sub-button, and not a higher row),
|
||||
* pick the bottom-right-most one. [PurchaseUiDriver.clickFresh] re-finds its target
|
||||
* live by `preferredOrDescendantLabel() == target.label && className && center±32`
|
||||
* and only then climbs to the nearest clickable ancestor and clicks it (see
|
||||
* `GoAutoAccessibilityService.clickFreshDetailed`); a label-less container's
|
||||
* [SnapshotNode.label] is `""` but its live `preferredOrDescendantLabel()` digs into a
|
||||
* child, so the re-find would always miss (task 594: TARGET_NOT_FOUND right after the
|
||||
* irreversible boundary). Aiming at the labelled leaf — the same convention already
|
||||
* used by `ImageSearchCandidatePolicy.clickTargetInside` — lets clickFresh's own
|
||||
* climb-to-ancestor logic land the tap back on the row. If the row has no such leaf
|
||||
* (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) —
|
||||
* never fall back to a higher row.
|
||||
*/
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||
return current
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
|
||||
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val container = panelContainerBounds(snapshot)
|
||||
if (container == null) {
|
||||
panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
|
||||
// Step 1: the bottom-most clickable row.
|
||||
val rowCandidates = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.clickable && hasArea(node) &&
|
||||
node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT &&
|
||||
inside(node.bounds, container)
|
||||
}.distinctBy { it.path }
|
||||
if (rowCandidates.isEmpty()) {
|
||||
panelDiagnostic("bottomSubmit;outcome=no_rows;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val rowMaxBottom = rowCandidates.maxOf { it.bounds.bottom }
|
||||
val rowBottomTied = rowCandidates.filter { it.bounds.bottom == rowMaxBottom }
|
||||
val rowTie = rowBottomTied.size > 1
|
||||
val row = rowBottomTied.maxBy { it.bounds.right }
|
||||
|
||||
val rowSubtreeLabels = (listOf(row.label) + subtreeDescendants(row, snapshot).map(SnapshotNode::label)).joinToString("")
|
||||
val paymentAreaAliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.paymentAreaAliases
|
||||
if (paymentAreaAliases.any(rowSubtreeLabels::contains) || SUBMIT_TARGET_BLOCKED_MARKERS.any(rowSubtreeLabels::contains)) {
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=row_blocked;tie=${rowTie.diagFlag()};class=${row.className};" +
|
||||
"w=${row.bounds.width};h=${row.bounds.height}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Step 2: the bottom-right-most labelled leaf whose nearest clickable ancestor is exactly this row.
|
||||
val leafCandidates = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
|
||||
node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() &&
|
||||
clickableAncestor(node)?.path == row.path
|
||||
}.distinctBy { it.path }
|
||||
if (leafCandidates.isEmpty()) {
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=bottom_row_unlabelled;tie=${rowTie.diagFlag()};class=${row.className};" +
|
||||
"w=${row.bounds.width};h=${row.bounds.height}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
val leafMaxBottom = leafCandidates.maxOf { it.bounds.bottom }
|
||||
val leafBottomTied = leafCandidates.filter { it.bounds.bottom == leafMaxBottom }
|
||||
val leafTie = leafBottomTied.size > 1
|
||||
val chosen = leafBottomTied.maxBy { it.bounds.right }
|
||||
|
||||
val hasPrice = PRICE_PRESENCE.containsMatchIn(rowSubtreeLabels)
|
||||
val legacyMarker = FINAL_SUBMIT_MARKERS.any { rowSubtreeLabels == it || rowSubtreeLabels.startsWith(it) }
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=ok;rowTie=${rowTie.diagFlag()};leafTie=${leafTie.diagFlag()};" +
|
||||
"leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" +
|
||||
"price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
|
||||
)
|
||||
return listOf(chosen)
|
||||
}
|
||||
|
||||
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val prefix = "${node.path}/"
|
||||
return snapshot.nodes.filter { it.path.startsWith(prefix) }
|
||||
}
|
||||
|
||||
/**
|
||||
* #335 follow-up: the submit click target must belong to the recognized spec panel,
|
||||
* not just be somewhere on screen (e.g. an underlying goods-detail-page bottom bar
|
||||
* that is still in the accessibility tree behind the panel). [ParsedPddScreen
|
||||
* .specPanelContainer] (PddScreenParser's `panelScrollable`) is not usable for this:
|
||||
* it is the inner *scrollable dimension list* used only for heading/option parsing,
|
||||
* and in a real PDD sheet the address/payment/submit rows sit OUTSIDE it as
|
||||
* structural siblings, not descendants. Instead this climbs from the panel's unique
|
||||
* quantity input — present for every recognized #331 panel type used here — to the
|
||||
* LARGEST ancestor that still does not cover the whole screen, i.e. the whole
|
||||
* bottom-sheet container. That is the same "does not cover the whole screen" bounded
|
||||
* notion #331 already uses for `sharesBoundedPanelContainer`/`boundedScrollables`,
|
||||
* just walked from the quantity input instead of recomputed from private internals.
|
||||
* A candidate then belongs to the panel when its center point falls inside that
|
||||
* container's bounds (geometric containment, not path prefix, since PDD's own
|
||||
* accessibility tree can place the submit bar as a structural sibling of the panel
|
||||
* container that is still visually part of the sheet). Returns null (no target) when
|
||||
* the quantity input is missing or not unique, so the caller fails explicitly instead
|
||||
* of guessing a container.
|
||||
*/
|
||||
private fun panelContainerBounds(snapshot: UiSnapshot): NodeBounds? {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenArea = screenWidth.toLong() * screenHeight
|
||||
val quantity = snapshot.nodes.singleOrNull { node ->
|
||||
node.visible && node.enabled && node.className == "android.widget.EditText" &&
|
||||
node.label.toIntOrNull()?.let { it > 0 } == true
|
||||
} ?: return null
|
||||
var lastBounded: SnapshotNode? = null
|
||||
var current: SnapshotNode = quantity
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
val parentArea = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (parentArea <= 0 || parentArea >= screenArea) break
|
||||
lastBounded = parent
|
||||
current = parent
|
||||
}
|
||||
return lastBounded?.bounds
|
||||
}
|
||||
|
||||
private fun Boolean.diagFlag(): Int = if (this) 1 else 0
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
@@ -793,9 +1002,27 @@ class PurchaseLiveAutomation(
|
||||
private companion object {
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
|
||||
val STRONG_PURCHASE_PANEL_TYPES = setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
// #335: presence-only signal for diagnostics; never records the actual price digits.
|
||||
val PRICE_PRESENCE = Regex("[¥¥][0-9]")
|
||||
// #335: a bottom-edge candidate taller than this share of screen height is a
|
||||
// whole-sheet/whole-body wrapper, never the order button itself.
|
||||
const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
// #335: broader than PAYMENT_MARKERS on purpose — this list is used ONLY by
|
||||
// finalSubmitTargets' bottom-node subtree check, never by pageProblem/order-result
|
||||
// parsing. "付款" alone must NOT join PAYMENT_MARKERS: UNPAID_MARKERS' "待付款"
|
||||
// contains "付款" and PAYMENT_MARKERS gates the global pageProblem() payment guard,
|
||||
// so adding it there would misclassify every ordinary unpaid-order page as a
|
||||
// forbidden payment page.
|
||||
val SUBMIT_TARGET_BLOCKED_MARKERS = listOf("立即支付", "确认支付", "去支付", "付款", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
|
||||
|
||||
+101
-17
@@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED }
|
||||
|
||||
data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int)
|
||||
|
||||
interface PurchaseUiDriver {
|
||||
fun capture(): UiSnapshot
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
@@ -32,6 +36,18 @@ interface PurchaseUiDriver {
|
||||
fun specRowSwipeFailureReason(): String = "gestureFailed"
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
/** Detailed result only for the rule's reversible spec-panel reveal swipe. */
|
||||
fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
val snapshot = capture()
|
||||
val count = snapshot.nodes.count { it.visible && it.scrollable }
|
||||
val result = when {
|
||||
snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE
|
||||
count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET
|
||||
swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS
|
||||
else -> PurchaseSwipeResult.FAILED
|
||||
}
|
||||
return PurchaseSwipeOutcome(result, count)
|
||||
}
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
@@ -100,7 +116,7 @@ class PurchaseRehearsalExecutor(
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -394,7 +410,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,7 +431,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
return failure(
|
||||
@@ -446,17 +462,51 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
var polls = 0
|
||||
var waitedMs = 0L
|
||||
var requiredEvidenceSeen = false
|
||||
var previousStructure: List<SpecActionNodeSignature>? = null
|
||||
fun waitDiagnostic(outcome: String) {
|
||||
if (requiredEvidenceSeen) {
|
||||
panelDiagnostic(
|
||||
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
|
||||
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
|
||||
)
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
||||
changed = changed || specActionSignature(last) != beforeSignature
|
||||
val structure = specActionSignature(last)
|
||||
// Stronger panel types keep their immediate acceptance. The #331
|
||||
// fallback type additionally needs two identical consecutive
|
||||
// structures so a still-loading sheet is not acted on too early.
|
||||
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
|
||||
waitDiagnostic("opened")
|
||||
return SpecPanelWait(last, true, true)
|
||||
}
|
||||
changed = changed || structure != beforeSignature
|
||||
requiredEvidenceSeen = requiredEvidenceSeen ||
|
||||
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
|
||||
previousStructure = structure
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
waitedMs += SPEC_SELECTION_POLL_MILLIS
|
||||
polls++
|
||||
// A partially loaded purchase sheet may take longer than the plain
|
||||
// entry verification window; it gets the bounded #331 wait. No Back
|
||||
// is pressed while waiting; the caller fails explicitly on timeout.
|
||||
val limitReached = if (requiredEvidenceSeen) {
|
||||
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
|
||||
} else {
|
||||
polls >= SPEC_POST_CLICK_VERIFY_POLLS
|
||||
}
|
||||
if (limitReached) break
|
||||
}
|
||||
waitDiagnostic("timeout")
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
@@ -478,7 +528,8 @@ class PurchaseRehearsalExecutor(
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
|
||||
screen.requiredEvidenceSummary()
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
@@ -574,9 +625,11 @@ class PurchaseRehearsalExecutor(
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (outcome.reason) {
|
||||
@@ -589,6 +642,19 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
/**
|
||||
* #332/task 535: parsed option texts and selected flags of one dimension.
|
||||
* Spec values are product attributes, not personal data; each is truncated.
|
||||
*/
|
||||
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
|
||||
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
|
||||
values.joinToString("|") { value ->
|
||||
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
@@ -1100,17 +1166,34 @@ class PurchaseRehearsalExecutor(
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
|
||||
val initialScreen = if (reveal) currentScreen(input) else null
|
||||
if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) {
|
||||
val count = initialScreen.sourceNodes.count { it.visible && it.scrollable }
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true")
|
||||
return null
|
||||
}
|
||||
repeat(swipe.count) { index ->
|
||||
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
// Keep other actions on the original Boolean path. Only the
|
||||
// post-open reveal may treat an absent scroll target as a no-op.
|
||||
val outcome = if (reveal) {
|
||||
driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs)
|
||||
} else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0)
|
||||
} else {
|
||||
val count = driver.capture().nodes.count { it.visible && it.scrollable }
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count)
|
||||
}
|
||||
if (outcome.result != PurchaseSwipeResult.SUCCESS) {
|
||||
val screen = currentScreen(input)
|
||||
val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" +
|
||||
"reveal=$reveal;reason=${outcome.result.name.lowercase()}"
|
||||
if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) {
|
||||
panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence")
|
||||
return null
|
||||
}
|
||||
panelDiagnostic("ruleSwipe;outcome=failed;$evidence")
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]")
|
||||
}
|
||||
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
|
||||
}
|
||||
@@ -1175,6 +1258,7 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* #334: a spec probe that produced zero color/size dimensions, or that never
|
||||
* collected any dimensions because the collector itself failed (e.g. an
|
||||
* over-swiped spec panel losing its headings — see
|
||||
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
|
||||
* rather than being reported as a normal `spec_probe_completed` empty
|
||||
* result or a generic "no matchable spec" outcome.
|
||||
*
|
||||
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
|
||||
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
|
||||
* wants to hand to the server), and its own unit tests exercise that
|
||||
* contract with canned payloads unrelated to this device bug. The checks
|
||||
* instead run once, at the point where
|
||||
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
|
||||
* collector result into the outcome that gets persisted and reported.
|
||||
* `collectPurchaseProbe` encodes a failed collector run as a
|
||||
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
|
||||
* through the same opaque `String?` probe callback) instead of `null`, so
|
||||
* [demote] can tell it apart from a genuinely empty-but-successful probe.
|
||||
*/
|
||||
object PurchaseSpecProbePolicy {
|
||||
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
|
||||
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
|
||||
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
|
||||
|
||||
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
|
||||
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
|
||||
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
|
||||
|
||||
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
|
||||
private val knownCollectorFailureMessages = mapOf(
|
||||
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
|
||||
)
|
||||
|
||||
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
|
||||
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
|
||||
if (probedSpecsJson.isNullOrBlank()) return true
|
||||
return try {
|
||||
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
|
||||
var total = 0
|
||||
for (i in 0 until dimensions.length()) {
|
||||
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
|
||||
}
|
||||
total == 0
|
||||
} catch (_: Exception) {
|
||||
// Malformed payload cannot be trusted as a real, non-empty probe result.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
|
||||
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
|
||||
if (probedSpecsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
|
||||
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
|
||||
JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
|
||||
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
|
||||
.toString()
|
||||
|
||||
private fun failureMessageFor(collectorCode: String): String =
|
||||
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
|
||||
|
||||
/**
|
||||
* Demotes a `spec_probe_completed` outcome that either carries an
|
||||
* encoded collector failure, or collected zero dimensions, into an
|
||||
* explicit failure. Any other outcome (including real failures reported
|
||||
* some other way, or a probe that did collect dimensions) is returned
|
||||
* unchanged.
|
||||
*/
|
||||
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
|
||||
if (outcome.resultType != "spec_probe_completed") return outcome
|
||||
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
|
||||
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
|
||||
}
|
||||
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
|
||||
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
|
||||
}
|
||||
|
||||
/** @see demote */
|
||||
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
|
||||
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
|
||||
}
|
||||
@@ -30,6 +30,10 @@ enum class AgentDiagnosticReason {
|
||||
SPEC_PANEL_RESTORE_LIMIT,
|
||||
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
|
||||
SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
|
||||
SPEC_PANEL_TOP_ALREADY,
|
||||
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
|
||||
SPEC_PANEL_TOP_COLLAPSED,
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
SIZE_FOUND,
|
||||
|
||||
+12
-1
@@ -38,6 +38,7 @@ import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -591,7 +592,7 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("$taskLabel #${task.taskId}")
|
||||
|
||||
val outcome = if (!snapshotHashValid) {
|
||||
val rawOutcome = if (!snapshotHashValid) {
|
||||
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
|
||||
} else {
|
||||
var parseFailure: PurchaseExecutionOutcome? = null
|
||||
@@ -652,6 +653,9 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #334: a spec probe that read zero colors/sizes must fail explicitly
|
||||
// instead of being reported as a normal, empty spec_probe_completed.
|
||||
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
@@ -691,6 +695,13 @@ class AgentForegroundService : Service() {
|
||||
collector = collector,
|
||||
)
|
||||
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
|
||||
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
|
||||
// collapse into a generic "spec probe failed" outcome. Carry the
|
||||
// real code/message through the opaque probeSpecs callback so
|
||||
// PurchaseSpecProbePolicy.demote can surface it explicitly.
|
||||
if (!result.successful) {
|
||||
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
|
||||
}
|
||||
val payload = result.payload ?: return null
|
||||
return JSONObject()
|
||||
.put("goodsId", task.pddGoodsId)
|
||||
|
||||
+133
-17
@@ -974,6 +974,95 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.captureCount > driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
|
||||
// Default fixture state already renders the color heading first (at
|
||||
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
|
||||
// that case, since dragging an already-top panel can lose headings
|
||||
// on a real device.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
|
||||
// The color heading is hidden until one DOWN (top-restore) swipe has
|
||||
// happened, simulating a panel scrolled past its headings.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
|
||||
// The panel starts not-at-top (color heading hidden) so the restore
|
||||
// swipe runs; after that swipe every heading and value disappears,
|
||||
// simulating the bottom sheet being dragged off screen. #334
|
||||
// requires the collector to stop and fail instead of reporting an
|
||||
// empty successful result.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelCollapsesAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
|
||||
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
|
||||
// Three-dimension panel not at top (color heading is present but has
|
||||
// no values yet, so it does not become dimensions.first()). After the
|
||||
// restore swipe, color values return but the third, lower "容量"
|
||||
// heading scrolls out of view — a normal scroll-to-top effect. Total
|
||||
// heading count drops, but color and size values are present the
|
||||
// whole time, so this must NOT be treated as a collapsed panel.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
extraDimension = true,
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelDropsExtraDimensionAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1044,7 +1133,9 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
|
||||
// #334: the panel already shows the color heading at top, so
|
||||
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
|
||||
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
||||
}
|
||||
@@ -1757,6 +1848,21 @@ class PddProductDetailCollectorTest {
|
||||
private val selectedSummaryPrefix: String = "已选",
|
||||
private val imageColorCards: Boolean = false,
|
||||
private val navigateAwayAfterColorClick: Boolean = false,
|
||||
// #334: simulates a spec panel that is not scrolled to top yet: the
|
||||
// color heading is present but has no values yet (so it is not the
|
||||
// first parsed dimension), until one DOWN (top-restore) swipe has
|
||||
// happened, after which color values render normally.
|
||||
private val specPanelHidesColorInitially: Boolean = false,
|
||||
// #334: simulates a bottom sheet dragged past its headings by a
|
||||
// restore swipe — once at least one DOWN swipe has happened, every
|
||||
// spec heading and value disappears from the panel.
|
||||
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
|
||||
// #334: simulates a normal scroll-to-top on a multi-dimension panel
|
||||
// where a lower, non-spec-value-bearing heading (here the extra
|
||||
// "容量" dimension) scrolls out of view once the color heading is
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -1770,6 +1876,7 @@ class PddProductDetailCollectorTest {
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
private var verticalPage = 0
|
||||
private var downSwipeCount = 0
|
||||
private var priceRead = 0
|
||||
private var panelOpen = !startWithPanelClosed
|
||||
private var reviewOpen = false
|
||||
@@ -1832,12 +1939,16 @@ class PddProductDetailCollectorTest {
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
|
||||
)
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage) {
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
@@ -1877,21 +1988,23 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
if (!panelCollapsedNow) {
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (extraDimension) {
|
||||
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
|
||||
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
|
||||
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
|
||||
}
|
||||
@@ -1966,7 +2079,10 @@ class PddProductDetailCollectorTest {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -197,7 +197,12 @@ class PurchaseLiveAutomationTest {
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg11")
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
// #335: the confirmation-page fixture now wraps the masked phone in a clickable
|
||||
// row (required so the page satisfies #331 REQUIRED_EVIDENCE panel recognition,
|
||||
// which finalSubmitTargets depends on). That changes address entry activation
|
||||
// from a bare TAP to a CLICK through the row's path, same as production PDD rows.
|
||||
assertEquals(0, driver.addressTaps)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
val final = automation.finalConfirmation(input(), address)
|
||||
assertEquals("_cg11", final.addressSuffix)
|
||||
@@ -872,38 +877,50 @@ class PurchaseLiveAutomationTest {
|
||||
"unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList())
|
||||
"pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页")))
|
||||
else -> {
|
||||
// #335: the confirmation page must satisfy PddScreenParser's spec-panel
|
||||
// recognition (#331 REQUIRED_EVIDENCE fallback: address entry + payment
|
||||
// entry + one quantity input, all sharing the "panel" container) so the
|
||||
// new bottom-most-clickable-node submit rule has a recognized panel to
|
||||
// operate on. "panel/submit" keeps the legacy exact "提交订单" label so
|
||||
// existing click-driven state transitions below stay unchanged.
|
||||
val nodes = mutableListOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
|
||||
node("quantity", "2", className = "android.widget.EditText"),
|
||||
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
|
||||
node("panel", "", scrollable = true, parentPath = "root", bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 420, 300, 470)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 480, 700, 530)),
|
||||
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 560, 600, 620)),
|
||||
node("panel/payment-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 640, 1060, 710)),
|
||||
node("panel/payment-row/label", "微信支付", parentPath = "panel/payment-row", bounds = NodeBounds(40, 650, 300, 700)),
|
||||
node("panel/submit", "提交订单", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1900, 1060, 2080)),
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
when {
|
||||
duplicateSemanticAddressCards -> {
|
||||
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
|
||||
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
|
||||
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
|
||||
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
|
||||
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
|
||||
nodes += node("panel/address-layer-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-layer-a/phone", "138****5678", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-layer-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-layer-b/phone", "138****5678", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
}
|
||||
duplicateAddressCards -> {
|
||||
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
|
||||
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
|
||||
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
|
||||
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
|
||||
nodes += node("panel/address-card-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card-a/phone", "138****5678", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card-a/detail", "广东省广州市天园街道一号", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-card-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-card-b/phone", "138****5678", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-card-b/detail", "广东省广州市天园街道二号", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
}
|
||||
duplicatePhoneNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
|
||||
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
|
||||
nodes += node("panel/address-card", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card/phone-a", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card/phone-b", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 862, 440, 892))
|
||||
}
|
||||
else -> {
|
||||
nodes += node("panel/address-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-row/phone", "138****5678", parentPath = "panel/address-row", bounds = NodeBounds(20, 830, 400, 890))
|
||||
}
|
||||
else -> nodes += node("phone", "138****5678")
|
||||
}
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
@@ -911,10 +928,10 @@ class PurchaseLiveAutomationTest {
|
||||
when {
|
||||
hideConfirmationSuffix -> nodes += node("address", addressBody)
|
||||
splitConfirmationAddress -> {
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840))
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 1000, 900, 1080))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 1090, 300, 1150))
|
||||
if (duplicateConfirmationSuffix) {
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 1090, 780, 1150))
|
||||
}
|
||||
}
|
||||
else -> nodes += node("address", address)
|
||||
|
||||
+122
-2
@@ -9,6 +9,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
@@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal skips missing scroll target regardless of panel classification`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
panelOverride = SpecPanelFixtures.liveShapedSheet(),
|
||||
purchaseSwipeSucceeds = false,
|
||||
)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, probes)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal with a scroll target still fails on gesture failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { driver.browser = true; true }, { probes++; "{}" },
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed"))
|
||||
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
|
||||
assertFalse(outcome.message.contains("黑色"))
|
||||
assertFalse(outcome.message.contains(input().goodsId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal does not skip a missing accessibility root`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
|
||||
))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal stops remaining swipes after target disappears`() {
|
||||
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1),
|
||||
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
|
||||
))
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrollable spec reveal preserves configured swipe count and interval`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing scroll target outside spec reveal remains a mandatory action failure`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
val configured = rule().replace(
|
||||
"\"waitAfterMs\":700}",
|
||||
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("scrollables=0;reveal=false"))
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipping a reveal does not skip a failed specification probe`() {
|
||||
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(1, probes)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -781,7 +890,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
|
||||
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
@@ -1248,6 +1358,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
private val panelOverride: UiSnapshot? = null,
|
||||
private val revealSwipeOutcomes: MutableList<PurchaseSwipeOutcome> = mutableListOf(),
|
||||
initiallyInAgent: Boolean = false,
|
||||
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
@@ -1350,6 +1462,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
panelOverride?.let { return it }
|
||||
if (unrecognizedPanel) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
@@ -1531,6 +1644,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
|
||||
if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs)
|
||||
val outcome = revealSwipeOutcomes.removeAt(0)
|
||||
if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs)
|
||||
return outcome
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseSpecProbePolicyTest {
|
||||
|
||||
@Test
|
||||
fun `zero dimensions is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with no values is treated as empty`() {
|
||||
assertTrue(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null blank or malformed payload is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with at least one value is not empty`() {
|
||||
assertFalse(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
|
||||
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves a non-empty probe result unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves other outcome types unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
|
||||
assertEquals(
|
||||
null,
|
||||
PurchaseSpecProbePolicy.extractCollectorFailureCode(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
|
||||
class SpecOptionDedupTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
@Test
|
||||
fun `nested clickable nodes of one option block form one value`() {
|
||||
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
|
||||
val colors = screen.dimensions.single { it.key == "color" }.values
|
||||
val sizes = screen.dimensions.single { it.key == "size" }.values
|
||||
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
|
||||
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
|
||||
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
|
||||
assertEquals(5, sizes.size)
|
||||
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unconfirmed selection reports parsed values selected flags and target`() {
|
||||
// The click never changes the page, so the white option stays unselected.
|
||||
val driver = object : PurchaseUiDriver {
|
||||
var opened = false
|
||||
override fun capture(): UiSnapshot =
|
||||
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
}
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
|
||||
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
|
||||
assertFalse(evidence.contains("****"))
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 332,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "白条纹",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
|
||||
/**
|
||||
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
|
||||
* PDD purchase sheets, but every name, phone number and address is fake.
|
||||
*/
|
||||
internal object SpecPanelFixtures {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
const val FAKE_PHONE = "139****0000"
|
||||
|
||||
class Tree(private val screenBottom: Int) {
|
||||
val nodes = mutableListOf<SnapshotNode>()
|
||||
|
||||
init {
|
||||
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
|
||||
}
|
||||
|
||||
fun add(
|
||||
path: String,
|
||||
text: String,
|
||||
bounds: NodeBounds,
|
||||
className: String = "android.widget.TextView",
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
visible: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
description: String? = null,
|
||||
) {
|
||||
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
|
||||
nodes += SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable, selected, false, enabled, visible,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
|
||||
}
|
||||
|
||||
/** Options for the purchase sheet shaped like the two unselected real samples. */
|
||||
data class Sheet(
|
||||
val screenBottom: Int = 2216,
|
||||
val address: Boolean = true,
|
||||
val payment: Boolean = true,
|
||||
val quantityInput: Boolean = true,
|
||||
val adjustButtons: Boolean = true,
|
||||
val close: Boolean = true,
|
||||
val summary: Boolean = true,
|
||||
val sizeDimension: Boolean = true,
|
||||
val listScrollable: Boolean = true,
|
||||
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
|
||||
val submit: String = "visible",
|
||||
val offset: Int = 0,
|
||||
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
|
||||
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
|
||||
)
|
||||
|
||||
fun sheet(options: Sheet = Sheet()): UiSnapshot {
|
||||
val o = options.offset
|
||||
val t = Tree(options.screenBottom)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
|
||||
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
|
||||
if (options.close) {
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
}
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
|
||||
if (options.address) {
|
||||
// Address row: clickable row five levels above the masked phone text.
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
|
||||
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
|
||||
}
|
||||
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
|
||||
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
|
||||
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
|
||||
if (options.adjustButtons) {
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
}
|
||||
if (options.quantityInput) {
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
}
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
|
||||
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
|
||||
options.colorLabels.forEachIndexed { index, label ->
|
||||
val step = if (options.colorLabels.size > 3) 258 else 346
|
||||
val w = if (options.colorLabels.size > 3) 240 else 316
|
||||
val left = 36 + index * step
|
||||
val block = "$list/color/o$index"
|
||||
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
|
||||
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
|
||||
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
|
||||
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
|
||||
}
|
||||
if (options.sizeDimension) {
|
||||
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
|
||||
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
|
||||
options.sizeLabels.forEachIndexed { index, label ->
|
||||
val top = 1779 + (index / 2) * 85
|
||||
val left = 36 + (index % 2) * 420
|
||||
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
|
||||
}
|
||||
}
|
||||
if (options.payment) {
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
|
||||
}
|
||||
when (options.submit) {
|
||||
"visible" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
|
||||
}
|
||||
"hidden" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
|
||||
}
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fallback sheet: no bounded scrollable, a single heading, no
|
||||
* summary, no +/- and no readable submit. Only the #331 required-evidence
|
||||
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
|
||||
*/
|
||||
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Shape of the 13 goods 8580 failures reported in #331:
|
||||
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
|
||||
* One bounded scrollable, two headings, nine options, +/- and input present,
|
||||
* but no selection summary and no readable submit action.
|
||||
*/
|
||||
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(
|
||||
summary = false,
|
||||
submit = "hidden",
|
||||
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
|
||||
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* #331 counter-example: a masked-phone row inside an address dialog, a
|
||||
* payment row inside a separate payment dialog and a quantity input in a
|
||||
* third place, together with a parsed option group. Every item exists but
|
||||
* they do not share one panel container.
|
||||
*/
|
||||
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
val root = if (fullScreenWrapper) {
|
||||
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
|
||||
"r/content"
|
||||
} else "r"
|
||||
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
|
||||
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
|
||||
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
|
||||
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
|
||||
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
|
||||
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
|
||||
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
|
||||
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
|
||||
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
|
||||
fun taskOptionDedupSheet(): UiSnapshot {
|
||||
val t = Tree(2328)
|
||||
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
|
||||
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
|
||||
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
|
||||
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
|
||||
val blue = "$list/color/o0"
|
||||
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
|
||||
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
|
||||
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
|
||||
val white = "$list/color/o1"
|
||||
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
|
||||
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
|
||||
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
|
||||
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
|
||||
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
|
||||
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
|
||||
listOf(
|
||||
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
|
||||
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
|
||||
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
|
||||
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
|
||||
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
|
||||
).forEachIndexed { index, (label, bounds, selected) ->
|
||||
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
|
||||
}
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun productDetailPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
|
||||
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
|
||||
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
|
||||
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
|
||||
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
|
||||
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
|
||||
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
|
||||
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun reviewPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
|
||||
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
|
||||
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
|
||||
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressListPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
listOf(0, 1).forEach { index ->
|
||||
val top = 300 + index * 260
|
||||
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
|
||||
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
|
||||
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressEditPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
|
||||
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
|
||||
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun paymentMethodDialog(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
|
||||
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
|
||||
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
|
||||
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun postSubmitPaymentPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
|
||||
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
|
||||
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
}
|
||||
|
||||
fun quantityOnlyPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
|
||||
class SpecPanelRecognitionTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
// --- Recognition -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
|
||||
val first = parse(SpecPanelFixtures.sheet())
|
||||
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
|
||||
|
||||
listOf(first, samsung).forEach { screen ->
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
}
|
||||
// The zero-size submit label is not visible, so the submit action is absent.
|
||||
assertFalse(samsung.hasOrderSubmitAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live shaped samples are recognized only through the required evidence fallback`() {
|
||||
listOf(2216, 2020).forEach { bottom ->
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertEquals(0, screen.panelScrollableCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertFalse(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasQuantityAdjustControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
|
||||
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing address entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
|
||||
assertFalse(screen.hasAddressEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
|
||||
assertFalse(screen.hasPaymentEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing quantity input is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
|
||||
assertFalse(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required items without any auxiliary evidence are not recognized`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
|
||||
|
||||
val screen = parse(t.snapshot())
|
||||
|
||||
assertTrue(screen.hasRequiredPanelEvidence)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quantity input alone is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non panel pages are never recognized as spec panels`() {
|
||||
mapOf(
|
||||
"product" to SpecPanelFixtures.productDetailPage(),
|
||||
"review" to SpecPanelFixtures.reviewPage(),
|
||||
"addressList" to SpecPanelFixtures.addressListPage(),
|
||||
"addressEdit" to SpecPanelFixtures.addressEditPage(),
|
||||
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
|
||||
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
|
||||
).forEach { (name, snapshot) ->
|
||||
val screen = parse(snapshot)
|
||||
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(name, screen.specPanelOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole sheet clickable containers do not count as an address row`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
assertFalse(parse(t.snapshot()).hasAddressEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single dimension sheet without adjust buttons is recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertEquals(listOf("color"), screen.dimensions.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required evidence diagnostics hold booleans and counts only`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet())
|
||||
val summary = screen.requiredEvidenceSummary()
|
||||
|
||||
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
|
||||
assertFalse(summary.contains("****"))
|
||||
assertFalse(summary.contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
|
||||
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
|
||||
|
||||
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
|
||||
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
|
||||
assertEquals(1, screen.panelScrollableCount)
|
||||
assertEquals(2, screen.panelHeadingCount)
|
||||
assertEquals(9, screen.panelOptionCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertTrue(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
}
|
||||
|
||||
// --- Same panel container --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `required items inside one panel container are recognized`() {
|
||||
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
|
||||
val screen = parse(snapshot)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
|
||||
listOf(false, true).forEach { wrapper ->
|
||||
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
|
||||
|
||||
// Every required item exists somewhere on screen, plus auxiliary
|
||||
// options and a close control, but not inside one panel container.
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
|
||||
assertFalse(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Purchaser hints for missing required items --------------------------
|
||||
|
||||
@Test
|
||||
fun `missing required items produce purchaser hints`() {
|
||||
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
|
||||
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
|
||||
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
|
||||
|
||||
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
|
||||
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
|
||||
// Unrelated pages without any required item or option do not get environment hints.
|
||||
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing payment hint`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
|
||||
assertFalse(outcome.message.orEmpty().contains("****"))
|
||||
assertEquals(0, driver.backs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
|
||||
mapOf(
|
||||
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
|
||||
Sheet(quantityInput = false) to "未找到购买数量输入框",
|
||||
).forEach { (options, hint) ->
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save timeout message carries the missing address hint`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
|
||||
}
|
||||
|
||||
// --- Bounded wait after the first spec-entry click -----------------------
|
||||
|
||||
@Test
|
||||
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val ready = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = SpecEntryDriver(listOf(loading))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
|
||||
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fallback sheet whose structure keeps changing is never accepted early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
|
||||
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
|
||||
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stronger panel type is still accepted immediately`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
|
||||
}
|
||||
|
||||
// --- Bounded wait after the address save ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `address save waits for the loading sheet without pressing back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
|
||||
|
||||
assertEquals("_cg31", proof.suffix)
|
||||
assertEquals(0, driver.backs)
|
||||
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
|
||||
assertTrue(done.contains("outcome=final_evidence"))
|
||||
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertTrue(done.contains("backPressed=false"))
|
||||
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a sheet that never completes fails at the bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
assertFalse(error.message.orEmpty().contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with an unstable fallback sheet does not decide early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
|
||||
val sheet = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
// The stable sheet has no saved suffix and no scroll region, so the
|
||||
// existing in-panel restore path fails closed; it must not press Back.
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
}
|
||||
|
||||
// --- Strict final submit click -------------------------------------------
|
||||
|
||||
// #335: the order button's text keeps changing across PDD builds (促销文案/价格文案),
|
||||
// so the click target is now the bottom-most clickable, enabled, non-zero-size node of
|
||||
// a *recognized* spec panel, not a text match. These fixtures build on
|
||||
// [SpecPanelFixtures.sheet] (address + payment + quantity present, so #331 recognizes
|
||||
// the panel) with `submit = "none"` and then splice in the bottom-row scenario under
|
||||
// test, mirroring how [readySheet] already extends a base fixture elsewhere in this file.
|
||||
|
||||
@Test
|
||||
fun `an unrecognized page never yields a submit target`() {
|
||||
// #335: the button's own text no longer has to match any known marker; this used
|
||||
// to fail as "labelMismatch" and must now succeed instead (see the dedicated
|
||||
// wording tests below). Only "the page is not a recognized spec panel" still blocks.
|
||||
val driver = StaticDriver(SpecPanelFixtures.addressListPage())
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "立即支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
// The project-wide page-problem guard (pageProblem) rejects any visible payment
|
||||
// wording before the submit-target logic even runs; either way, nothing is clicked.
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose subtree carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "确认支付 ¥23.99", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label is go-pay is never a click target`() {
|
||||
// "去支付" is not in PAYMENT_MARKERS (so the global pageProblem guard does not
|
||||
// trip on it — a real 待付款/去支付 order-result page must stay usable), but it
|
||||
// must still block the submit-target logic via SUBMIT_TARGET_BLOCKED_MARKERS.
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "去支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label is pay-fee is never a click target`() {
|
||||
// "付款" alone is not in PAYMENT_MARKERS either (UNPAID_MARKERS' "待付款" contains
|
||||
// it, so adding it to PAYMENT_MARKERS would misclassify every unpaid-order page),
|
||||
// but it must still block the submit-target logic here.
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "付款", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invisible payment word in the bottom node subtree still blocks the click`() {
|
||||
// The project-wide pageProblem guard only scans *visible* labels; an invisible
|
||||
// descendant would slip past it, so the submit-target logic must catch it on its
|
||||
// own (#335 safety condition 3 covers "自身或子树文字" regardless of visibility).
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val hiddenPaymentText = SnapshotNode(
|
||||
"r/submit/hidden", "r/submit", "输入支付密码", null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 0, 0, 0), false, false, false, false, true, false,
|
||||
)
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
hiddenPaymentText,
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size bottom node is ignored in favor of the next non-zero labelled node`() {
|
||||
// The click target is the labelled LEAF node (#335 2nd revision); the button
|
||||
// carries its own label directly here (no separate text child) to make the
|
||||
// assertion check the real click target.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
|
||||
// Zero-*area* (right==left) and lower on screen: must never win despite
|
||||
// having its own non-blank label and a clickable ancestor.
|
||||
node("r/decoy", "¥0.0", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two side by side bottom buttons pick the rightmost and are reported as a tie`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
// Avoid quickBuyAliases ("现在买") in either label: it would flip specPanelType
|
||||
// to QUICK_CONFIRMATION, which is intentionally outside the recognized set here.
|
||||
node("r/a", "限时优惠 ¥19.6", NodeBounds(0, 2000, 540, 2216), clickable = true, parent = "r"),
|
||||
node("r/b", "大促价,¥16.9", NodeBounds(540, 2000, 1080, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
|
||||
// r/b is the rightmost of the tied bottom row, so it is the one clicked.
|
||||
assertEquals(listOf("大促价,¥16.9"), driver.clicked)
|
||||
assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("rowTie=1") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once regardless of its wording`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task 570 promo priced bottom row picks the bottom-right-most labelled leaf`() {
|
||||
// #335 origin: goods 8580 task 570, the bottom button's text was split across
|
||||
// three sibling text nodes ("大促价," / "仅 ¥30.8" / "¥39.9") instead of one
|
||||
// "提交订单"-prefixed label. This checks finalSubmitTargets' own choice at the
|
||||
// SnapshotNode level (fast, no re-find simulation); the full live re-find +
|
||||
// climb-to-clickable-ancestor path is covered separately below with
|
||||
// ReFindingDriver, which is what actually caught the task 594 regression.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2181, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/l1", "大促价,", NodeBounds(40, 2190, 300, 2210), parent = "r/submit"),
|
||||
node("r/submit/l2", "仅 ¥30.8", NodeBounds(320, 2190, 600, 2210), parent = "r/submit"),
|
||||
node("r/submit/l3", "¥39.9", NodeBounds(620, 2190, 780, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
// #335 2nd revision: the target is the labelled leaf (bottom edge tied across
|
||||
// l1/l2/l3, rightmost wins), NOT the label-less "r/submit" container — a
|
||||
// label-less target can never be re-found live (task 594).
|
||||
assertEquals(listOf("¥39.9"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task 570 leaf target is re-found live and the click lands on the clickable container`() {
|
||||
// #335 2nd revision (task 594 real-device failure): StaticDriver's clickFresh just
|
||||
// records target.label directly, so it cannot catch a target that a REAL driver
|
||||
// could never re-find. ReFindingDriver instead emulates
|
||||
// GoAutoAccessibilityService.clickFreshDetailed's real semantics: find the live
|
||||
// node by (label, className, center within ±32), then climb to the nearest live
|
||||
// clickable ancestor and click THAT.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(screenBottom = 2400, submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), clickable = true, parent = "r/sheet"),
|
||||
node("r/sheet/submit/l1", "大促价,", NodeBounds(364, 2225, 568, 2284), parent = "r/sheet/submit"),
|
||||
node("r/sheet/submit/l2", "仅 ¥30.8", NodeBounds(568, 2225, 748, 2284), parent = "r/sheet/submit"),
|
||||
node("r/sheet/submit/l3", "¥39.9", NodeBounds(748, 2222, 896, 2287), parent = "r/sheet/submit"),
|
||||
),
|
||||
)
|
||||
val driver = ReFindingDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
// finalSubmitTargets aims at "r/sheet/submit/l3" ("¥39.9", the bottom-right-most
|
||||
// labelled leaf); the real click must land on its clickable ancestor, the
|
||||
// FrameLayout "r/sheet/submit" — not on the leaf itself.
|
||||
assertEquals("r/sheet/submit", driver.clickedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Samsung 31px bottom bar with only a zero-size label fails explicitly instead of clicking the payment row above it`() {
|
||||
// #335 (3rd revision): reviewer cross-check on real dumps found that on this
|
||||
// Samsung sample the bottom-most row is a 31px FrameLayout whose only text node
|
||||
// is [0,0][0,0] (invisible), while the payment-method row directly above it has a
|
||||
// real, visible label ("使用#微信支付,更换先用后付可0元下单"). A naive whole-panel
|
||||
// leaf scan falls through to that payment row's text and the climb lands the click
|
||||
// on "change payment method" — which must never happen. SpecPanelFixtures'
|
||||
// `submit = "hidden"` branch already models exactly this shape.
|
||||
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "hidden"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
}.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertTrue(diagnostics.any { it.contains("outcome=bottom_row_unlabelled") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payment-method row as the bottom-most clickable row is never a submit target`() {
|
||||
// Without a submit region at all, the payment-method row ("使用#微信支付,更换先用
|
||||
// 后付可0元下单") is itself the bottom-most clickable row. It must be rejected by
|
||||
// its own alias match, not merely skipped in favor of something else.
|
||||
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
}.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertTrue(diagnostics.any { it.contains("outcome=row_blocked") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clickable node outside the recognized panel container is never chosen even if it is bottom most`() {
|
||||
// A hand-built #331 REQUIRED_EVIDENCE panel ("r/panel", bounded, not full-screen)
|
||||
// with its own valid submit row, plus an unrelated clickable bar further down the
|
||||
// same accessibility tree but OUTSIDE "r/panel" — e.g. an underlying goods-detail
|
||||
// page's "单独购买" bar still present behind the sheet. It is visually and
|
||||
// structurally lower on screen (bottom=2400 vs the panel submit's bottom=2100),
|
||||
// so a naive whole-screen "bottom-most clickable node" scan would wrongly pick it.
|
||||
val t = SpecPanelFixtures.Tree(2400)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2100), "android.view.ViewGroup")
|
||||
t.add("r/panel/close", "", NodeBounds(975, 320, 1050, 390), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 340, 1080, 460), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市", NodeBounds(40, 360, 900, 440))
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 500, 1080, 600), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "微信支付", NodeBounds(40, 520, 400, 580))
|
||||
t.add("r/panel/qty", "1", NodeBounds(400, 650, 600, 720), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/submit", "提交订单", NodeBounds(0, 2020, 1080, 2100), "android.widget.FrameLayout", clickable = true)
|
||||
// Outside "r/panel": lower on screen, but not part of the recognized panel.
|
||||
t.add("r/outsideBar", "单独购买", NodeBounds(0, 2200, 1080, 2400), "android.widget.FrameLayout", clickable = true)
|
||||
val snapshot = t.snapshot()
|
||||
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
|
||||
val driver = StaticDriver(snapshot)
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no determinable panel container fails explicitly instead of guessing`() {
|
||||
// Recognized (NORMAL_SCROLLABLE, via the headed-scrollable + selection-summary
|
||||
// branch, which does not require a quantity input at all) but with NO quantity
|
||||
// input anywhere: panelContainerBounds cannot determine a container, so
|
||||
// finalSubmitTargets must fail explicitly rather than fall back to a whole-screen
|
||||
// scan that could pick an unrelated bottom-most clickable node.
|
||||
val recognizedNoQuantity = SpecPanelFixtures.sheet(
|
||||
Sheet(quantityInput = false, adjustButtons = false, submit = "none"),
|
||||
)
|
||||
val screen = PddScreenParser.parse(recognizedNoQuantity, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, screen.specPanelType)
|
||||
|
||||
val snapshot = recognizedNoQuantity.copy(
|
||||
nodes = recognizedNoQuantity.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun readySheet(expected: String): UiSnapshot {
|
||||
// #335 (3rd revision): [SpecPanelFixtures.liveShapedSheet] hardcodes a "hidden"
|
||||
// 31px submit placeholder (its own separate `submit`-shape fixture concern,
|
||||
// unrelated to this address-save-wait test). With the row-then-leaf submit-target
|
||||
// rule that placeholder would tie with "r/final" on the bottom edge and can win
|
||||
// the rightmost tie-break, so this helper builds the same shape directly with
|
||||
// `submit = "none"` instead, leaving "r/final" as the only, unambiguous bottom row.
|
||||
val base = SpecPanelFixtures.sheet(
|
||||
Sheet(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "none"),
|
||||
)
|
||||
val extra = listOf(
|
||||
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
|
||||
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
|
||||
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
|
||||
)
|
||||
return base.copy(nodes = base.nodes + extra)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 331,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "黑色 示例款",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
|
||||
private open class BaseDriver : PurchaseUiDriver {
|
||||
var backs = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
|
||||
override fun capture() = snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* #335 (2nd revision): emulates `GoAutoAccessibilityService.clickFreshDetailed`'s real
|
||||
* re-find semantics against a fixed, static "live" node list — match by (own label,
|
||||
* className, center within ±32 — every fixture node here already carries its own
|
||||
* label so this is equivalent to `preferredOrDescendantLabel()`), then climb to the
|
||||
* nearest clickable ancestor and click THAT, exactly like the real accessibility
|
||||
* service. Exposes the PATH of the node that actually received the click, so a test
|
||||
* can assert the tap landed on the container, not the labelled leaf `finalSubmitTargets`
|
||||
* aimed at.
|
||||
*/
|
||||
private class ReFindingDriver(private val liveSnapshot: UiSnapshot) : BaseDriver() {
|
||||
var clickedPath: String? = null
|
||||
override fun capture() = liveSnapshot
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
val byPath = liveSnapshot.nodes.associateBy { it.path }
|
||||
val matches = liveSnapshot.nodes.filter { candidate ->
|
||||
candidate.label == target.label && candidate.className == target.className &&
|
||||
kotlin.math.abs(candidate.bounds.centerX - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(candidate.bounds.centerY - target.bounds.centerY) <= 32
|
||||
}
|
||||
if (matches.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (matches.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
var node = matches.single()
|
||||
if (target.clickable && !node.clickable) return FreshActionResult.NOT_FOUND
|
||||
while (!node.clickable) {
|
||||
node = node.parentPath?.let(byPath::get) ?: return FreshActionResult.FAILED
|
||||
}
|
||||
clickedPath = node.path
|
||||
clicked += node.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
|
||||
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
|
||||
private var opened = false
|
||||
var panelCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!opened) return SpecPanelFixtures.productDetailPage()
|
||||
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
|
||||
panelCaptures++
|
||||
return panels[index]
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
|
||||
private class AddressSaveDriver(
|
||||
private val afterSave: List<UiSnapshot>,
|
||||
private val cycle: Boolean = false,
|
||||
private val settled: (String) -> UiSnapshot,
|
||||
) : BaseDriver() {
|
||||
private var page = "sheet"
|
||||
private var address = "示例路1号-old"
|
||||
private var afterSaveCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"sheet" -> SpecPanelFixtures.sheet()
|
||||
"list" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
|
||||
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
|
||||
}.snapshot()
|
||||
"edit" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
|
||||
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
|
||||
}.snapshot()
|
||||
else -> {
|
||||
val index = afterSaveCaptures++
|
||||
when {
|
||||
cycle -> afterSave[index % afterSave.size]
|
||||
index < afterSave.size -> afterSave[index]
|
||||
else -> settled(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
when (target.label) {
|
||||
"修改" -> page = "edit"
|
||||
"保存" -> page = "saved"
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||
page = "list"
|
||||
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||
FreshActionResult.SUCCESS,
|
||||
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
@@ -2,8 +2,24 @@
|
||||
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: a0ad0d4ed6b71676b56276d84d713e85c6b206b8
|
||||
synchronized_at: 2026-09-19T03:52:01Z
|
||||
wiki_revision: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
|
||||
synchronized_at: 2026-09-30T01:50:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
|
||||
synchronized_at: 2026-09-27T02:47:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
|
||||
synchronized_at: 2026-09-22T02:56:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -121,6 +137,7 @@ Android Portal/Agent
|
||||
| 虾皮商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700600000_shopee_product_archive.go` |
|
||||
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
|
||||
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
|
||||
| SYB 来源货运单创建时间(#352,待迁移与发布) | `syb_product.syb_stock_created_at` 为可空且带索引的 `t_stock.created`,由 `sybimport/sync.go` 从列表取值,经 `apply.go` 写入;`service.go` 的日期筛选使用该字段。迁移 `server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go` 仅加列和索引,不改历史行;历史由有界 SYB 重同步补齐。 |
|
||||
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
|
||||
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
|
||||
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
|
||||
@@ -349,6 +366,8 @@ PddProductDetailCollector
|
||||
|
||||
## 采购规格面板预滑动兼容(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
|
||||
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
@@ -486,6 +505,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 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验收。
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
## Chrome PDD 订单回填扩展(#316)
|
||||
|
||||
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
||||
@@ -498,3 +523,79 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
分支 fix/325-order-result,2026-09-19,尚未发布及真实下单验收。PurchaseLiveAutomation.readOrderResult 采用25次支付页返回后采样、首次待付款页独立30次采样/最多4次按需滑动;原过渡60次与总90次采样上限保留有界终止。parsePayableAmount只从本次只读标签集合提取明确应付总额。
|
||||
|
||||
数据链路:PurchaseOrderEvidence.pddOrderAmountCent → PurchaseExecutionOutcome → service/PurchaseResultPayload.kt → purchase.ResultRequest → lifecycle.go 的 order_created 分支 → 既有 purchase_task.pdd_order_amount_cent。重启只读核单也沿用同一可选字段。复用已有结果Outbox、幂等哈希和订单号唯一性;不新增表、配置、任务或SYB金额字段。未修改我的订单回填、Chrome扩展和Web,不能将旧入口的实付含义描述为本次已改成应付。
|
||||
|
||||
## Admin 订单资料补全与一致回填资格(#326)
|
||||
|
||||
实现3a2472d(2026-09-19),Server/Web,未合并发布;无迁移、无Android/插件改动。
|
||||
|
||||
- purchase/types.go ManualRequest 增加可选 pddOrderAmountCent;manual.go ResolveUnknown 复用既有行锁事务和模型单号保护,扩展为未知结果确认或已创建订单补缺。仅此操作启用新增重放内容校验和事务内 ensureOrderWriteback,其他人工付款/取消/授权流程不新增入队副作用。
|
||||
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
|
||||
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
|
||||
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
|
||||
|
||||
## 规格面板识别回退与选项去重(#331/#332)
|
||||
|
||||
- `automation/PddProductDetailCollector.kt`:`SpecPanelType.REQUIRED_EVIDENCE` 回退分支(地址入口/支付入口/唯一数量输入框 + 同一面板容器 + ≥1 辅助证据);`ParsedPddScreen` 增加 `hasAddressEntry`、`hasPaymentEntry`、`hasQuantityInput`、`hasQuantityAdjustControls`、`quantityInputCount` 与 `requiredEvidenceSummary()`;`SpecPanelStabilityPolicy`(等待上限 5000ms,地址返回采样 200ms);规格值按选项块合并(`optionBlock`)。
|
||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||
|
||||
## GoAuto 售后角色与退货模块权限(#341)
|
||||
|
||||
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
|
||||
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
|
||||
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
|
||||
|
||||
|
||||
## #338 退货匹配(2026-09-27)
|
||||
|
||||
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||
|
||||
|
||||
## Yeeke 已重出同步与匹配边界(#345)
|
||||
|
||||
- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。
|
||||
- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。
|
||||
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
|
||||
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
|
||||
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
|
||||
## SYB 批量取消退货匹配(#347)
|
||||
|
||||
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
|
||||
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
|
||||
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。
|
||||
|
||||
- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。
|
||||
- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。
|
||||
- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。
|
||||
- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。
|
||||
|
||||
## 档口入库码匹配容错与手动批量重匹配(#350)
|
||||
- `sybinnercode/match.go` 在 SKU/档口证据筛选与分配结果为空时明确受限,不访问空数组;只读单条规划通过 `safePlanRecord` 隔离异常,下一条继续处理,不调用远端写接口。
|
||||
- `failMatchJob` 用独立 10 秒清理上下文收敛中断批次及其 pending/matching 记录;成功计划和回写记录保留。`cmd/api/server.go` 在服务启动时调用 `RecoverInterruptedMatches`,不自动重跑匹配或写入。
|
||||
- rematch 在原行锁事务内检查资格和活动批次占用,排队记录改为 matching,阻止重叠请求;matching 记录不可删除。
|
||||
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
## SYB 过滤商品标记与规则重算(#340)
|
||||
|
||||
- 数据:`syb_product` 新增 `pdd_purchase_excluded`(索引)、`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`;新表 `syb_product_filter_recompute_log`(操作人、两方向变更数、两类跳过数、时间)。迁移 `1789801500000_syb_pdd_purchase_excluded`,只新增,存量行默认需 PDD 采购。
|
||||
- 入库:`sybimport/sync.go applyStockDetail` 命中规则不再 `continue`,把规则快照随明细交给 `apply.go ApplyDetail`;仅新建分支写标记,更新分支保留原标记。
|
||||
- 阶段与隔离:`purchase/process_stage.go` 新增 `pdd_excluded`(「无需采购」);`purchase/return_match_block.go rejectIfPDDExcluded`(`service.create`,覆盖单条/批量)、`batch.go` 预检、`collection_eligibility.go`、`ai_match_eligibility.go`、`task/image_search.go` 统一拒绝;`returnmatch` 的参与阶段包含 `pdd_excluded`。
|
||||
- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。
|
||||
- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。
|
||||
- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。
|
||||
|
||||
- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。
|
||||
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
|
||||
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
|
||||
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
|
||||
|
||||
@@ -2,8 +2,24 @@
|
||||
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: 9320ce910ddb26881aafc4dfa4b497d62c144b5b
|
||||
synchronized_at: 2026-09-19T03:52:04Z
|
||||
wiki_revision: 93335c279bcd660ceb5179ea4809ae8a2d90210c
|
||||
synchronized_at: 2026-10-05T03:50:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
|
||||
synchronized_at: 2026-09-27T02:47:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 7bb3997cfbe96343f63d4f6ef80201146c27c142
|
||||
synchronized_at: 2026-09-22T02:56:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -41,6 +57,10 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
|
||||
## SYB 商品明细
|
||||
|
||||
### 来源创建时间(#352 目标契约,待迁移与发布)
|
||||
|
||||
Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(UTC+8 本地日期)筛选关联商品明细,起止日均包含;这不是商品明细独立创建时间。同步新增或重同步读取列表 `created` 并存入 `syb_product.syb_stock_created_at`;`syb_product.created_at` 保留 GoAuto 首次入库审计时间。来源值缺失或格式异常时明确报错,不能用订单号或入库时间推断。历史空值不命中日期筛选;迁移后按不超过 31 天且受货运单数量上限约束的区间重同步补齐,先后统计空值数量,失败区间可幂等重跑,仍无法取得来源值的记录保持空值并报告。
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
|
||||
|
||||
@@ -50,7 +70,7 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
|
||||
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
|
||||
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
|
||||
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
|
||||
- #353: The Admin SYB product page offers multi-select shops exclusively from managed SYB shops (including disabled shops), up to 50. Selected shops are OR-combined, then AND-combined with order code, parse status, process stage, purchase type, source creation-date and pagination filters. Order-code search accepts up to 100 distinct nonblank exact codes of at most 64 characters each; it does not search Shopee item IDs or titles. Matching uses the same edge-trim/full-width ASCII/case folding as SYB shop sync, without collapsing interior spaces. Legacy API clients can still use shopName substring search, but Admin cannot enter free-form or deleted shops. If a managed shop is renamed, historical products retaining its old name may no longer be found under the new selection; accepted limitation.
|
||||
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
|
||||
|
||||
|
||||
@@ -59,6 +79,7 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- #338/#340 后在「已创建任务」之后、「待人工处理」之前依次插入:有效退货匹配(退货待确认 / 已用退货)→ 无需采购(`pdd_purchase_excluded`)。即进行中、已成功及结果不明的采购任务仍优先显示;失败/取消的旧任务不占主阶段时,标记为无需 PDD 采购的行显示「无需采购」。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
@@ -319,11 +340,11 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
## SYB 档口入库码导入、匹配与回写
|
||||
|
||||
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,不提供独立“匹配”按钮。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
|
||||
- 回写前弹窗展示业务记录数、入库码总数、预计占位明细数和替换旧码数。只有 `ready` 记录可提交;所有服务实例共用数据库租约全局串行执行。
|
||||
- 每个远端写动作只发送一次,动作前重读整张货运单并校验匹配计划未漂移;每件写入后重读确认目标码唯一位于预期明细。超时、5xx、响应无法确认或服务在写入期间重启时转为 `needs_check`,禁止自动重试;“只读复核”只能读取远端状态。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `matching`、`queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
|
||||
## PDD 失效或售罄商品替换(#129)
|
||||
|
||||
@@ -332,7 +353,7 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
- 一个 PDD 商品可能被多个虾皮商品共用,因此规格匹配按受影响的每个虾皮商品分项记录。主表只表达总体进度;Agent 展示和“继续采购”资格必须读取当前任务对应虾皮商品的分项状态。
|
||||
- 选错替代商品时,连续执行 B→C 不等于撤销 A→B,因为 B 可能还关联其他虾皮商品。正确纠错语义是把原 A→B 记录置为 `superseded`,再建立 A→C,并只处理原记录分项中冻结的影响集合。
|
||||
- 创建请求按 `create_request_id` 幂等;重放时源商品、替代商品、来源类型、来源任务、采集证据和发起设备必须一致,否则返回幂等冲突,不能静默覆盖。
|
||||
- `created_by_device_id` 只代表 Agent 设备。当前系统没有设备到采购员账号的绑定,多人多机场景若需要个人责任追踪,必须另建工单实现设备绑定操作员。
|
||||
- `created_by_device_id` 只代表 Agent 设备。设备可由管理员绑定到一个采购员账号;一个采购员可拥有多台设备,一台设备最多归属一个采购员,也允许暂不归属。SYB 商品一键关联/替换按当前采购员选择的归属设备读取该设备最新临时采集,不使用其他账号或其他设备的全局最新记录。
|
||||
|
||||
|
||||
## PDD 商品替换生效与规格匹配(#131)
|
||||
@@ -472,6 +493,8 @@ synchronized_at: 2026-09-19T03:52:04Z
|
||||
|
||||
## 打开采购规格面板后按需滚动(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
@@ -630,6 +653,10 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
||||
@@ -644,3 +671,104 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
|
||||
采购任务保存 PDD 订单号、下单时间和订单金额即为资料完整,不检查是否已付款;金额0元有效。复用 pddOrderNo/orderSubmittedAt/pddOrderAmountCent,金额以人民币分存储。order_created 不改义,仍是已创建订单事实;缺金额展示“订单信息待补全”,不能当作采购失败再次下单。页面统一称“订单金额”,其来源可能是#325应付或既有回填实付,不能将历史值统一称已付金额。
|
||||
|
||||
Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建且缺资料任务提供“补全订单信息”;已有字段只读、缺项可填。保存中禁用提交,失败保留输入;沿用既有权限,不增加审批。非执行中的未知结果人工确认与已有订单资料补全都会事务内保证单号回填记录,不修改支付复核或触发设备操作。
|
||||
|
||||
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
|
||||
|
||||
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
|
||||
|
||||
## 采购管理已回填 SYB 筛选(#327)
|
||||
|
||||
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
|
||||
|
||||
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
|
||||
|
||||
## 规格面板识别与选项去重(#331/#332)
|
||||
|
||||
实现 `0edc78a`(#332)、`d8382e6`(#331),2026-09-22 已合并 main 并发布 Server(Android 0.9.60 仅安装到设备 8);真机采购效果未验证。
|
||||
|
||||
- 规格面板识别在原有判定之后新增回退类型 `REQUIRED_EVIDENCE`:**必须**同时存在收货地址入口(打码手机号所在可点击行)、支付方式修改入口(支付别名所在可点击行)和唯一可用的数量输入框,三者须位于同一规格面板容器内;另需至少一项辅助证据(规格选项、已选/请选择摘要、关闭、加减数量、提交订单)。加减按钮不再是必备条件。原有已识别的面板分类不变。
|
||||
- 地址入口只判断存在与否,不读取、不记录、不上传地址内容;诊断只输出布尔值与计数。
|
||||
- 点击规格入口后、保存地址返回后,若面板仅靠回退类型识别,须连续两次采样结构一致才继续,上限 5 秒;等待期间不按返回键,超时明确失败。稳定且明显不是采购面板的页面(如地址列表)沿用原返回路径。
|
||||
- 提交订单点击保持严格并进一步收紧:必须唯一、可点击、文字匹配,且文字节点及其可点击容器尺寸非零。
|
||||
- 规格选项解析:同一选项块(外层可点击块与其内部文字)只计一个值,文字优先取外层块;不做“零差评/快要抢光”等标签清洗。选中确认失败时诊断列出该维度解析值(截断)、选中状态和目标值。
|
||||
- 缺少必备证据时,失败信息附带可读提示,例如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”“未找到收货地址入口:请确认 PDD 已设置默认收货地址”“未找到购买数量输入框”。
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
## 管理端售后角色(#341)
|
||||
|
||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
|
||||
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
|
||||
|
||||
|
||||
## 退货匹配(#338)
|
||||
|
||||
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||
|
||||
|
||||
## Yeeke 双状态退货同步(#345)
|
||||
|
||||
- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。
|
||||
- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。
|
||||
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
|
||||
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
|
||||
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
|
||||
## SYB 当前页批量取消匹配(#347)
|
||||
|
||||
- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。
|
||||
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
|
||||
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
|
||||
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。
|
||||
|
||||
- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。
|
||||
- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。
|
||||
- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。
|
||||
- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。
|
||||
- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。
|
||||
|
||||
## 档口入库码手动匹配与中断恢复(#350)
|
||||
- 页面首次进入默认本地今天~今天;清除筛选明确取消日期限制。可勾选当前页 pending/failed/skipped 记录点击“匹配 N”;混选只提交合格项,跨营业日期提示分日期处理。加载/列表失败/提交/活动匹配期间禁用匹配。
|
||||
- 手动匹配仍按原精确订单、规格、SKU/档口和数量证据,不可强行选近似商品。空候选明确 skipped;单条规划异常 failed 并继续后续记录;匹配成功仅 ready,不自动回写。
|
||||
- 原 rematch 可接受同日 pending/failed/skipped;已有活动匹配任务占用或其他状态均拒绝;同 requestId 幂等重放,同记录排队为 matching。matching 期间不可删除,成功/回写/需复核记录不能重新匹配。
|
||||
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
|
||||
### 档口入库码排版字符兼容(#350 v2)
|
||||
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
|
||||
|
||||
|
||||
### 档口入库码混选回写范围(#351)
|
||||
列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。
|
||||
预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。
|
||||
|
||||
## SYB 过滤商品入库与「无需采购」(#340)
|
||||
|
||||
- 命中 SYB 过滤规则(结构 `-#` 或关键词)的订单明细不再跳过,而是入库并标记「无需 PDD 采购」,同时保存命中规则快照;店铺过滤不变。
|
||||
- 标记只在明细首次入库时确定;重复同步不改标记,规则启停或删除也不自动改变已有标记。历史被跳过的明细不自动补同步。
|
||||
- 带标记的商品**硬性隔离所有 PDD 流程**:创建采购(单条、批量)、采购预检可采购结果、创建采集资格、AI 规格匹配、图搜采集均由服务端拒绝或判为不可用,错误码 `PURCHASE_PDD_EXCLUDED`;前端隐藏只是辅助。
|
||||
- 带标记的商品可参与退货匹配:匹配后「退货待确认」,确认后「已用退货」,取消后回到「无需采购」。
|
||||
- SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。
|
||||
- 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。
|
||||
- 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。
|
||||
|
||||
## 采购规格面板揭示滑动无目标兼容(#348)
|
||||
|
||||
Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。
|
||||
|
||||
有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: f8996a09b2158253553c9e929eab72e1e53e8f61
|
||||
synchronized_at: 2026-09-18T07:57:06Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Common-Changes
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:12Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:54Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:20Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览与当前 MVP
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 17d42cfee87b84a306bfd991b24ca3f3948f1354
|
||||
synchronized_at: 2026-09-19T03:52:23Z
|
||||
wiki_revision: efd822778c102c9d3c4fd4ea48bda6b59368d698
|
||||
synchronized_at: 2026-10-05T03:46:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -83,12 +83,15 @@ POST /api/admin/v1/syb-products/reparse-batch
|
||||
PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
```
|
||||
|
||||
#353 SYB product list shop filter: Admin sends repeated shopIds query parameters (1-50 managed-shop IDs, OR within the set). shopIds cannot be combined with legacy shopName; malformed, missing, or soft-deleted IDs return 422. Selected IDs resolve against the managed SYB shop list, including disabled shops, and are compared with persisted SYB product shop names using the same trim/full-width ASCII/case normalization used by shop sync. Other filters remain AND conditions, with pagination after filtering. Zero matches return an empty list. The legacy shopName substring filter remains available only to older API clients; the Admin UI uses managed IDs only. Renaming a managed shop may leave historical products under its old name unfindable by that shop selection; this limitation is accepted for #353.
|
||||
|
||||
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
#352 目标契约(待迁移与发布):SYB 商品列表沿用 `createdFrom` / `createdTo` 的 `YYYY-MM-DD` 入参和 Admin「创建时间」标签,但条件改为所属货运单 `t_stock.created` 的 UTC+8 日期,使用左闭右开区间并包含结束日。该条件与店铺、订单号、解析状态、处理阶段、采购类型及分页组合;`sybStockCreatedAt` 是新增可空响应字段,来自 `syb_product.syb_stock_created_at`,不等于 GoAuto 审计字段 `createdAt`。历史来源时间为空者在设置日期条件时不返回,不得回退到 `createdAt`;无日期条件仍可返回。
|
||||
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
|
||||
|
||||
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
|
||||
@@ -115,9 +118,11 @@ GET /api/admin/v1/syb-product-filters
|
||||
POST /api/admin/v1/syb-product-filters
|
||||
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
|
||||
DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
GET /api/admin/v1/syb-product-filters/recompute/preview
|
||||
POST /api/admin/v1/syb-product-filters/recompute/execute
|
||||
```
|
||||
|
||||
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
同步时按 `variationSku` 判定明细是否「无需 PDD 采购」(#340 起命中者照常入库并打标记,不再跳过)。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
|
||||
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
|
||||
`lastChangedBy`、`lastChangedAt`。
|
||||
@@ -126,9 +131,20 @@ DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
|
||||
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
|
||||
|
||||
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
|
||||
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
|
||||
不得写死——写死的数字在数据变化后会给出错误警告。
|
||||
`[必须]` `lastHitCount` 是每条规则各自在上次同步中的**命中次数**(新建与已存在明细都计),
|
||||
由同步结束时回写;未命中的规则写 0,从未同步过为 `null`。页面称「上次同步命中」。
|
||||
列表另返回 `markedCount`(#340):查询时按数据库实际统计的「当前 `pdd_purchase_excluded=true`
|
||||
且 `excluded_rule_id` 为该规则」的明细数,一次分组查询得出。停用 `kind=char` 的二次确认提示
|
||||
条数必须取实时的 `markedCount`,不得写死,也不得用 `lastHitCount` 冒充已标记数量。
|
||||
|
||||
`recompute/preview`、`recompute/execute` 仅管理员可用(其他角色 403)。预览只读,返回
|
||||
`excludedToPdd`、`pddToExcluded`、`skippedHasTask`、`skippedReturnMatch`、最多 20 条
|
||||
`samples`(`orderCode`、`shopeeItemId`、`direction`=`excluded_to_pdd`/`pdd_to_excluded`、
|
||||
`ruleKind`、`ruleKeyword`)及 `fingerprint`。执行请求体 `{"fingerprint": "..."}` 必填(空值 422);
|
||||
服务端在事务内重新规划并比对指纹(涵盖商品、方向、规则 ID、类型、关键词),不一致返回
|
||||
`409 RECOMPUTE_PREVIEW_STALE`(「数据或规则已变化,请重新预览后再执行」)且不写入。
|
||||
执行逐行加锁复查,有采购任务或有效退货匹配的商品跳过;返回实际写入/跳过数量与 `operator`,
|
||||
并写 `syb_product_filter_recompute_log`。
|
||||
|
||||
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
|
||||
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
|
||||
@@ -1166,6 +1182,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
|
||||
### openSpecPanel 后置滑动兼容与诊断(#238)
|
||||
|
||||
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
|
||||
|
||||
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
@@ -1350,7 +1368,7 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
|
||||
实现绑定 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。
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录(#326 起不限制任务状态);网络写入由事务外 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,不能以重放响应判断最终成功。
|
||||
@@ -1377,3 +1395,59 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
|
||||
|
||||
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
|
||||
|
||||
## Admin 订单资料补全与字段型 SYB 入队(#326)
|
||||
|
||||
实现提交 3a2472d,2026-09-19,feat/326-order-information;隔离测试及构建通过,未合并 main、未发布、未执行真实 SYB 写入。
|
||||
|
||||
复用 POST /api/admin/v1/purchase-tasks/:id/resolve-unknown 及其原有管理员/采购员权限:live 的 order_result_unknown 可人工确认订单或取消;live 的 order_created 可补全订单信息,但不能通过此接口改成 cancelled。status=order_created 时沿用 pddOrderNo、orderSubmittedAt,增加可选非负整数分 pddOrderAmountCent,复用原数据库列。订单号非空、无首尾空白/换行且不超过100字符,时间须为有效非零时间;旧客户端省略金额仍接受并保留已有值。新 Admin 补录表单要求金额,0元合法,最多两位小数,转换为整数分后提交。
|
||||
|
||||
已保存非空字段只补缺,不覆盖不同单号、时间或金额;订单号唯一归属仍由原模型保护。相同 requestId/已保存内容重放不重复保存,改变处理结果或已保存字段返回原采购冲突错误。前端网络失败保留输入及相同载荷的 requestId;修改载荷使用新 requestId。复用原审计字段 UnknownResolveRequestID,无新增表或持久化状态。
|
||||
|
||||
资料完整性由订单号、有效下单时间和非空非负订单金额推导,页面显示“信息完整”或“订单信息待补全”。order_created 仍表示已创建订单,缺金额不转 failed,不代表已付款,不重新采购。
|
||||
|
||||
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
|
||||
|
||||
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
|
||||
|
||||
## Admin 采购列表已回填单号筛选(#327)
|
||||
|
||||
实现368f2c2(2026-09-19),feat/327-syb-writeback-filter;测试和构建通过,未合并发布。
|
||||
|
||||
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
|
||||
|
||||
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
|
||||
## 管理端退货匹配取消前提(#347)
|
||||
|
||||
此为 Admin 共享管理接口补充,不改变 Android Agent 契约。
|
||||
|
||||
`POST /api/admin/v1/return-matches/:id/cancel` 可携带 `{"expectedStatus":"matched"}`。仅允许此值;其他值(含空字符串、null)或错误 JSON 返回 HTTP 400 / `INVALID_REQUEST`。省略请求体或属性兼容旧单条取消。
|
||||
|
||||
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
|
||||
|
||||
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
|
||||
|
||||
## Admin 批量使用退货复用确认接口(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。
|
||||
|
||||
- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。
|
||||
- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。
|
||||
- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。
|
||||
- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。
|
||||
|
||||
## Admin 档口入库码 rematch 补充(#350,Agent 接口不变)
|
||||
- `POST /api/admin/v1/syb-inner-codes/rematch` 保留 `{requestId, ids}` 与响应 `{matchJobId, queued}`;原权限与客户端密钥 match 功能权限不变,不新增权限项或迁移。
|
||||
- 同日 pending/failed/skipped 可以排队;matching、ready、updated、already_filled、queued、applying、needs_check 和活动匹配占用拒绝 409。排队事务记录 matching,避免新 requestId 重叠提交;同 requestId 返回原结果。
|
||||
- 任务正常结束沿用 succeeded + ready/failed 分项计数;批次中断/服务启动恢复为 failed 并提供安全说明与 finishedAt,未完成记录 failed 可重新匹配,已完成结果不回滚。匹配仅只读 SYB,成功需另行 apply 确认。
|
||||
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
### openSpecPanel 无滚动目标兼容与诊断(#348)
|
||||
|
||||
版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。
|
||||
|
||||
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
|
||||
|
||||
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:28Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: OnePlus-Real-Device-Acceptance
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:32Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 一加真机验收记录
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:37Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:17Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# PDD 商品详情采集规则迁移分析
|
||||
|
||||
@@ -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: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
|
||||
synchronized_at: 2026-09-18T02:18:53Z
|
||||
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4
|
||||
synchronized_at: 2026-09-29T08:18:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -496,12 +496,14 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分。
|
||||
自 #340 起命中规则的明细**同样入库**,只在 `syb_product` 上标记「无需 PDD 采购」,不再跳过(见本节末尾):
|
||||
|
||||
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
|
||||
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
|
||||
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
|
||||
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
|
||||
- `kind=char`:当前只有一条结构规则 `-#`,`variationSku` 须**同时包含** `-` 和 `#` 才命中
|
||||
(#286 起合并为一条、要求全部字符;代码见 `sybproductfilter.RuleSet.Match` 的 `containsAllRunes`)。
|
||||
这是「档口-供应商#货号」编码格式的判据。`[历史]` #269 初版曾是 `-`、`#` 两条、任一命中
|
||||
(2026-09-11 核验线上 13010 行:含 `#` 8498、含 `-` 6392、两者都含 6386);#286 改为
|
||||
同时包含,只含 `#` 的编码(如 `DD#004`)不再命中结构规则。
|
||||
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
|
||||
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
|
||||
是为档口改用不含 `#` 的编码时兜底。
|
||||
@@ -516,6 +518,15 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
|
||||
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
|
||||
|
||||
`[必须]` **命中规则 = 标记无需 PDD 采购,不再跳过入库(#340)。** 启用店铺内命中结构或关键词规则的
|
||||
明细照常写入 `syb_product`,并记录 `pdd_purchase_excluded=true` 与命中规则快照
|
||||
(`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`)。
|
||||
标记**只在该明细首次创建时**按当次启用规则确定;之后重复同步同一明细不改变标记,
|
||||
规则启停或删除也不会自动改变已有标记,只能由管理员「按当前规则重算」变更。
|
||||
历史上被跳过的明细不自动补同步;需要时按日期手动导入即可补入并打标记。
|
||||
`char_filter_skipped` / `keyword_filter_skipped` 与每条规则的 `last_hit_count` 仍是**本次命中次数**
|
||||
(新建与已存在明细都计),不是「本次被标记的数量」,页面文案须称「命中」。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
@@ -525,6 +536,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
|
||||
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
|
||||
|
||||
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
|
||||
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
|
||||
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
|
||||
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
|
||||
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
|
||||
`not_selected`。
|
||||
|
||||
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
|
||||
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
||||
|
||||
|
||||
@@ -2,21 +2,71 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: c208aeeeb4baa3ae2da29f13f3651910c500e360
|
||||
synchronized_at: 2026-09-18T07:57:16Z
|
||||
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
|
||||
synchronized_at: 2026-09-29T03:47:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
|
||||
synchronized_at: 2026-09-27T02:47:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
|
||||
synchronized_at: 2026-09-22T02:56:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
|
||||
## 当前线上拓扑
|
||||
|
||||
- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。
|
||||
- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。
|
||||
- GoAuto 服务监听:`127.0.0.1:8010`。
|
||||
- 常驻服务:systemd `goauto.service`。
|
||||
- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。
|
||||
- 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。
|
||||
- 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。
|
||||
- Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。
|
||||
|
||||
## Nginx 入口(9527)
|
||||
|
||||
线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 9527 default_server;
|
||||
server_name 122.228.200.167 _;
|
||||
root /home/goauto/current/dist;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
allow all;
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
location / {
|
||||
try_files $uri @goauto_backend;
|
||||
}
|
||||
location @goauto_backend {
|
||||
proxy_pass http://127.0.0.1:8010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。
|
||||
|
||||
## Agent HTTP 例外
|
||||
|
||||
服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置:
|
||||
@@ -33,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。
|
||||
2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200):
|
||||
- `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」;
|
||||
- 前端路由(如 `GET /login`):同样返回 Admin 的 index.html;
|
||||
- `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200;
|
||||
- `GET /api/v1/captcha`:`application/json` 且 `code=200`;
|
||||
- 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401;
|
||||
- 结构日志无 panic/fatal/1146/1054。
|
||||
4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。
|
||||
5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。
|
||||
|
||||
@@ -45,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
## 服务器迁移清单
|
||||
|
||||
更换线上服务器时逐项核对,每项都要在新机上回读确认:
|
||||
|
||||
1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload;
|
||||
2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`;
|
||||
3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度;
|
||||
4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务;
|
||||
5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致;
|
||||
6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服);
|
||||
7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录;
|
||||
8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。
|
||||
|
||||
## 回滚
|
||||
|
||||
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
|
||||
@@ -142,3 +211,38 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 管理员为密钥显式勾选“采购管理 → 回填(writeback)”。建议 HTTPS;若使用项目既有客户端密钥 HTTP 例外,密钥和订单事实会明文传输,风险边界不扩大到 Cookie、JWT 或 Device Token。
|
||||
- popup 关闭不停止运行;重新打开可查看持久状态。设置在运行时只读。停止会保留已确认批次并阻止后续请求;重启或超时仅以原批次 UUID 重放未确认批次。
|
||||
- 排错只查看阶段、已读、可提交、已写入、冲突、未完整扫描原因、脱敏错误码和批次状态;不得记录客户端密钥、地址全文、订单号、Cookie、原始 HTML 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
|
||||
|
||||
## 采购手机准备清单(#331)
|
||||
|
||||
采购员配置或更换采购用 Android 手机时逐项确认:
|
||||
1. 拼多多 App 已登录;
|
||||
2. 微信已登录;
|
||||
3. 拼多多已设置默认收货地址;
|
||||
4. 拼多多默认支付方式为微信支付;
|
||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||
|
||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||
|
||||
## #341 售后角色与账号迁移
|
||||
|
||||
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
|
||||
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
|
||||
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
|
||||
|
||||
|
||||
## #338 退货匹配发布
|
||||
|
||||
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||
|
||||
## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346)
|
||||
|
||||
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
|
||||
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
|
||||
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
|
||||
|
||||
## 档口入库码匹配中断排错与恢复(#350)
|
||||
- 匹配后台总时限仍为 20 分钟,SYB 客户端单请求 60 秒;候选数组越界造成的进程退出不能通过增大时间解决。受控日志查看运行服务的 panic/退出/重启时间,结合匹配 processed/total 与记录状态判断中断;不导出原始生产记录。
|
||||
- 修复版本启动时在接受新任务前将旧 pending/running 匹配批次终结,未完成 pending/matching 记录设为失败,已成功结果保留;仅状态恢复,不自动匹配或 SYB 回写。
|
||||
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:34Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# GoAuto 文档中心
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:41Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-Archive-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:46Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
goautopurchase "go-admin/app/goauto/purchase"
|
||||
goautopurchaserule "go-admin/app/goauto/purchaserule"
|
||||
goautoreplacement "go-admin/app/goauto/replacement"
|
||||
goautoreturnmatch "go-admin/app/goauto/returnmatch"
|
||||
goautorule "go-admin/app/goauto/rule"
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
goautosybimport "go-admin/app/goauto/sybimport"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
goautoyeeke "go-admin/app/goauto/yeeke"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -69,4 +71,6 @@ func InitRouter() {
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||
goautoyeeke.InitRouter(r, authMiddleware)
|
||||
goautoreturnmatch.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST"
|
||||
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
|
||||
// an HTML page, which is far more confusing than a plain 404.
|
||||
func InitSPARouter(engine *gin.Engine) {
|
||||
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index := filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
dist, index, ok := spaIndex()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) {
|
||||
})
|
||||
}
|
||||
|
||||
// spaIndex resolves the built frontend directory and reports whether its
|
||||
// index.html exists.
|
||||
func spaIndex() (dist, index string, ok bool) {
|
||||
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index = filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
return dist, index, false
|
||||
}
|
||||
return dist, index, true
|
||||
}
|
||||
|
||||
// registerRootRoute decides what `GET /` returns (#346).
|
||||
//
|
||||
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
|
||||
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
|
||||
// that forwarded `/` to this server (instead of serving dist itself) showed
|
||||
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
|
||||
// the 2026-09-28 server migration. The welcome page is kept only for
|
||||
// development without a dist, where the frontend runs under vite.
|
||||
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
|
||||
if _, index, ok := spaIndex(); ok {
|
||||
r.GET("/", func(c *gin.Context) { c.File(index) })
|
||||
return
|
||||
}
|
||||
if mode != "prod" {
|
||||
r.GET("/", welcome)
|
||||
}
|
||||
}
|
||||
|
||||
// isAPIPath reports whether a path belongs to the server rather than the SPA.
|
||||
func isAPIPath(path string) bool {
|
||||
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
|
||||
|
||||
@@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
|
||||
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
|
||||
// welcome page, even in non-prod modes where the welcome page used to own `/`.
|
||||
func TestRootServesSPAWhenDistExists(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dist := filepath.Join(t.TempDir(), "dist")
|
||||
if err := os.MkdirAll(dist, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(SPADirEnv, dist)
|
||||
for _, mode := range []string{"dev", "test", "prod"} {
|
||||
engine := gin.New()
|
||||
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
|
||||
InitSPARouter(engine)
|
||||
response := do(engine, http.MethodGet, "/")
|
||||
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
|
||||
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a dist (development under vite) the previous behaviour is kept:
|
||||
// welcome page outside prod, nothing registered in prod.
|
||||
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
|
||||
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
|
||||
|
||||
dev := gin.New()
|
||||
registerRootRoute(dev, "dev", welcome)
|
||||
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
|
||||
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
prod := gin.New()
|
||||
registerRootRoute(prod, "prod", welcome)
|
||||
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod without dist should not register /, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) {
|
||||
go ws.WebsocketManager.SendService()
|
||||
go ws.WebsocketManager.SendAllService()
|
||||
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
r.GET("/", apis.GoAdmin)
|
||||
}
|
||||
registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin)
|
||||
r.GET("/info", handler.Ping)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleYeekeReturns = "yeeke_returns"
|
||||
ModuleYeekeSyncRuns = "yeeke_sync_runs"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -67,6 +69,8 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
ModulePDDProducts,
|
||||
ModuleCollectionTasks,
|
||||
ModulePurchaseTasks,
|
||||
ModuleYeekeReturns,
|
||||
ModuleYeekeSyncRuns,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,6 +105,8 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
{Key: ModuleYeekeReturns, Title: "yeeke 退货包裹", Path: "/yeeke-returns", RouteName: "GoAutoYeekeReturns", Component: "/goauto/yeeke-returns/index", Icon: "goods", Sort: 63, PurchaserDefault: true},
|
||||
{Key: ModuleYeekeSyncRuns, Title: "yeeke 同步记录", Path: "/yeeke-sync-runs", RouteName: "GoAutoYeekeSyncRuns", Component: "/goauto/yeeke-sync-runs/index", Icon: "time", Sort: 64, PurchaserDefault: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -161,6 +167,14 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModulePurchaseTasks
|
||||
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
|
||||
return ModuleAIMatching
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync-runs"):
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync"):
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||
return ModuleYeekeReturns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
|
||||
return ModuleSYBProducts
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
if len(modules) != 15 {
|
||||
t.Fatalf("got %d modules, want 15", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -31,6 +31,30 @@ func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYeekeSyncRunsModuleIsRegisteredAndPurchaserVisible(t *testing.T) {
|
||||
module, ok := ModuleByRouteName("GoAutoYeekeSyncRuns")
|
||||
if !ok {
|
||||
t.Fatal("yeeke sync-runs module not found")
|
||||
}
|
||||
if module.Key != ModuleYeekeSyncRuns {
|
||||
t.Fatalf("unexpected key: %s", module.Key)
|
||||
}
|
||||
if module.Path != "/yeeke-sync-runs" || module.Component != "/goauto/yeeke-sync-runs/index" {
|
||||
t.Fatalf("unexpected route wiring: %#v", module)
|
||||
}
|
||||
if !module.PurchaserDefault {
|
||||
t.Fatal("yeeke sync-runs module must be purchaser-visible per #337")
|
||||
}
|
||||
if len(module.APIs) == 0 {
|
||||
t.Fatal("yeeke sync-runs module must own its APIs")
|
||||
}
|
||||
for _, permission := range module.APIs {
|
||||
if permission.Path == "/api/admin/v1/yeeke-returns" && permission.Method == "GET" {
|
||||
t.Fatal("the return-package list API belongs to yeeke_returns, not yeeke_sync_runs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
|
||||
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
|
||||
if !ok {
|
||||
@@ -56,8 +80,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 64 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..64", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +95,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
|
||||
@@ -3,6 +3,9 @@ package access
|
||||
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
|
||||
const RolePurchaser = "purchaser"
|
||||
|
||||
// RoleAfterSales is the fixed role key for the internal after-sales users.
|
||||
const RoleAfterSales = "after_sales"
|
||||
|
||||
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
|
||||
// APIs that the purchaser role may call; every other API remains admin-only.
|
||||
type APIPermission struct {
|
||||
@@ -124,6 +127,23 @@ var AdminAPIs = []APIPermission{
|
||||
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
|
||||
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
|
||||
|
||||
{"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true},
|
||||
{"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true},
|
||||
{"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true},
|
||||
{"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true},
|
||||
{"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true},
|
||||
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||
|
||||
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
|
||||
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
|
||||
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
|
||||
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
|
||||
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
|
||||
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
|
||||
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
|
||||
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
|
||||
|
||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 13 {
|
||||
if len(s.Modules) != 15 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,14 @@ func (handler Handler) List(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if role, _ := jwt.ExtractClaims(context)["rolekey"].(string); role != "admin" {
|
||||
id := currentUserID(context)
|
||||
if id > 0 {
|
||||
request.OwnerUserID = &id
|
||||
} else {
|
||||
request.OwnerUserID = new(uint64)
|
||||
}
|
||||
}
|
||||
response, err := NewService(db).List(context.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
@@ -37,6 +46,68 @@ func (handler Handler) List(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
// #333: go-admin's Authorizator runs on every request with the
|
||||
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
|
||||
// always 0 there. The JWT "identity" claim is the authenticated user id.
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case uint64:
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (handler Handler) Owners(context *gin.Context) {
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
rows, err := NewService(db).Owners(context.Request.Context())
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": rows})
|
||||
}
|
||||
|
||||
func (handler Handler) SetOwner(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
}
|
||||
if err := decodeJSON(context, &req); err != nil {
|
||||
writeError(context, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if err := NewService(db).SetOwner(context.Request.Context(), deviceID, req.OwnerUserID); err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"deviceId": deviceID, "ownerUserId": req.OwnerUserID}})
|
||||
}
|
||||
|
||||
func (handler Handler) Register(context *gin.Context) {
|
||||
request, err := decodeRegisterRequest(context)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
type ListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
OwnerUserID *uint64
|
||||
}
|
||||
|
||||
type DeviceListItem struct {
|
||||
@@ -36,6 +37,8 @@ type DeviceListItem struct {
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
}
|
||||
|
||||
type DeviceListResponse struct {
|
||||
@@ -68,6 +71,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.OwnerUserID != nil {
|
||||
query = query.Where("owner_user_id = ?", *request.OwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return DeviceListResponse{}, internalError(err)
|
||||
@@ -100,8 +106,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
|
||||
Capabilities: capabilities,
|
||||
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil, OwnerUserID: record.OwnerUserID,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
if taskID, busy := currentTasks[record.ID]; busy {
|
||||
item.Busy = true
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #333: mirrors the real go-admin middleware, whose Authorizator sets
|
||||
// userId to 0 on every request while the JWT claims carry the identity.
|
||||
func listDevicesAs(t *testing.T, handler Handler, claims jwt.MapClaims) []DeviceListItem {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/devices", func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, claims)
|
||||
c.Set("userId", 0)
|
||||
c.Next()
|
||||
}, handler.List)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/devices?page=1&pageSize=20", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data DeviceListResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return body.Data.Items
|
||||
}
|
||||
|
||||
func seedOwnedDevice(t *testing.T, handler Handler, name string, owner *uint64) models.AgentDevice {
|
||||
t.Helper()
|
||||
device := models.AgentDevice{
|
||||
InstallID: "install-" + name, Name: name, Manufacturer: "test", Model: "test",
|
||||
AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", CapabilitiesJSON: "[]",
|
||||
Status: models.DeviceStatusOnline, TokenDigest: fmt.Sprintf("digest-%s", name),
|
||||
TokenIssuedAt: time.Now(), OwnerUserID: owner,
|
||||
}
|
||||
if err := handler.DB.Create(&device).Error; err != nil {
|
||||
t.Fatalf("create device: %v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func deviceNames(items []DeviceListItem) map[string]bool {
|
||||
names := map[string]bool{}
|
||||
for _, item := range items {
|
||||
names[item.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDeviceListPurchaserSeesOnlyOwnDevicesFromJWTIdentity(t *testing.T) {
|
||||
handler := Handler{DB: openTestDatabase(t)}
|
||||
two, three := uint64(2), uint64(3)
|
||||
seedOwnedDevice(t, handler, "caigou1-phone", &two)
|
||||
seedOwnedDevice(t, handler, "caigou2-phone", &three)
|
||||
seedOwnedDevice(t, handler, "unowned-phone", nil)
|
||||
|
||||
names := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}))
|
||||
if len(names) != 1 || !names["caigou2-phone"] {
|
||||
t.Fatalf("purchaser 3 should see only own device, got %v", names)
|
||||
}
|
||||
|
||||
all := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "admin", "identity": float64(1)}))
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("admin should see all devices, got %v", all)
|
||||
}
|
||||
|
||||
none := listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser"})
|
||||
if len(none) != 0 {
|
||||
t.Fatalf("purchaser without identity must see no devices, got %d", len(none))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceOwnerItem struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
NickName string `json:"nickName"`
|
||||
}
|
||||
|
||||
func (service *Service) Owners(ctx context.Context) ([]DeviceOwnerItem, error) {
|
||||
var rows []DeviceOwnerItem
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Select("u.user_id AS user_id, u.username, u.nick_name").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.status <> ? AND r.role_key <> ?", "1", "admin").Order("u.user_id").Scan(&rows).Error; err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (service *Service) SetOwner(ctx context.Context, deviceID uint64, owner *uint64) error {
|
||||
if deviceID == 0 {
|
||||
return invalidRequest("deviceId 无效")
|
||||
}
|
||||
if owner != nil && *owner == 0 {
|
||||
return invalidRequest("ownerUserId 无效")
|
||||
}
|
||||
if owner != nil {
|
||||
var count int64
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.user_id = ? AND u.status <> ? AND r.role_key <> ?", *owner, "1", "admin").Count(&count).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if count != 1 {
|
||||
return invalidRequest("采购员不存在或不可分配")
|
||||
}
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).Where("id = ?", deviceID).Update("owner_user_id", owner)
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOwnerID(value string) (*uint64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, fmt.Errorf("ownerUserId 无效")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.GET("/owners", middleware.RequireRoleKey("admin"), handler.Owners)
|
||||
admin.PATCH("/:deviceId/owner", middleware.RequireRoleKey("admin"), handler.SetOwner)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
|
||||
@@ -41,9 +41,15 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseRun{},
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBSessionAuthLease{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBProductFilterRecomputeLog{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.YeekeSession{},
|
||||
&models.YeekeReturnPackage{},
|
||||
&models.YeekeReturnItem{},
|
||||
&models.YeekeSyncRun{},
|
||||
&models.SYBInnerCodeRecord{},
|
||||
&models.SYBInnerCodeItem{},
|
||||
&models.SYBInnerCodeApplyBatch{},
|
||||
@@ -75,6 +81,9 @@ func MigratedModels() []any {
|
||||
&models.PDDProductReplacement{},
|
||||
&models.PDDProductReplacementItem{},
|
||||
&models.PDDProductReplacementWorkerLease{},
|
||||
&models.ReturnMatch{},
|
||||
&models.ReturnMatchLog{},
|
||||
&models.ReturnMatchBatch{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so
|
||||
// the return can be reused instead of buying the item again on PDD. A match
|
||||
// is "active" (still blocking purchase creation and occupying the return)
|
||||
// while Status is matched or confirmed; cancelling clears both Active*
|
||||
// columns so the unique index frees up both sides for a future rematch
|
||||
// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许).
|
||||
//
|
||||
// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique-
|
||||
// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase:
|
||||
// MySQL unique indexes allow any number of NULL rows, so only the currently
|
||||
// active match for a given side occupies the slot, and a concurrent second
|
||||
// insert for the same side fails the unique constraint instead of double-
|
||||
// booking it.
|
||||
const (
|
||||
ReturnMatchStatusMatched = "matched"
|
||||
ReturnMatchStatusConfirmed = "confirmed"
|
||||
ReturnMatchStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
type ReturnMatch struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
|
||||
SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"`
|
||||
YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"`
|
||||
|
||||
// ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above
|
||||
// while Status is matched/confirmed, and are cleared to NULL on cancel.
|
||||
ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"`
|
||||
ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"`
|
||||
|
||||
Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"`
|
||||
|
||||
// Snapshots of the normalized spec text on both sides at match time, kept
|
||||
// for audit and for the compare screen even if the source rows change
|
||||
// later.
|
||||
SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"`
|
||||
YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"`
|
||||
NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"`
|
||||
|
||||
// PreviousProcessStage records what the SYB product's computed process
|
||||
// stage was immediately before this match, so cancel can report/restore
|
||||
// context; the actual restore is a pure recomputation (no stage is
|
||||
// stored on syb_product), this is for audit/display only.
|
||||
PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"`
|
||||
|
||||
DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"`
|
||||
|
||||
Remark string `json:"remark" gorm:"size:500;not null;default:''"`
|
||||
|
||||
MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"`
|
||||
MatchedAt time.Time `json:"matchedAt"`
|
||||
ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"`
|
||||
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ReturnMatch) TableName() string { return "return_match" }
|
||||
|
||||
// IsActive reports whether the match currently blocks purchase creation and
|
||||
// occupies its return item.
|
||||
func (m ReturnMatch) IsActive() bool {
|
||||
return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it,
|
||||
// which SYB products were submitted and the per-product outcome, so a later
|
||||
// question like "why did only N of my selection match" can be answered from
|
||||
// data instead of reconstructing the page selection. ItemsJSON holds the
|
||||
// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/
|
||||
// matchId) in submission order.
|
||||
type ReturnMatchBatch struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Operator string `json:"operator" gorm:"size:64;not null;default:'';index"`
|
||||
SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"`
|
||||
MatchedCount int `json:"matchedCount" gorm:"not null;default:0"`
|
||||
SkippedCount int `json:"skippedCount" gorm:"not null;default:0"`
|
||||
ItemsJSON string `json:"-" gorm:"type:longtext;not null"`
|
||||
// Error is non-empty when the batch aborted part-way; ItemsJSON then
|
||||
// holds only the rows processed before the failure.
|
||||
Error string `json:"error" gorm:"size:500;not null;default:''"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
}
|
||||
|
||||
func (ReturnMatchBatch) TableName() string { return "return_match_batch" }
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ReturnMatchLog is #338's dedicated operation log for the return-matching
|
||||
// feature. The framework's generic sys_opera_log records raw HTTP
|
||||
// request/response per call and is not queryable per match id nor does it
|
||||
// carry a stable action/operator/detail shape, so this table is written
|
||||
// explicitly, in the same transaction as the action it records (match /
|
||||
// confirm / cancel / remark).
|
||||
const (
|
||||
ReturnMatchLogActionMatched = "matched"
|
||||
ReturnMatchLogActionConfirmed = "confirmed"
|
||||
ReturnMatchLogActionCancelled = "cancelled"
|
||||
ReturnMatchLogActionRemark = "remark"
|
||||
)
|
||||
|
||||
type ReturnMatchLog struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MatchID uint64 `json:"matchId" gorm:"not null;index"`
|
||||
Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"`
|
||||
Operator string `json:"operator" gorm:"size:64;not null;default:''"`
|
||||
Detail string `json:"detail" gorm:"size:1000;not null;default:''"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (ReturnMatchLog) TableName() string { return "return_match_log" }
|
||||
@@ -38,6 +38,7 @@ type AgentDevice struct {
|
||||
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
|
||||
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
|
||||
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId" gorm:"column:owner_user_id;index"`
|
||||
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
@@ -503,6 +504,17 @@ type SYBSession struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SYBSessionAuthLease serializes re-authentication across sync and writeback
|
||||
// workers. Cookies and passwords never live in this table.
|
||||
type SYBSessionAuthLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Username string `gorm:"size:128;not null;uniqueIndex"`
|
||||
Owner string `gorm:"size:36;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
|
||||
|
||||
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
|
||||
// several Shopee product lines, and the same Shopee product can appear more
|
||||
// than once within one order at different colors/sizes/quantities — each such
|
||||
@@ -567,12 +579,47 @@ type SYBProduct struct {
|
||||
// parse-rule change, only the derived fields above may change.
|
||||
RawJSON string `json:"-" gorm:"type:json;not null"`
|
||||
|
||||
// PDDExcluded marks that this row hit an enabled product filter
|
||||
// rule (char/keyword) at the moment it was first created by the sync
|
||||
// (#340). It replaces the old "skip on filter hit" behaviour: a filtered
|
||||
// row is still stored, but every PDD purchase/collection/AI-match entry
|
||||
// point must hard-reject it. The mark is decided once, at creation time
|
||||
// (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME
|
||||
// existing row never changes it, even if the enabled rules changed in
|
||||
// the meantime — only the admin "按当前规则重新计算" action
|
||||
// (sybproductfilter recompute) may flip it, and only for rows with no
|
||||
// purchase task and no active return match.
|
||||
PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"`
|
||||
// ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at
|
||||
// mark time, kept even if the rule is later edited or deleted, so the
|
||||
// mark stays explainable in the UI and in return matching (#340).
|
||||
ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"`
|
||||
ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"`
|
||||
ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"`
|
||||
ExcludedAt *time.Time `json:"excludedAt,omitempty"`
|
||||
// SYBStockCreatedAt is t_stock.created, not GoAuto's first-import CreatedAt.
|
||||
SYBStockCreatedAt *time.Time `json:"sybStockCreatedAt,omitempty" gorm:"index"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBProduct) TableName() string { return "syb_product" }
|
||||
|
||||
// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算"
|
||||
// action (#340 decision 7). Every execute run writes exactly one row here.
|
||||
type SYBProductFilterRecomputeLog struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Operator string `json:"operator" gorm:"size:128;not null;default:''"`
|
||||
ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"`
|
||||
PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"`
|
||||
SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"`
|
||||
SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" }
|
||||
|
||||
func (SYBSession) TableName() string { return "syb_session" }
|
||||
|
||||
// SYBShop is the list of SYB shops whose shipment orders are imported (#49).
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// YeekeSession stores only the opaque session material; credentials are kept
|
||||
// outside the application database and supplied by the administrator at run time.
|
||||
type YeekeSession struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
Username string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_session_username"`
|
||||
Token string `json:"-" gorm:"type:text;not null"`
|
||||
CookiesJSON string `json:"-" gorm:"type:text;not null"`
|
||||
UserID string `gorm:"size:128;not null;default:''"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (YeekeSession) TableName() string { return "yeeke_session" }
|
||||
|
||||
type YeekeReturnPackage struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
ExternalID string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_return_package_external"`
|
||||
OrderSN string `gorm:"size:128;not null;index"`
|
||||
TrackingNo string `gorm:"size:128;not null;index"`
|
||||
ShopID string `gorm:"size:128;not null;default:''"`
|
||||
ShopName string `gorm:"size:255;not null;default:''"`
|
||||
WareCode string `gorm:"size:128;not null;default:''"`
|
||||
WareHouse string `gorm:"size:255;not null;default:''"`
|
||||
WareName string `gorm:"size:255;not null;default:''"`
|
||||
ClaimStatus string `gorm:"size:64;not null;default:''"`
|
||||
// StatusUnrecognized is set when ClaimStatus is not one of the values the
|
||||
// sync code currently understands. It is never bucketed into a known
|
||||
// status silently (#336): the raw value is still kept in ClaimStatus, and
|
||||
// this flag lets an operator find and review these rows.
|
||||
StatusUnrecognized bool `gorm:"not null;default:false;index"`
|
||||
ClaimTime *time.Time
|
||||
CreateTime *time.Time
|
||||
UpdateTime *time.Time
|
||||
DestroyDeadLine *time.Time
|
||||
LastSyncedAt time.Time `gorm:"not null;index"`
|
||||
// SyncStatus is "ok" while the package still appears in a COMPLETE yeeke
|
||||
// sync; #338 sets it to "missing" (with MissingSince stamped) once a
|
||||
// completed sync no longer sees it, so return matching stops using it.
|
||||
// It is never deleted or marked "已销毁" — only flagged unavailable.
|
||||
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
|
||||
MissingSince *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" }
|
||||
|
||||
type YeekeReturnItem struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
PackageID uint64 `gorm:"not null;uniqueIndex:ux_yeeke_return_item_key,priority:1;index"`
|
||||
ExternalKey string `gorm:"size:512;not null;uniqueIndex:ux_yeeke_return_item_key,priority:2"`
|
||||
ItemID string `gorm:"size:128;not null;index"`
|
||||
VariationID string `gorm:"size:128;not null;default:''"`
|
||||
ItemName string `gorm:"size:500;not null;default:''"`
|
||||
VariationName string `gorm:"size:500;not null;default:''"`
|
||||
Image string `gorm:"type:text;not null"`
|
||||
Quantity int64 `gorm:"not null;default:0"`
|
||||
LastSyncedAt time.Time `gorm:"not null;index"`
|
||||
// SyncStatus/MissingSince mirror YeekeReturnPackage's fields (#338): once
|
||||
// a COMPLETE yeeke sync no longer sees this item it is flagged "missing"
|
||||
// so returnmatch.availableReturnPool stops offering it, without ever
|
||||
// deleting the row.
|
||||
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
|
||||
MissingSince *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
|
||||
|
||||
type YeekeSyncRun struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
Status string `gorm:"size:32;not null;index"`
|
||||
Trigger string `gorm:"size:32;not null;index"`
|
||||
TotalPages int `gorm:"not null;default:0"`
|
||||
ReadCount int `gorm:"not null;default:0"`
|
||||
CreatedCount int `gorm:"not null;default:0"`
|
||||
UpdatedCount int `gorm:"not null;default:0"`
|
||||
SkippedCount int `gorm:"not null;default:0"`
|
||||
FailedCount int `gorm:"not null;default:0"`
|
||||
// #338: items flipped to "missing" / back to "ok" by this run.
|
||||
MissingMarkedCount int `gorm:"not null;default:0"`
|
||||
RecoveredCount int `gorm:"not null;default:0"`
|
||||
ErrorMessage string `gorm:"size:1000;not null;default:''"`
|
||||
StartedAt time.Time `gorm:"not null"`
|
||||
FinishedAt *time.Time
|
||||
LastSuccessAt *time.Time
|
||||
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
|
||||
LeaseOwner string `gorm:"size:128;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -30,9 +31,16 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
request := ListRequest{
|
||||
Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
})
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
if role, _ := claims["rolekey"].(string); role != "admin" {
|
||||
id, _ := claims["identity"].(float64)
|
||||
owner := uint64(id)
|
||||
request.CollectionOwnerUserID = &owner
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -64,10 +64,11 @@ type UpdateRequest struct {
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
CollectionOwnerUserID *uint64
|
||||
}
|
||||
|
||||
type ProductView struct {
|
||||
@@ -266,6 +267,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.CollectionOwnerUserID != nil {
|
||||
query = query.Where("EXISTS (SELECT 1 FROM collection_task ct JOIN agent_device ad ON ad.id = ct.device_id WHERE ct.pdd_product_id = pdd_product.id AND ct.source = ? AND ct.status IN ? AND ad.owner_user_id = ?)", models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}, *request.CollectionOwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
|
||||
@@ -205,6 +205,44 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersManualAssociationProductsByOwnedCollectionDevice(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
service := NewService(db)
|
||||
rule := models.CollectionRule{Name: "owned-rule", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
products := []models.PDDProduct{{GoodsID: "910001", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910001", Status: "active"}, {GoodsID: "910002", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910002", Status: "active"}, {GoodsID: "910003", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910003", Status: "active"}}
|
||||
if err := db.Create(&products).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := models.AgentDevice{InstallID: "owned-manual-list", Name: "owned", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "owned-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(41)}
|
||||
other := models.AgentDevice{InstallID: "other-manual-list", Name: "other", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "other-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(42)}
|
||||
if err := db.Create(&owned).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, task := range []models.CollectionTask{{PDDProductID: &products[0].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[1].ID, DeviceID: &other.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[2].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted}} {
|
||||
task.RuleID = rule.ID
|
||||
task.URLSnapshot = products[0].URL
|
||||
task.RuleSnapshot = rule.ContentJSON
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20, CollectionOwnerUserID: ptrUint64(41)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].GoodsID != products[0].GoodsID {
|
||||
t.Fatalf("unexpected owned products: %+v", got.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrUint64(value uint64) *uint64 { return &value }
|
||||
|
||||
func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"}
|
||||
|
||||
@@ -109,6 +109,9 @@ type AdminDetailResponse struct {
|
||||
Matching MatchingView `json:"matching"`
|
||||
}
|
||||
|
||||
// Query-only value: never a PurchaseTask status or a logistics writeback state.
|
||||
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
|
||||
|
||||
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
@@ -119,7 +122,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Status != "" && !validPurchaseStatus(req.Status) {
|
||||
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
|
||||
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
|
||||
}
|
||||
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
|
||||
@@ -132,7 +135,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
if req.Status != "" {
|
||||
if req.Status == adminStatusSYBWritebackSucceeded {
|
||||
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
||||
} else if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.ExecutionMode != "" {
|
||||
|
||||
@@ -105,6 +105,63 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
|
||||
s, first := orderWritebackFixture(t)
|
||||
db := s.DB
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var successIDs []uint64
|
||||
successIDs = append(successIDs, first.ID)
|
||||
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
|
||||
task := first
|
||||
task.ID = 0
|
||||
task.Status = models.PurchaseTaskStatusCancelled
|
||||
task.PDDOrderNo = nil
|
||||
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
|
||||
task.CreateRequestID = uuid.NewString()
|
||||
task.UnknownResolveRequestID = nil
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "none" {
|
||||
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if state == "succeeded" {
|
||||
successIDs = append(successIDs, task.ID)
|
||||
}
|
||||
}
|
||||
for page := 1; page <= 2; page++ {
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
|
||||
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
|
||||
t.Fatalf("page %d: %+v, %v", page, out, err)
|
||||
}
|
||||
}
|
||||
for _, req := range []AdminListRequest{
|
||||
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
|
||||
{Status: models.PurchaseTaskStatusOrderCreated},
|
||||
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
|
||||
} {
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
|
||||
t.Fatalf("combined filter: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{})
|
||||
if err != nil || out.Total != 8 {
|
||||
t.Fatalf("clear filter: %+v %v", out, err)
|
||||
}
|
||||
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
|
||||
if err != nil || out.Total != 0 {
|
||||
t.Fatalf("empty filter: %+v %v", out, err)
|
||||
}
|
||||
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
|
||||
t.Fatal("filter became task state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
|
||||
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ type batchPreviewDataset struct {
|
||||
// 见 #289 与 sybimport.RawSpecHalves。
|
||||
collapsedColorByShopee map[uint64]map[string]bool
|
||||
collapsedSizeByShopee map[uint64]map[string]bool
|
||||
// activeReturnMatchBySYB is #338's return-match stage input: a non-zero
|
||||
// entry means the SYB product currently has an active (matched or
|
||||
// confirmed) return_match row, which forces the 退货待确认/已用退货
|
||||
// stages regardless of what the rest of the pipeline would compute.
|
||||
activeReturnMatchBySYB map[uint64]models.ReturnMatch
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||
@@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
collapsedColorByShopee: make(map[uint64]map[string]bool),
|
||||
collapsedSizeByShopee: make(map[uint64]map[string]bool),
|
||||
activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
@@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
dataset.latestTaskBySYB[*task.SYBProductID] = task
|
||||
}
|
||||
}
|
||||
// #338: load active return matches for these SYB products. This query
|
||||
// only ever ADDS a stage override on top of the pre-#338 computation; an
|
||||
// empty result set (the common case for products never matched) leaves
|
||||
// every other branch of processStageFromDataset byte-for-byte unchanged
|
||||
// (issue #338 acceptance item 11 regression).
|
||||
var activeMatches []models.ReturnMatch
|
||||
if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
for _, m := range activeMatches {
|
||||
dataset.activeReturnMatchBySYB[m.SYBProductID] = m
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
@@ -381,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
|
||||
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
|
||||
// #340: hard-exclude before anything else in the pipeline. This preview
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
}
|
||||
if !sybSpecsTrusted(syb) {
|
||||
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
|
||||
@@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
||||
}
|
||||
// #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)
|
||||
// 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃
|
||||
// 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体
|
||||
// 数字;只有新增的查询确实有界时才允许上调。
|
||||
if queries != 9 {
|
||||
t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", 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)
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
item.CollectionDisabledReason = "尚未关联蝦皮商品"
|
||||
return
|
||||
|
||||
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
if role == "admin" || role == "purchaser" || role == "after_sales" {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := allowedOperator(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -647,12 +647,25 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
|
||||
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI}
|
||||
if !complete {
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码"))
|
||||
// #334: a probe that read zero colors and zero sizes (both dimensions
|
||||
// empty) is a distinct, more specific failure than "candidates present
|
||||
// but none matched the target" — it usually means the spec panel was
|
||||
// over-swiped off the device screen. The Agent now fails this case
|
||||
// explicitly before submitting, but resolveProbedSpecs keeps the same
|
||||
// explicit code as a defense in depth for any spec_probe_completed
|
||||
// result that still arrives empty (e.g. older Agent builds).
|
||||
reason := "规格探测结果没有包含所需的可选颜色或尺码"
|
||||
code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 {
|
||||
reason = "规格探测未读取到任何颜色或尺码"
|
||||
code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码"
|
||||
}
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
decision.FailureCode, decision.FailureMessage = code, message
|
||||
} else {
|
||||
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
|
||||
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
@@ -115,19 +116,31 @@ func (s *Service) Cancel(ctx context.Context, id uint64, req ManualRequest) (mod
|
||||
|
||||
func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) {
|
||||
return s.manual(ctx, id, req, func(t *models.PurchaseTask) error {
|
||||
if t.Status != models.PurchaseTaskStatusOrderResultUnknown {
|
||||
return fail(CodeStateConflict, "任务不是订单结果未知状态")
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || (t.Status != models.PurchaseTaskStatusOrderResultUnknown && t.Status != models.PurchaseTaskStatusOrderCreated) {
|
||||
return fail(CodeStateConflict, "仅正式采购的未知结果或已创建订单允许补录")
|
||||
}
|
||||
if t.Status == models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
return fail(CodeStateConflict, "已创建订单仅允许补全订单信息")
|
||||
}
|
||||
if req.Status != models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusCancelled {
|
||||
return fail(CodeInvalidRequest, "人工处理结果只支持已创建订单或已取消")
|
||||
}
|
||||
now := s.Now()
|
||||
if req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || strings.TrimSpace(req.PDDOrderNo) != req.PDDOrderNo || utf8.RuneCountInString(req.PDDOrderNo) > 100 || strings.ContainsAny(req.PDDOrderNo, "\r\n\t") || req.OrderSubmittedAt == nil || req.OrderSubmittedAt.IsZero() || req.OrderSubmittedAt.Year() < 1000 || req.OrderSubmittedAt.Year() > 9999 {
|
||||
return fail(CodeInvalidRequest, "请填写订单号和下单时间")
|
||||
}
|
||||
if req.PDDOrderAmountCent != nil && *req.PDDOrderAmountCent < 0 {
|
||||
return fail(CodeInvalidRequest, "订单金额不能为负数")
|
||||
}
|
||||
if (t.PDDOrderNo != nil && *t.PDDOrderNo != "" && *t.PDDOrderNo != req.PDDOrderNo) || (t.OrderSubmittedAt != nil && !t.OrderSubmittedAt.Equal(*req.OrderSubmittedAt)) || (t.PDDOrderAmountCent != nil && req.PDDOrderAmountCent != nil && *t.PDDOrderAmountCent != *req.PDDOrderAmountCent) {
|
||||
return fail(CodeResultConflict, "已有不同订单信息,本次未覆盖")
|
||||
}
|
||||
t.PDDOrderNo = &req.PDDOrderNo
|
||||
t.OrderSubmittedAt = req.OrderSubmittedAt
|
||||
if req.PDDOrderAmountCent != nil {
|
||||
t.PDDOrderAmountCent = req.PDDOrderAmountCent
|
||||
}
|
||||
} else {
|
||||
t.CancelledAt = &now
|
||||
t.CancelledBy = &req.OperatorID
|
||||
@@ -140,12 +153,13 @@ func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualReque
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = now
|
||||
return nil
|
||||
})
|
||||
}, true)
|
||||
}
|
||||
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error) (models.PurchaseTask, bool, error) {
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error, orderInfo ...bool) (models.PurchaseTask, bool, error) {
|
||||
var out models.PurchaseTask
|
||||
replayed := false
|
||||
isOrderInfo := len(orderInfo) > 0 && orderInfo[0]
|
||||
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
|
||||
return out, false, fail(CodeInvalidRequest, "requestId 或操作人无效")
|
||||
}
|
||||
@@ -157,6 +171,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
if requestAlreadyApplied(out, req.RequestID) {
|
||||
if isOrderInfo && out.Status != req.Status {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变处理结果")
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated && (out.PDDOrderNo == nil || *out.PDDOrderNo != req.PDDOrderNo || out.OrderSubmittedAt == nil || req.OrderSubmittedAt == nil || !out.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || (req.PDDOrderAmountCent != nil && (out.PDDOrderAmountCent == nil || *out.PDDOrderAmountCent != *req.PDDOrderAmountCent))) {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变订单信息")
|
||||
}
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
@@ -172,6 +192,9 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
return ensureOrderWriteback(tx, out)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
||||
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 {
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
t.Fatal("unrelated business facts changed")
|
||||
}
|
||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderInformationManualCompletion(t *testing.T) {
|
||||
for _, amount := range []int64{0, 1300} {
|
||||
t.Run(taskNumber(uint64(amount)), func(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-MANUAL", OrderSubmittedAt: task.IrreversibleAt}
|
||||
got, _, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || got.Status != models.PurchaseTaskStatusOrderCreated || got.PDDOrderAmountCent != nil {
|
||||
t.Fatalf("legacy save: %v", err)
|
||||
}
|
||||
row := loadOrderWriteback(t, db, task.ID)
|
||||
if row.Status != "pending" {
|
||||
t.Fatal(row.Status)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
req.PDDOrderAmountCent = &amount
|
||||
got, replay, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || replay || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
if got.PaymentReviewStatus != task.PaymentReviewStatus {
|
||||
t.Fatal("payment status changed")
|
||||
}
|
||||
if _, replay, err = s.ResolveUnknown(context.Background(), task.ID, req); err != nil || !replay {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusCancelled
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay status: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusOrderCreated
|
||||
changed := amount + 1
|
||||
req.PDDOrderAmountCent = &changed
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay: %v", err)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("duplicate queue: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationQueueFailureRollsBack(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("326_queue_failure", func(tx *gorm.DB) {
|
||||
if _, ok := tx.Statement.Dest.(*models.PurchaseOrderWriteback); ok {
|
||||
tx.AddError(errors.New("synthetic queue failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Callback().Create().Remove("326_queue_failure")
|
||||
amount := int64(100)
|
||||
_, _, err := s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-ROLLBACK", OrderSubmittedAt: task.IrreversibleAt, PDDOrderAmountCent: &amount})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.PDDOrderNo != nil || got.PDDOrderAmountCent != nil || got.Status != task.Status {
|
||||
t.Fatal("partial transaction saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackFieldBasedEligibility(t *testing.T) {
|
||||
for _, status := range []string{models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusOrderResultUnknown} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
task.Status = status
|
||||
task.OrderSubmittedAt = nil
|
||||
task.PDDOrderAmountCent = nil
|
||||
if err := s.DB.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Existing queued items must also pass the same relaxed worker check.
|
||||
fake := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, fake).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.writes != 1 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
||||
t.Fatal("status/time/amount incorrectly gated worker")
|
||||
}
|
||||
// A failed writeback is selectable for manual compensation.
|
||||
s.DB.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Update("status", "failed")
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || !views[task.ID].CanSubmit {
|
||||
t.Fatalf("view: %v", err)
|
||||
}
|
||||
result, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "pending" {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
s.DB.Model(&models.SYBProduct{}).Where("id = ?", *task.SYBProductID).Update("detail_id", 0)
|
||||
views, err = s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || views[task.ID].CanSubmit {
|
||||
t.Fatal("invalid target selectable")
|
||||
}
|
||||
result, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "skipped" {
|
||||
t.Fatal("invalid target accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationRejectInvalidInputs(t *testing.T) {
|
||||
for _, name := range []string{"negative", "zero_time", "blank_order", "running", "rehearsal"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
amount := int64(100)
|
||||
stamp := *task.IrreversibleAt
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-INVALID", OrderSubmittedAt: &stamp, PDDOrderAmountCent: &amount}
|
||||
switch name {
|
||||
case "negative":
|
||||
amount = -1
|
||||
case "zero_time":
|
||||
stamp = time.Time{}
|
||||
case "blank_order":
|
||||
req.PDDOrderNo = " "
|
||||
case "running":
|
||||
task.Status = models.PurchaseTaskStatusRunning
|
||||
case "rehearsal":
|
||||
task.ExecutionMode = models.PurchaseExecutionModeRehearsal
|
||||
}
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.ResolveUnknown(context.Background(), task.ID, req); err == nil {
|
||||
t.Fatal("invalid input accepted")
|
||||
}
|
||||
if loadBackfillTask(t, db, task.ID).PDDOrderNo != nil {
|
||||
t.Fatal("invalid input persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,11 @@ type OrderWritebackResponse struct {
|
||||
}
|
||||
|
||||
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) != ""
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
func validOrderWritebackTarget(syb models.SYBProduct) bool {
|
||||
return syb.StockID > 0 && syb.DetailID > 0 && syb.StockID <= 1<<63-1 && syb.DetailID <= 1<<63-1
|
||||
}
|
||||
|
||||
// Called within the same transaction as the order fact. Never contacts SYB.
|
||||
@@ -52,24 +56,44 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.StockID <= 0 || syb.DetailID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
|
||||
if !validOrderWritebackTarget(syb) {
|
||||
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
|
||||
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).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))
|
||||
var sybIDs []uint64
|
||||
for _, t := range tasks {
|
||||
if orderWritebackEligible(t) {
|
||||
sybIDs = append(sybIDs, *t.SYBProductID)
|
||||
}
|
||||
}
|
||||
validTargets := map[uint64]bool{}
|
||||
if len(sybIDs) > 0 {
|
||||
var targets []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Select("id", "stock_id", "detail_id").Where("id IN ?", sybIDs).Find(&targets).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
validTargets[target.ID] = validOrderWritebackTarget(target)
|
||||
}
|
||||
}
|
||||
for _, t := range tasks {
|
||||
ids = append(ids, t.ID)
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "需要正式SYB采购任务、订单号及有效SYB明细"}
|
||||
if orderWritebackEligible(t) {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
if validTargets[*t.SYBProductID] {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
} else {
|
||||
v.Reason = "SYB明细关联已失效"
|
||||
}
|
||||
}
|
||||
out[t.ID] = v
|
||||
}
|
||||
@@ -83,9 +107,16 @@ func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.Purcha
|
||||
for _, r := range rows {
|
||||
v := out[r.PurchaseTaskID]
|
||||
v.Status = r.Status
|
||||
v.Reason = r.ErrorMessage
|
||||
if r.ErrorMessage != "" || v.CanSubmit || r.Status == "succeeded" {
|
||||
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()))
|
||||
// A session-class failure's LeaseExpiresAt is the automatic-retry backoff
|
||||
// deadline (#330 修订2, order_writeback_worker.go finishSessionUnavailable),
|
||||
// not an in-flight write lease — manual "resubmit" must stay available
|
||||
// during that window instead of being hidden until it expires.
|
||||
sessionBackoff := r.Status == "failed" && r.ErrorCode == "SYB_SESSION_UNAVAILABLE"
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (sessionBackoff || r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
}
|
||||
return out, nil
|
||||
@@ -141,6 +172,15 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
var target models.SYBProduct
|
||||
if err := tx.First(&target, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(target) {
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
a.Reason = "SYB明细关联已失效"
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if err := ensureOrderWriteback(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,7 +205,12 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
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 {
|
||||
// Manual resubmit resets attempt_count to 0 (#330 修订3) so a stale
|
||||
// history of automatic session-class retries never eats into a
|
||||
// fresh manual attempt budget, and clears lease_expires_at so the
|
||||
// worker's bounded auto-retry claim (which requires it non-nil)
|
||||
// does not race a double-claim against this manual pending row.
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": "", "attempt_count": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// wbFactoryWorker builds a worker whose Factory itself fails, exercising the
|
||||
// restoreOrderWritebackClient failure path (session unavailable) rather than
|
||||
// a remote read/write failure on an otherwise-working client.
|
||||
func wbFactoryWorker(s *Service, err error) *OrderWritebackWorker {
|
||||
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return nil, err }}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureSchedulesBoundedRetry(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.ErrorMessage == "" || len(row.ErrorMessage) > 300 {
|
||||
t.Fatalf("error message not recorded safely: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "会话缺失/已过期") {
|
||||
t.Fatalf("category missing from message: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "将自动重试") || !strings.Contains(row.ErrorMessage, "恢复登录") {
|
||||
t.Fatalf("message is not actionable: %q", row.ErrorMessage)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil || !row.LeaseExpiresAt.After(s.Now()) {
|
||||
t.Fatal("no backoff scheduled for first session-class failure")
|
||||
}
|
||||
if row.AttemptCount != 1 {
|
||||
t.Fatalf("attempt_count=%d", row.AttemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionRetryBackoffTotalExceedsHourlySyncWindow guards the ticket's
|
||||
// blocker: the cumulative auto-retry window must outlast one hourly sync
|
||||
// period (up to ~60 minutes from failure to the refresh that fixes it),
|
||||
// otherwise attempts run out before the session has a chance to recover.
|
||||
func TestSessionRetryBackoffTotalExceedsHourlySyncWindow(t *testing.T) {
|
||||
if len(sessionRetryBackoff) != maxSessionRetryAttempts-1 {
|
||||
t.Fatalf("expected %d backoff steps for %d attempts, got %d", maxSessionRetryAttempts-1, maxSessionRetryAttempts, len(sessionRetryBackoff))
|
||||
}
|
||||
var total time.Duration
|
||||
for _, d := range sessionRetryBackoff {
|
||||
total += d
|
||||
}
|
||||
if total <= time.Hour {
|
||||
t.Fatalf("total backoff %s must exceed one hourly sync period", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureNotReclaimedBeforeBackoffExpires(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed before backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 0 {
|
||||
t.Fatal("wrote while still inside backoff window")
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrderWriteback in order_writeback_test.go takes (t, db, id); provide a
|
||||
// small adapter so this file reads naturally when task id is already in hand.
|
||||
func loadOrderWritebackByTask(t *testing.T, s *Service, id uint64) models.PurchaseOrderWriteback {
|
||||
return loadOrderWriteback(t, s.DB, id)
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureReclaimedAfterBackoffExpires(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
s.Now = func() time.Time { return row.LeaseExpiresAt.Add(time.Second) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("not reclaimed after backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 1 {
|
||||
t.Fatal("did not write after successful reclaim")
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "succeeded" {
|
||||
t.Fatalf("status=%s", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureStopsRetryingAtMaxAttempts(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < maxSessionRetryAttempts; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("attempt %d: ok=%v err=%v", i+1, ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.AttemptCount != i+1 {
|
||||
t.Fatalf("attempt %d: attempt_count=%d", i+1, row.AttemptCount)
|
||||
}
|
||||
if i+1 < maxSessionRetryAttempts {
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatalf("attempt %d: no backoff scheduled", i+1)
|
||||
}
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
} else {
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("lease still scheduled at max attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
// One more tick past any plausible backoff: the claim query must exclude
|
||||
// attempt_count >= maxSessionRetryAttempts, so nothing is claimed.
|
||||
s.Now = func() time.Time { return now.Add(24 * time.Hour) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed a row past max attempts: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionInvalidIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrSessionInvalid).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("ErrSessionInvalid was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionNetworkErrorIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, errors.New("dial tcp: i/o timeout")).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("network error was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func seedWritebackSession(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
store := sybclient.NewSessionStore(s.DB)
|
||||
// restoreOrderWritebackClient's SessionStore.Load compares against real
|
||||
// wall-clock time.Now(), not the service's mocked s.Now (which fixtures
|
||||
// pin to a fixed past date) — so the session must expire relative to the
|
||||
// real clock or Load reports ErrNoSession even though a row exists.
|
||||
if err := store.Save(context.Background(), sybclient.Session{
|
||||
Username: "syb-writeback-test", UserID: 555, CookiesJSON: `[{"name":"SESSION","value":"x"}]`, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWritebackSessionUntouched(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := s.DB.Model(&models.SYBSession{}).Where("username = ?", "syb-writeback-test").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("writeback worker deleted or otherwise removed the cached SYB session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCanSubmitDuringSessionBackoff(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := loadBackfillTask(t, s.DB, task.ID)
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{updated})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := views[task.ID]
|
||||
if v.Status != "failed" || !v.CanSubmit {
|
||||
t.Fatalf("expected resubmit available during backoff: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackManualResubmitResetsAttemptCountAndLease(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
}
|
||||
before := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if before.AttemptCount != 3 {
|
||||
t.Fatalf("attempt_count=%d", before.AttemptCount)
|
||||
}
|
||||
if _, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "pending" || after.AttemptCount != 0 || after.LeaseExpiresAt != nil {
|
||||
t.Fatalf("manual resubmit did not reset state: %+v", after)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok || f.writes != 1 {
|
||||
t.Fatalf("worker could not process post-resubmit row: ok=%v err=%v writes=%d", ok, err, f.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackOtherFailureCodesAreNotAutoRetried(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := &fakeOrderNumberClient{readErr: errors.New("offline")}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_READ_FAILED" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("non-session failure code must not be scheduled for automatic retry")
|
||||
}
|
||||
s.Now = func() time.Time { return row.CreatedAt.Add(24 * time.Hour) }
|
||||
again := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, again).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("a non-session failure code was auto-reclaimed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- restoreOrderWritebackClient against a real sybclient.Client + emulated
|
||||
// SYB /am/user/get, so the CheckSession probe added by #330 is actually
|
||||
// exercised end to end instead of only through a fake Factory. ---
|
||||
|
||||
// sybUserGetServer emulates the one endpoint restoreOrderWritebackClient's
|
||||
// CheckSession call depends on, using the real envelope shape documented in
|
||||
// sybclient/client.go's `envelope` type and asserted against in
|
||||
// sybclient/client_test.go.
|
||||
func sybUserGetServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/am/user/get", handler)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// withWritebackSYBConfig points config.ExtConfig.SYB at the given test
|
||||
// server for the duration of the test, restoring the previous value
|
||||
// afterwards so other tests (and any parallel config reads) are unaffected.
|
||||
func withWritebackSYBConfig(t *testing.T, baseURL, username string) {
|
||||
t.Helper()
|
||||
prev := config.ExtConfig.SYB
|
||||
config.ExtConfig.SYB = config.SYB{BaseURL: baseURL, Username: username}
|
||||
t.Cleanup(func() { config.ExtConfig.SYB = prev })
|
||||
}
|
||||
|
||||
func envelopeOK(w http.ResponseWriter, data any) {
|
||||
body, _ := json.Marshal(data)
|
||||
env, _ := json.Marshal(map[string]any{"status": true, "msg": "获取成功", "data": json.RawMessage(body), "code": nil})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(env)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientValidSessionReturnsClient(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("id") != strconv.FormatInt(555, 10) {
|
||||
t.Fatalf("unexpected id query: %s", r.URL.RawQuery)
|
||||
}
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "syb-writeback-test"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
client, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err != nil || client == nil {
|
||||
t.Fatalf("expected a usable client, got client=%v err=%v", client, err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientMismatchedUsernameIsSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// SYB says the cookie now belongs to a different account (12 §3.5):
|
||||
// treated the same as an explicit logout.
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if !errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("expected ErrSessionInvalid, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClient500IsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 5xx response")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a 5xx must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientTimeoutIsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done() // never respond; the client-side ctx timeout fires first
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := restoreOrderWritebackClient(ctx, s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a request that never completes")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a timeout must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
// TestOrderWritebackWorkerWithRealFactoryOnInvalidSession is the requested
|
||||
// end-to-end case: the worker's actual Factory (restoreOrderWritebackClient)
|
||||
// against a server that reports the cached session invalid. It must record
|
||||
// SYB_SESSION_UNAVAILABLE with a scheduled backoff and must not touch the
|
||||
// cached session row.
|
||||
func TestOrderWritebackWorkerWithRealFactoryOnInvalidSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
w := &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: restoreOrderWritebackClient}
|
||||
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("no backoff scheduled")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
||||
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 {
|
||||
wantTaskWriteback := models.PurchaseWritebackStatusFailed
|
||||
if tc.want == "succeeded" {
|
||||
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
|
||||
t.Fatal("changed purchase/payment/logistics facts")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,20 +24,76 @@ type OrderWritebackWorker struct {
|
||||
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
|
||||
}
|
||||
|
||||
// errSessionUserIDMissing marks a cached session whose UserID column is not a
|
||||
// positive SYB account id. SessionStore.Save (session.go) rejects UserID<=0
|
||||
// before it is ever persisted, so this should be unreachable in practice; it
|
||||
// exists so a corrupted/legacy row fails loudly and safely instead of calling
|
||||
// CheckSession with id=0 (#330 修订1).
|
||||
var errSessionUserIDMissing = errors.New("SYB 会话记录缺少有效 user id")
|
||||
|
||||
// Bounded auto-retry for session-class writeback failures (#330). A session
|
||||
// outage self-heals once GoAutoSYBHourlySync refreshes syb_session, but that
|
||||
// refresh only happens once per hour (at :05) and only fires the run *after*
|
||||
// the session is found dead — so the wait from failure to refresh can be
|
||||
// close to a full hour. The backoff schedule below sums to ~90 minutes
|
||||
// (5+10+15+30+30) across maxSessionRetryAttempts=6 attempts, deliberately
|
||||
// longer than one hourly sync period so a session recovered by "the next"
|
||||
// hourly run is still caught automatically instead of exhausting attempts
|
||||
// first. maxSessionRetryAttempts caps the automatic attempts so a session
|
||||
// that never recovers still lands back in "failed" for a human instead of
|
||||
// retrying forever.
|
||||
const maxSessionRetryAttempts = 6
|
||||
|
||||
var sessionRetryBackoff = []time.Duration{
|
||||
5 * time.Minute,
|
||||
10 * time.Minute,
|
||||
15 * time.Minute,
|
||||
30 * time.Minute,
|
||||
30 * time.Minute,
|
||||
}
|
||||
|
||||
// sessionRetryDelay returns the backoff before the next automatic attempt,
|
||||
// given the attempt number (1-based, i.e. the count already recorded for the
|
||||
// attempt that just failed).
|
||||
func sessionRetryDelay(attempt int) time.Duration {
|
||||
idx := attempt - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sessionRetryBackoff) {
|
||||
idx = len(sessionRetryBackoff) - 1
|
||||
}
|
||||
return sessionRetryBackoff[idx]
|
||||
}
|
||||
|
||||
// sessionUnavailableMessage classifies why the cached SYB session could not
|
||||
// be used, without ever including cookies, tokens or other credential
|
||||
// material (#330 修订1点3). The category — not the raw error text — is what
|
||||
// gets persisted to error_message, wrapped in a fixed, actionable template
|
||||
// that stays well under the 300-char column limit.
|
||||
func sessionUnavailableMessage(err error) string {
|
||||
category := "会话恢复失败(网络/其他)"
|
||||
switch {
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
category = "会话缺失/已过期"
|
||||
case errors.Is(err, errSessionUserIDMissing):
|
||||
category = "会话记录异常,缺少 user id"
|
||||
case errors.Is(err, sybclient.ErrSessionInvalid):
|
||||
category = "会话校验失效"
|
||||
}
|
||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||
}
|
||||
|
||||
// restoreOrderWritebackClient uses the same session acquisition path as SYB
|
||||
// sync. A valid cached session is reused; an explicitly invalid session is
|
||||
// refreshed under the shared database auth lease so concurrent workers do not
|
||||
// request multiple captcha codes.
|
||||
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
|
||||
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
|
||||
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// One short-lived claim at a time across processes. No business writes occur
|
||||
@@ -78,7 +134,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
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 {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
||||
"status = ? OR (status = ? AND lease_expires_at <= ?) OR (status = ? AND error_code = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ? AND attempt_count < ?)",
|
||||
"pending", "running", now, "failed", "SYB_SESSION_UNAVAILABLE", now, maxSessionRetryAttempts,
|
||||
).Order("id").First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recovering = item.Status == "running"
|
||||
@@ -90,6 +149,11 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// tx.Model(&item).Updates used gorm.Expr("attempt_count + 1") above, which
|
||||
// GORM does not read back into the struct; sync it here so downstream
|
||||
// bounded-retry math (finishSessionUnavailable) sees the true post-claim
|
||||
// count instead of being off by one.
|
||||
item.AttemptCount++
|
||||
finish := func(status, code, message string) error {
|
||||
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
|
||||
if status != "unknown" {
|
||||
@@ -98,7 +162,40 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
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
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the admin task state aligned with the authoritative writeback
|
||||
// record. Conflicts and unknown outcomes remain failed until a human
|
||||
// resolves them; they must never appear as successful.
|
||||
taskStatus := models.PurchaseWritebackStatusFailed
|
||||
if status == "succeeded" {
|
||||
taskStatus = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
|
||||
"writeback_status": taskStatus,
|
||||
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
|
||||
}).Error
|
||||
}
|
||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||
// next automatic attempt (item.AttemptCount was already incremented by the
|
||||
// claim above) until maxSessionRetryAttempts is reached, at which point it
|
||||
// behaves like finish("failed", ...) and stops retrying (#330).
|
||||
finishSessionUnavailable := func(err error) error {
|
||||
updates := map[string]any{
|
||||
"status": "failed", "error_code": "SYB_SESSION_UNAVAILABLE",
|
||||
"error_message": sessionUnavailableMessage(err), "lease_owner": "",
|
||||
}
|
||||
if item.AttemptCount < maxSessionRetryAttempts {
|
||||
updates["lease_expires_at"] = w.Now().Add(sessionRetryDelay(item.AttemptCount))
|
||||
} else {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
@@ -108,14 +205,14 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
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 {
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(syb) || 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会话不可用,请恢复登录后重试")
|
||||
return true, finishSessionUnavailable(err)
|
||||
}
|
||||
read := func() (string, string, error) {
|
||||
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by
|
||||
// every PDD entry point, mirroring #338's return-match block tests above.
|
||||
|
||||
func TestCreate_RejectsWhenPDDExcluded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection, got success")
|
||||
}
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("preview call itself must not fail: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible {
|
||||
t.Fatalf("excluded row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.CollectionEligible {
|
||||
t.Fatalf("excluded row must not be collection-eligible: %+v", item)
|
||||
}
|
||||
if item.AIMatchEligible {
|
||||
t.Fatalf("excluded row must not be AI-match-eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodePDDExcluded {
|
||||
t.Fatalf("expected CodePDDExcluded reason, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("excluded row must not be created: %+v", item)
|
||||
}
|
||||
if item.SYBProductID == syb2.ID && !item.Created {
|
||||
t.Fatalf("clean row must still be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessStage_ExcludedRowStage covers priority: active return match wins
|
||||
// over the exclusion mark; once the match is cancelled the stage falls back
|
||||
// to pdd_excluded (#340 decision 3).
|
||||
func TestProcessStage_ExcludedRowStagePriority(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
|
||||
// Cancel: no more active match -> falls back to pdd_excluded, not the
|
||||
// normal pipeline.
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
|
||||
t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID])
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,18 @@ const (
|
||||
ProcessStageTaskCreated = "task_created"
|
||||
ProcessStagePurchaseSucceeded = "purchase_succeeded"
|
||||
ProcessStageOrderReview = "order_review"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
// ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages
|
||||
// added by issue #338: a SYB product with an active return_match blocks
|
||||
// purchase creation regardless of what stage it would otherwise compute
|
||||
// to (see rejectIfActiveReturnMatch and processStageFromDataset below).
|
||||
ProcessStageReturnPending = "return_pending"
|
||||
ProcessStageReturnUsed = "return_used"
|
||||
// ProcessStagePDDExcluded is #340's stage for a SYB product marked
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
var processStageLabels = map[string]string{
|
||||
@@ -35,6 +46,9 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageTaskCreated: "已创建任务",
|
||||
ProcessStagePurchaseSucceeded: "采购成功",
|
||||
ProcessStageOrderReview: "待人工核对",
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -132,6 +146,24 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
}
|
||||
}
|
||||
|
||||
// #338: an active return match overrides the normal computation below —
|
||||
// it only ever fires for a SYB product that has one, so with zero
|
||||
// matches (the pre-#338 default) this branch is a no-op and every stage
|
||||
// below is unchanged (acceptance item 11 regression).
|
||||
if match, ok := dataset.activeReturnMatchBySYB[id]; ok {
|
||||
if match.Status == models.ReturnMatchStatusConfirmed {
|
||||
return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match")
|
||||
}
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded {
|
||||
return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rejectIfActiveReturnMatch implements issue #338's purchase-creation block:
|
||||
// a SYB product with an active (matched or confirmed) return_match row must
|
||||
// not get a NEW purchase task created for it (single or batch — BatchCreate
|
||||
// calls s.Create per row, so this single check point covers both paths and
|
||||
// batch creation simply skips the row via the normal createErr handling,
|
||||
// issue #338 acceptance item 5). Resuming/reparsing an existing task is a
|
||||
// different code path and is not affected.
|
||||
//
|
||||
// On any query error this returns the error (never nil) so a DB failure can
|
||||
// never silently allow a purchase for an already-matched product (issue
|
||||
// #338 acceptance item 11).
|
||||
func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
var match models.ReturnMatch
|
||||
err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error
|
||||
if err == nil {
|
||||
return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务")
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
// point covers both, mirroring rejectIfActiveReturnMatch above).
|
||||
func rejectIfPDDExcluded(syb models.SYBProduct) error {
|
||||
if syb.PDDExcluded {
|
||||
return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// #338 acceptance item 5/6/11: a SYB product with an active return match
|
||||
// must be rejected for NEW single-task creation, batch creation must skip
|
||||
// only that row (not fail the whole batch), and with zero matches (the
|
||||
// default) behavior must be byte-for-byte unchanged.
|
||||
|
||||
func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection, got success")
|
||||
}
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
|
||||
t.Fatalf("expected CodeReturnMatched, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := createLive(t, s, f)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for confirmed match too")
|
||||
}
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
|
||||
t.Fatalf("expected CodeReturnMatched, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("cancelled match must not block creation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5
|
||||
// "batch creation must skip just those rows" regression: one blocked SYB
|
||||
// product and one clean one in the same batch call.
|
||||
func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
|
||||
// second, independent eligible SYB product sharing the same shopee/pdd
|
||||
// chain shape but its own row.
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: f1.syb.ID, YeekeReturnItemID: 1,
|
||||
ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched,
|
||||
MatchedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch call itself must not fail: %v", err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID {
|
||||
if item.Created {
|
||||
t.Fatalf("matched row must not be created: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodeReturnMatched {
|
||||
t.Fatalf("expected CodeReturnMatched reason, got %+v", item)
|
||||
}
|
||||
}
|
||||
if item.SYBProductID == syb2.ID && !item.Created {
|
||||
t.Fatalf("clean row must still be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11
|
||||
// regression: with zero return_match rows anywhere, creation behaves exactly
|
||||
// as it did before #338.
|
||||
func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("zero-match baseline must succeed unchanged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func asServiceError(err error) (*ServiceError, bool) {
|
||||
se, ok := err.(*ServiceError)
|
||||
return se, ok
|
||||
}
|
||||
@@ -178,6 +178,12 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil {
|
||||
return notFound(err, "顺云宝商品不存在")
|
||||
}
|
||||
if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
|
||||
@@ -592,6 +592,47 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te
|
||||
}
|
||||
}
|
||||
|
||||
// #334: a probe with zero colors and zero sizes is a distinct, more specific
|
||||
// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates
|
||||
// present but none matched" message, so operators can tell an over-swiped
|
||||
// device probe apart from an ordinary spec mismatch.
|
||||
func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved)
|
||||
}
|
||||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
|
||||
@@ -36,7 +36,19 @@ const (
|
||||
CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING"
|
||||
CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID"
|
||||
CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodeReturnMatched is returned when a SYB product has an active (matched
|
||||
// or confirmed) return match: issue #338 blocks creating NEW purchase
|
||||
// tasks for such a product until the match is cancelled. Resuming or
|
||||
// reparsing an existing task is not affected.
|
||||
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
|
||||
// CodePDDExcluded is returned by every PDD entry point (purchase create,
|
||||
// collection task creation, AI spec match, image search collection,
|
||||
// quick-replace, ...) when the SYB product is marked
|
||||
// pdd_purchase_excluded (#340): it hit a product filter rule at import
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
@@ -180,10 +192,11 @@ type SpecDecisionRequest struct {
|
||||
}
|
||||
|
||||
type ManualRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #338: every 「匹配退货」 click is recorded with the submitted SYB products in
|
||||
// order and each one's outcome, so "why did only some of my selection match"
|
||||
// can be answered from data.
|
||||
func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
|
||||
miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L", &deadline)
|
||||
missingID := uint64(999999)
|
||||
|
||||
submitted := []uint64{miss.ID, hit.ID, missingID}
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
batches, err := s.ListBatches(context.Background(), 10, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(batches) != 1 {
|
||||
t.Fatalf("want exactly one batch record, got %d", len(batches))
|
||||
}
|
||||
b := batches[0]
|
||||
if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" {
|
||||
t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch)
|
||||
}
|
||||
if len(b.Items) != len(submitted) {
|
||||
t.Fatalf("want %d items, got %+v", len(submitted), b.Items)
|
||||
}
|
||||
for i, id := range submitted {
|
||||
if b.Items[i].SYBProductID != id {
|
||||
t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID)
|
||||
}
|
||||
}
|
||||
if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible {
|
||||
t.Fatalf("unexpected recorded outcomes: %+v", b.Items)
|
||||
}
|
||||
|
||||
got, err := s.GetBatch(context.Background(), b.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Items) != 3 {
|
||||
t.Fatalf("GetBatch items: %+v", got.Items)
|
||||
}
|
||||
|
||||
// The list without items stays small.
|
||||
light, err := s.ListBatches(context.Background(), 10, false)
|
||||
if err != nil || len(light) != 1 || light[0].Items != nil {
|
||||
t.Fatalf("list without items: %+v err=%v", light, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty submission is rejected before matching and leaves no record.
|
||||
func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.ReturnMatchBatch{}).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("empty submission recorded %d batches", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedCancelMatch(t *testing.T, db *gorm.DB, status string) models.ReturnMatch {
|
||||
t.Helper()
|
||||
syb := seedSYB(t, db, "CANCEL-TEST", 1, "白色", "L", time.Now())
|
||||
item := seedReturn(t, db, "白色,L", nil)
|
||||
m := models.ReturnMatch{SYBProductID: syb.ID, YeekeReturnItemID: item.ID, ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &item.ID, Status: status, MatchedAt: time.Now()}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCancelExpectedStatusHTTP(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, body, role string
|
||||
code int
|
||||
}{
|
||||
{"matched", "matched", `{"expectedStatus":"matched"}`, "purchaser", 200},
|
||||
{"confirmed_conflict", "confirmed", `{"expectedStatus":"matched"}`, "after_sales", 409},
|
||||
{"cancelled_conflict", "cancelled", `{"expectedStatus":"matched"}`, "admin", 409},
|
||||
{"legacy_empty", "confirmed", "", "after_sales", 200},
|
||||
{"legacy_object", "confirmed", `{}`, "admin", 200},
|
||||
{"invalid_status", "matched", `{"expectedStatus":"confirmed"}`, "admin", 400},
|
||||
{"invalid_empty", "matched", `{"expectedStatus":""}`, "admin", 400},
|
||||
{"invalid_null", "matched", `{"expectedStatus":null}`, "admin", 400},
|
||||
{"malformed", "matched", `{`, "admin", 400},
|
||||
{"forbidden", "matched", `{"expectedStatus":"matched"}`, "other", 403},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, tc.status)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/cancel", m.ID), strings.NewReader(tc.body))
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "cancel-tester"})
|
||||
Handler{DB: db}.Cancel(c)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
var logs int64
|
||||
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
|
||||
if tc.code == 200 {
|
||||
if saved.Status != "cancelled" || saved.ActiveSYBProductID != nil || saved.ActiveYeekeReturnItemID != nil || saved.CancelledBy != "cancel-tester" || saved.CancelledAt == nil || logs != 1 {
|
||||
t.Fatalf("cancel or audit incomplete: %+v logs=%d", saved, logs)
|
||||
}
|
||||
_, err := NewService(db).Cancel(context.Background(), m.ID, "repeat", "matched")
|
||||
if err != errStateConflict {
|
||||
t.Fatalf("repeat err=%v", err)
|
||||
}
|
||||
} else if saved.Status != tc.status || logs != 0 {
|
||||
t.Fatalf("rejected transition mutated state: %+v logs=%d", saved, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelExpectedStatusMissingAndConfirmedRace(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.Cancel(context.Background(), 99999, "tester", "matched"); err != gorm.ErrRecordNotFound {
|
||||
t.Fatalf("missing err=%v", err)
|
||||
}
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if _, err := s.Confirm(context.Background(), m.ID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A batch holding the earlier matched snapshot must not undo a completed confirmation.
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "batch", "matched"); err != errStateConflict {
|
||||
t.Fatalf("stale batch err=%v", err)
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
if saved.Status != "confirmed" || saved.ActiveSYBProductID == nil {
|
||||
t.Fatalf("confirmation lost: %+v", saved)
|
||||
}
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "legacy"); err != nil {
|
||||
t.Fatalf("legacy confirmed cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelAuditFailureRollsBackTransition(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_cancel_log", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
|
||||
tx.AddError(errors.New("synthetic audit failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(db).Cancel(context.Background(), m.ID, "tester", "matched"); err == nil {
|
||||
t.Fatal("expected audit failure")
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
if err := db.First(&saved, m.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != "matched" || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || saved.CancelledAt != nil {
|
||||
t.Fatalf("failed audit did not roll back: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SYBCandidate is the pure, DB-free view of one selected SYB order product
|
||||
// eligible to participate in this batch's matching (stage filter and "no
|
||||
// active match" filter are applied by the caller before building this list;
|
||||
// see issue #338 rule 1).
|
||||
type SYBCandidate struct {
|
||||
SYBProductID uint64
|
||||
ShopeeItemID string
|
||||
TargetColor string
|
||||
TargetSize string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ReturnCandidate is the pure, DB-free view of one available yeeke return
|
||||
// item (no active match, non-nil destroy deadline; the "deadline later than
|
||||
// now" filter is applied by the caller — see BuildReturnPool below, or by
|
||||
// the caller directly when it already filters in SQL).
|
||||
type ReturnCandidate struct {
|
||||
ReturnItemID uint64
|
||||
ItemID string
|
||||
VariationName string
|
||||
DestroyDeadline time.Time
|
||||
}
|
||||
|
||||
// SkipReason enumerates why a selected SYB candidate was not matched in this
|
||||
// batch, for the batch-result dialog's per-row reasons (issue #338 prototype
|
||||
// screen 2).
|
||||
type SkipReason string
|
||||
|
||||
const (
|
||||
SkipReasonNoCandidate SkipReason = "no_candidate"
|
||||
)
|
||||
|
||||
// MatchOutcome is one row of the pure matching result: either matched to a
|
||||
// return item, or skipped with a reason.
|
||||
type MatchOutcome struct {
|
||||
SYBProductID uint64
|
||||
Matched bool
|
||||
ReturnItemID uint64
|
||||
SkipReason SkipReason
|
||||
NormalizedKey string
|
||||
DestroyDeadline time.Time
|
||||
}
|
||||
|
||||
// SelectMatches implements issue #338 rules 2-6 purely in memory:
|
||||
// - products are processed in the order given by the caller, which MUST be
|
||||
// SYB created_at DESC (rule 4); this function does not itself sort by
|
||||
// CreatedAt so a caller can supply a stable pre-sorted/tie-broken order.
|
||||
// - a return is eligible only while its deadline is strictly after `now`
|
||||
// (rule 2), matched on shopeeItemId + normalized spec text (rule 3);
|
||||
// - once a return is used within this run it cannot be reused by a later
|
||||
// product in the same run, on top of whatever was already occupied
|
||||
// before the run started (rule 4, "已被占用的退货商品不再参与本轮后续
|
||||
// 及以后的匹配");
|
||||
// - when several candidates match the same product, the one with the
|
||||
// earliest destroy deadline is chosen (rule 5); quantity is ignored
|
||||
// entirely (rule 6).
|
||||
func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.Time) []MatchOutcome {
|
||||
// Group available (deadline > now) returns by matchKey = itemId + "|" +
|
||||
// normalized spec text, sorted by deadline ascending so the first unused
|
||||
// entry in each bucket is always the earliest deadline.
|
||||
buckets := make(map[string][]ReturnCandidate)
|
||||
for _, r := range returns {
|
||||
if !r.DestroyDeadline.After(now) {
|
||||
continue
|
||||
}
|
||||
key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName)))
|
||||
buckets[key] = append(buckets[key], r)
|
||||
}
|
||||
for key := range buckets {
|
||||
bucket := buckets[key]
|
||||
sort.Slice(bucket, func(i, j int) bool {
|
||||
return bucket[i].DestroyDeadline.Before(bucket[j].DestroyDeadline)
|
||||
})
|
||||
buckets[key] = bucket
|
||||
}
|
||||
|
||||
used := make(map[uint64]bool)
|
||||
outcomes := make([]MatchOutcome, 0, len(products))
|
||||
for _, p := range products {
|
||||
key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize))))
|
||||
var picked *ReturnCandidate
|
||||
for i := range buckets[key] {
|
||||
cand := buckets[key][i]
|
||||
if used[cand.ReturnItemID] {
|
||||
continue
|
||||
}
|
||||
picked = &buckets[key][i]
|
||||
break
|
||||
}
|
||||
if picked == nil {
|
||||
outcomes = append(outcomes, MatchOutcome{SYBProductID: p.SYBProductID, Matched: false, SkipReason: SkipReasonNoCandidate, NormalizedKey: key})
|
||||
continue
|
||||
}
|
||||
used[picked.ReturnItemID] = true
|
||||
outcomes = append(outcomes, MatchOutcome{
|
||||
SYBProductID: p.SYBProductID, Matched: true, ReturnItemID: picked.ReturnItemID,
|
||||
NormalizedKey: key, DestroyDeadline: picked.DestroyDeadline,
|
||||
})
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func matchKey(itemID, normalizedSpec string) string {
|
||||
return itemID + "|" + normalizedSpec
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func t1(offsetDays int) time.Time {
|
||||
base := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
|
||||
return base.AddDate(0, 0, offsetDays)
|
||||
}
|
||||
|
||||
func TestSelectMatches_BasicMatch(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{
|
||||
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
|
||||
}
|
||||
returns := []ReturnCandidate{
|
||||
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
|
||||
}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if len(out) != 1 || !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||
t.Fatalf("unexpected outcome: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectMatches_MultiColourSameOrderCrossPairing reproduces issue #338's
|
||||
// documented real-world case: the same order buys 2+ colour variants of the
|
||||
// same Shopee item, and each SYB row must pair with the return item of its
|
||||
// OWN colour, never grab whichever candidate is available first.
|
||||
func TestSelectMatches_MultiColourSameOrderCrossPairing(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{
|
||||
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
|
||||
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "紫色", TargetSize: "M", CreatedAt: t1(-2)},
|
||||
}
|
||||
returns := []ReturnCandidate{
|
||||
{ReturnItemID: 901, ItemID: "100", VariationName: "紫色,M【建議43-53公斤】", DestroyDeadline: t1(5)},
|
||||
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
|
||||
}
|
||||
out := SelectMatches(products, returns, now)
|
||||
got := map[uint64]uint64{}
|
||||
for _, o := range out {
|
||||
if !o.Matched {
|
||||
t.Fatalf("expected all matched, got skip: %+v", o)
|
||||
}
|
||||
got[o.SYBProductID] = o.ReturnItemID
|
||||
}
|
||||
if got[1] != 900 {
|
||||
t.Fatalf("white/2XL SYB product should match return 900 (white/2XL), got %d", got[1])
|
||||
}
|
||||
if got[2] != 901 {
|
||||
t.Fatalf("purple/M SYB product should match return 901 (purple/M), got %d", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_ExpiredDeadlineExcluded(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{
|
||||
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(0)}, // not after now -> excluded
|
||||
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(-1)}, // already past -> excluded
|
||||
}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if out[0].Matched {
|
||||
t.Fatalf("expected no match for expired-only candidates, got %+v", out[0])
|
||||
}
|
||||
if out[0].SkipReason != SkipReasonNoCandidate {
|
||||
t.Fatalf("unexpected skip reason: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_EarliestDeadlineFirst(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{
|
||||
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(30)},
|
||||
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(3)}, // soonest to expire, must win
|
||||
{ReturnItemID: 902, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
|
||||
}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if !out[0].Matched || out[0].ReturnItemID != 901 {
|
||||
t.Fatalf("expected earliest-deadline candidate 901, got %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_OccupiedWithinRunNotReused(t *testing.T) {
|
||||
now := t1(0)
|
||||
// Two products with the identical spec, only one return item available:
|
||||
// processed in the given (already created_at DESC) order, the second
|
||||
// product must NOT be able to reuse the same return item.
|
||||
products := []SYBCandidate{
|
||||
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}, // newer, processed first (DESC)
|
||||
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-5)},
|
||||
}
|
||||
returns := []ReturnCandidate{
|
||||
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
|
||||
}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||
t.Fatalf("first (newer) product should win the only candidate: %+v", out[0])
|
||||
}
|
||||
if out[1].Matched {
|
||||
t.Fatalf("second product must not reuse the already-occupied return: %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_QuantityIgnored(t *testing.T) {
|
||||
// SYBCandidate/ReturnCandidate deliberately carry no quantity field at
|
||||
// all: this test documents that omission is intentional (issue #338
|
||||
// rule 6, quantity never participates in matching).
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if !out[0].Matched {
|
||||
t.Fatalf("expected match regardless of any quantity mismatch: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||
t.Fatalf("color-only spec should still match: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if !out[0].Matched || out[0].ReturnItemID != 900 {
|
||||
t.Fatalf("size-only spec should still match: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) {
|
||||
now := t1(0)
|
||||
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
|
||||
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "200", VariationName: "白色,L", DestroyDeadline: t1(10)}}
|
||||
out := SelectMatches(products, returns, now)
|
||||
if out[0].Matched {
|
||||
t.Fatalf("different shopee item id must never match: %+v", out[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #349 reuses the existing single-record transaction; no production batch API.
|
||||
func TestConfirmBatchExistingHTTPContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, role string
|
||||
code int
|
||||
}{
|
||||
{"admin", "matched", "admin", 200},
|
||||
{"purchaser", "matched", "purchaser", 200},
|
||||
{"after_sales", "matched", "after_sales", 200},
|
||||
{"already_confirmed", "confirmed", "after_sales", 409},
|
||||
{"cancelled", "cancelled", "purchaser", 409},
|
||||
{"forbidden", "matched", "viewer", 403},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, tc.status)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"})
|
||||
Handler{DB: db}.Confirm(c)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
var logs int64
|
||||
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
|
||||
if tc.code == 200 {
|
||||
if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 {
|
||||
t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs)
|
||||
}
|
||||
} else if saved.Status != tc.status || logs != 0 {
|
||||
t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound {
|
||||
t.Fatalf("missing=%v", err)
|
||||
}
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a later rematch with the same product/return, but a new identity.
|
||||
replacement := m
|
||||
replacement.ID = 0
|
||||
replacement.CancelledAt = nil
|
||||
replacement.CancelledBy = ""
|
||||
if err := db.Create(&replacement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict {
|
||||
t.Fatalf("stale original=%v", err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict {
|
||||
t.Fatalf("duplicate=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchAuditFailureRollsBack(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
|
||||
tx.AddError(errors.New("synthetic audit failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil {
|
||||
t.Fatal("expected audit failure")
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil {
|
||||
t.Fatalf("confirm did not roll back: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler exposes the #338 return-matching admin surface: manual batch
|
||||
// match, list/filter (shared by the SYB product page and the yeeke returns
|
||||
// page), detail, confirm, cancel and remark. Everything here only reads or
|
||||
// writes GoAuto's own database — no yeeke or SYB write call is made.
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
||||
db := h.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return nil, false
|
||||
}
|
||||
return db, true
|
||||
}
|
||||
|
||||
func operatorFromContext(c *gin.Context) (string, string) {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims["rolekey"].(string)
|
||||
username, _ := claims["username"].(string)
|
||||
if username == "" {
|
||||
username, _ = claims["userName"].(string)
|
||||
}
|
||||
return role, username
|
||||
}
|
||||
|
||||
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
|
||||
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
|
||||
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
|
||||
// writes and require it; the two list/detail read endpoints do not.
|
||||
func requireCanPurchase(c *gin.Context) bool {
|
||||
role, _ := operatorFromContext(c)
|
||||
if role != "admin" && role != "purchaser" && role != "after_sales" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type batchMatchBody struct {
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
}
|
||||
|
||||
// BatchMatch is the「匹配退货」button: manual-only trigger for ticked rows.
|
||||
func (h Handler) BatchMatch(c *gin.Context) {
|
||||
if !requireCanPurchase(c) {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body batchMatchBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.SYBProductIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "sybProductIds 不能为空"})
|
||||
return
|
||||
}
|
||||
_, operator := operatorFromContext(c)
|
||||
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
|
||||
if errors.Is(err, ErrTooManyItems) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": resp})
|
||||
}
|
||||
|
||||
// List backs both the SYB product page's匹配列/筛选 and the yeeke returns
|
||||
// page's占用状态筛选/列 (issue #338 pages section).
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter := ListFilter{Status: c.Query("status")}
|
||||
if raw := c.QueryArray("sybProductId"); len(raw) > 0 {
|
||||
filter.SYBProductIDs = parseUint64List(raw)
|
||||
}
|
||||
if raw := c.QueryArray("yeekeReturnItemId"); len(raw) > 0 {
|
||||
filter.YeekeReturnItemIDs = parseUint64List(raw)
|
||||
}
|
||||
rows, err := NewService(db).List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
|
||||
}
|
||||
|
||||
func (h Handler) Detail(c *gin.Context) {
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||
return
|
||||
}
|
||||
detail, err := NewService(db).Detail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail})
|
||||
}
|
||||
|
||||
func (h Handler) Confirm(c *gin.Context) {
|
||||
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
|
||||
return s.Confirm(ctx.Request.Context(), id, operator)
|
||||
})
|
||||
}
|
||||
|
||||
func (h Handler) Cancel(c *gin.Context) {
|
||||
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
|
||||
var body map[string]json.RawMessage
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
raw, supplied := body["expectedStatus"]
|
||||
if !supplied {
|
||||
return s.Cancel(ctx.Request.Context(), id, operator)
|
||||
}
|
||||
var expectedStatus string
|
||||
if err := json.Unmarshal(raw, &expectedStatus); err != nil {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
return s.Cancel(ctx.Request.Context(), id, operator, expectedStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint64, string) (models.ReturnMatch, error)) {
|
||||
if !requireCanPurchase(c) {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||
return
|
||||
}
|
||||
_, operator := operatorFromContext(c)
|
||||
match, err := fn(NewService(db), c, id, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidExpectedStatus) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "expectedStatus 只允许 matched,或省略请求体"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errStateConflict) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "STATE_CONFLICT", "message": "匹配状态已变化,请刷新后重试"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
|
||||
}
|
||||
|
||||
type remarkBody struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func (h Handler) Remark(c *gin.Context) {
|
||||
if !requireCanPurchase(c) {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
|
||||
return
|
||||
}
|
||||
var body remarkBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"})
|
||||
return
|
||||
}
|
||||
_, operator := operatorFromContext(c)
|
||||
match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errRemarkTooLong) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
|
||||
}
|
||||
|
||||
func parseUint64List(raw []string) []uint64 {
|
||||
out := make([]uint64, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted
|
||||
// and matched counts); pass withItems=1 to include each row's outcome.
|
||||
func (h Handler) ListBatches(c *gin.Context) {
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
|
||||
}
|
||||
|
||||
// GetBatch returns one batch record with every submitted SYB product's outcome.
|
||||
func (h Handler) GetBatch(c *gin.Context) {
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("batchId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"})
|
||||
return
|
||||
}
|
||||
rec, err := NewService(db).GetBatch(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestReturnMatchWriteRoles(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := requireCanPurchase(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is
|
||||
// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered
|
||||
// to matching, even though it otherwise satisfies every other rule (an
|
||||
// eligible SYB candidate, a future destroy deadline, no active match).
|
||||
func TestBatchMatch_MissingItemNotMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||
t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp)
|
||||
}
|
||||
if resp.Items[0].ReasonCode != ReasonNoCandidate {
|
||||
t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself
|
||||
// is still "ok" while its PACKAGE is "missing" — availableReturnPool must
|
||||
// exclude it too, since #338's rule is "an item, or an item whose package,
|
||||
// is no longer ok".
|
||||
func TestBatchMatch_MissingPackageNotMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).
|
||||
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||
t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing
|
||||
// active match is NOT auto-cancelled when its return item later becomes
|
||||
// missing. Detail() must still report it (SYB side unaffected) and surface
|
||||
// the yeeke side's syncStatus="missing" so the UI can show the warning.
|
||||
func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 {
|
||||
t.Fatalf("expected a match before the item goes missing: %+v", resp)
|
||||
}
|
||||
matchID := resp.Items[0].MatchID
|
||||
|
||||
// The return item now drops out of a COMPLETE yeeke sync.
|
||||
missingSince := time.Now().UTC()
|
||||
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var match models.ReturnMatch
|
||||
if err := db.First(&match, matchID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil {
|
||||
t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match)
|
||||
}
|
||||
|
||||
detail, err := s.Detail(context.Background(), matchID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Yeeke == nil {
|
||||
t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)")
|
||||
}
|
||||
if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil {
|
||||
t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke)
|
||||
}
|
||||
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID {
|
||||
t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and
|
||||
// the compare dialog read List()'s syncStatus/missingSince fields (#338); a
|
||||
// match whose return item is missing must carry them through.
|
||||
func TestList_ReportsMissingReturnSyncStatus(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 {
|
||||
t.Fatalf("expected a match: %+v", resp)
|
||||
}
|
||||
|
||||
missingSince := time.Now().UTC()
|
||||
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
|
||||
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one list row, got %d", len(items))
|
||||
}
|
||||
if items[0].SyncStatus != "missing" || items[0].MissingSince == nil {
|
||||
t.Fatalf("List() must surface the missing return item's status, got %+v", items[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package returnmatch implements the #338 return-matching algorithm: pairing
|
||||
// a SYB order product with an available yeeke return item that is the same
|
||||
// Shopee item with the same spec, so the return can be reused instead of
|
||||
// buying the item again. This file holds only pure, DB-free functions so the
|
||||
// matching rules can be unit tested directly against the 840 real pairs used
|
||||
// to design the normalization rule (see issue #338 "当前事实").
|
||||
package returnmatch
|
||||
|
||||
import "strings"
|
||||
|
||||
// bracketPairs lists every bracket style issue #338 requires stripped, along
|
||||
// with its contained content: 【】()() and [].
|
||||
var bracketPairs = []struct{ open, close rune }{
|
||||
{'【', '】'},
|
||||
{'(', ')'},
|
||||
{'(', ')'},
|
||||
{'[', ']'},
|
||||
}
|
||||
|
||||
// stripBracketedContent removes every bracket-delimited span (any style in
|
||||
// bracketPairs) and its contents. Unmatched opening brackets discard the
|
||||
// remainder of the string from that point, which is safe here because SYB
|
||||
// and yeeke free-text specs never rely on a trailing unmatched bracket to
|
||||
// carry meaningful spec content.
|
||||
func stripBracketedContent(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range s {
|
||||
isOpen, isClose := false, false
|
||||
for _, pair := range bracketPairs {
|
||||
if r == pair.open {
|
||||
isOpen = true
|
||||
}
|
||||
if r == pair.close {
|
||||
isClose = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case isOpen:
|
||||
depth++
|
||||
case isClose:
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case depth == 0:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fullwidthToHalfwidth converts fullwidth ASCII forms (U+FF01-U+FF5E) and the
|
||||
// fullwidth space (U+3000) to their halfwidth equivalents, leaving CJK
|
||||
// characters (colors like 紫色) untouched.
|
||||
func fullwidthToHalfwidth(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == ' ':
|
||||
b.WriteRune(' ')
|
||||
case r >= '!' && r <= '~':
|
||||
b.WriteRune(r - 0xFEE0)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Normalize implements issue #338's spec-text normalization rule: strip
|
||||
// bracketed remarks (【】()()[] and their content), strip whitespace,
|
||||
// convert fullwidth characters to halfwidth, and lowercase. It is used on
|
||||
// both the SYB side (target_color + "," + target_size) and the yeeke side
|
||||
// (variation_name) so the two can be compared for equality.
|
||||
func Normalize(raw string) string {
|
||||
s := stripBracketedContent(raw)
|
||||
s = fullwidthToHalfwidth(s)
|
||||
s = strings.ToLower(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if !isSpace(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v', ' ':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SYBSpecText builds the SYB-side comparable spec text from the parser's
|
||||
// target_color/target_size fields, per issue #338's data design. Only
|
||||
// non-empty parts are joined so a single-dimension spec (color-only or
|
||||
// size-only) doesn't pick up a stray leading/trailing comma that would
|
||||
// prevent it from normalizing equal to the yeeke side's single-value
|
||||
// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,").
|
||||
func SYBSpecText(targetColor, targetSize string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if targetColor != "" {
|
||||
parts = append(parts, targetColor)
|
||||
}
|
||||
if targetSize != "" {
|
||||
parts = append(parts, targetSize)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// trimCommas removes leading/trailing commas left over after normalization
|
||||
// (e.g. a source value that itself started or ended with a comma). Used only
|
||||
// when building the matching key, so exact-text comparisons/tests elsewhere
|
||||
// are unaffected.
|
||||
func trimCommas(s string) string {
|
||||
return strings.Trim(s, ",")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package returnmatch
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"square brackets with content", "白色,2XL【建議65-75公斤】", "白色,2xl"},
|
||||
{"chinese parens", "紫色,M(建議43-53公斤)", "紫色,m"},
|
||||
{"ascii parens", "紫色,M (建議43-53公斤)", "紫色,m"},
|
||||
{"latin square brackets", "紫色,M [建議43-53公斤]", "紫色,m"},
|
||||
{"internal whitespace stripped", "紫色, M 【建議43-53公斤】", "紫色,m"},
|
||||
{"fullwidth digits and letters", "白色,2XL", "白色,2xl"},
|
||||
{"no bracket, plain", "黑色,L", "黑色,l"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Normalize(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatchesRealPairSample(t *testing.T) {
|
||||
// Representative real pairs from issue #338's local 840-pair backtest
|
||||
// note: SYB target_color+","+target_size vs yeeke variation_name should
|
||||
// normalize equal once bracketed remarks are stripped.
|
||||
pairs := []struct{ syb, yeeke string }{
|
||||
{SYBSpecText("白色", "2XL"), "白色,2XL【建議65-75公斤】"},
|
||||
{SYBSpecText("紫色", "M"), "紫色,M【建議43-53公斤】"},
|
||||
{SYBSpecText("黑色", "L"), "黑色,L"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if Normalize(p.syb) != Normalize(p.yeeke) {
|
||||
t.Fatalf("expected normalized equality: syb=%q (%q) yeeke=%q (%q)",
|
||||
p.syb, Normalize(p.syb), p.yeeke, Normalize(p.yeeke))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSYBSpecText(t *testing.T) {
|
||||
if got := SYBSpecText("白色", "2XL"); got != "白色,2XL" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSYBSpecText_SingleDimension(t *testing.T) {
|
||||
if got := SYBSpecText("黑色", ""); got != "黑色" {
|
||||
t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色")
|
||||
}
|
||||
if got := SYBSpecText("", "L"); got != "L" {
|
||||
t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L")
|
||||
}
|
||||
if got := SYBSpecText("", ""); got != "" {
|
||||
t.Fatalf("both empty: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimCommas(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"黑色,": "黑色",
|
||||
",黑色": "黑色",
|
||||
",黑色,": "黑色",
|
||||
"黑色": "黑色",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := trimCommas(in); got != want {
|
||||
t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but
|
||||
// must remain eligible to take part in return matching.
|
||||
func TestBatchMatch_ExcludedStageParticipates(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).
|
||||
Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
|
||||
t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
deadline := time.Now().Add(24 * time.Hour)
|
||||
ret := seedReturn(t, db, "红色", &deadline)
|
||||
sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now())
|
||||
pool, err := s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 1 {
|
||||
t.Fatalf("waiting pool=%v err=%v", pool, err)
|
||||
}
|
||||
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err = s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 0 {
|
||||
t.Fatalf("reshipped pool=%v err=%v", pool, err)
|
||||
}
|
||||
_, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test")
|
||||
if !errors.Is(err, errReturnNoLongerEligible) {
|
||||
t.Fatalf("final recheck=%v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.ReturnMatch{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("reshipped item was allocated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// InitRouter mounts the #338 return-matching admin surface. List/detail are
|
||||
// readable by any authenticated admin user; the write actions (batch match,
|
||||
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
|
||||
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc())
|
||||
group.GET("", handler.List)
|
||||
// Static batch routes are registered before /:id so they never parse as a match id.
|
||||
group.GET("/batches", handler.ListBatches)
|
||||
group.GET("/batches/:batchId", handler.GetBatch)
|
||||
group.GET("/:id", handler.Detail)
|
||||
group.POST("/batch-match", handler.BatchMatch)
|
||||
group.POST("/:id/confirm", handler.Confirm)
|
||||
group.POST("/:id/cancel", handler.Cancel)
|
||||
group.POST("/:id/remark", handler.Remark)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchase"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func clauseLockUpdate() clause.Locking { return clause.Locking{Strength: "UPDATE"} }
|
||||
|
||||
// participatingStages is issue #338 rule 1's "待采购范围": 待人工处理 is
|
||||
// explicitly excluded (already matched, per the confirmed rule), as are the
|
||||
// stages that mean a task is already in flight or a match already exists.
|
||||
var participatingStages = map[string]bool{
|
||||
purchase.ProcessStagePDDUnlinked: true,
|
||||
purchase.ProcessStagePDDPending: true,
|
||||
purchase.ProcessStagePDDCollecting: true,
|
||||
purchase.ProcessStagePDDCollectionFail: true,
|
||||
purchase.ProcessStageColorMapping: true,
|
||||
purchase.ProcessStagePurchaseReady: true,
|
||||
// #340: a pdd_purchase_excluded row still needs no PDD purchase and can
|
||||
// still take part in return matching — matching it just confirms there is
|
||||
// nothing left to buy back. After a cancel it returns to pdd_excluded,
|
||||
// which stays true here (see processStageFromDataset), so the row is
|
||||
// eligible again the same way any other participating stage is.
|
||||
purchase.ProcessStagePDDExcluded: true,
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{DB: db, Now: time.Now}
|
||||
}
|
||||
|
||||
// BatchMatchRequest/Result mirror the prototype's batch result dialog
|
||||
// (screen 2): per-SYB-product outcome plus a reason bucket count.
|
||||
type BatchMatchRequest struct {
|
||||
SYBProductIDs []uint64
|
||||
Operator string
|
||||
}
|
||||
|
||||
type BatchMatchItem struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
Matched bool `json:"matched"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MatchID uint64 `json:"matchId,omitempty"`
|
||||
}
|
||||
|
||||
type BatchMatchResponse struct {
|
||||
Items []BatchMatchItem `json:"items"`
|
||||
ProcessedCount int `json:"processedCount"`
|
||||
MatchedCount int `json:"matchedCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
}
|
||||
|
||||
const (
|
||||
ReasonStageIneligible = "stage_ineligible"
|
||||
ReasonNoCandidate = "no_candidate"
|
||||
ReasonConflict = "conflict"
|
||||
)
|
||||
|
||||
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
|
||||
// size can now go up to 500/page, so the button's selection can exceed the
|
||||
// previously-unbounded batch-match request size).
|
||||
const maxBatchMatchItems = 500
|
||||
|
||||
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
|
||||
// maxBatchMatchItems SYB product ids; the handler turns this into an
|
||||
// INVALID_REQUEST response instead of a 500.
|
||||
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
|
||||
|
||||
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
|
||||
// ever called from the batch-match button (ticked rows) — no scheduler, no
|
||||
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
|
||||
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||
if len(req.SYBProductIDs) > maxBatchMatchItems {
|
||||
return BatchMatchResponse{}, ErrTooManyItems
|
||||
}
|
||||
resp, err := s.batchMatch(ctx, req)
|
||||
if len(req.SYBProductIDs) > 0 {
|
||||
// The batch record is written after the per-row transactions have
|
||||
// committed, so a failure here must not turn an already-applied
|
||||
// match into an error response; it is logged instead.
|
||||
if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil {
|
||||
log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr)
|
||||
}
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// recordBatch stores one ReturnMatchBatch row describing this click of
|
||||
// 「匹配退货」: submitted SYB products in order and each one's outcome.
|
||||
func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error {
|
||||
items := resp.Items
|
||||
if items == nil {
|
||||
items = []BatchMatchItem{}
|
||||
}
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := models.ReturnMatchBatch{
|
||||
Operator: req.Operator,
|
||||
SubmittedCount: len(req.SYBProductIDs),
|
||||
MatchedCount: resp.MatchedCount,
|
||||
SkippedCount: resp.SkippedCount,
|
||||
ItemsJSON: string(raw),
|
||||
}
|
||||
if batchErr != nil {
|
||||
row.Error = truncateRunes(batchErr.Error(), 500)
|
||||
}
|
||||
return s.DB.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
func truncateRunes(v string, max int) string {
|
||||
r := []rune(v)
|
||||
if len(r) <= max {
|
||||
return v
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// BatchRecord is the API view of one ReturnMatchBatch row.
|
||||
type BatchRecord struct {
|
||||
models.ReturnMatchBatch
|
||||
Items []BatchMatchItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ListBatches returns the most recent batch records, newest first; items are
|
||||
// only expanded when withItems is true to keep the list response small.
|
||||
func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []models.ReturnMatchBatch
|
||||
if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]BatchRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rec := BatchRecord{ReturnMatchBatch: row}
|
||||
if withItems {
|
||||
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetBatch returns one batch record with its items expanded.
|
||||
func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) {
|
||||
var row models.ReturnMatchBatch
|
||||
if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
return BatchRecord{}, err
|
||||
}
|
||||
rec := BatchRecord{ReturnMatchBatch: row}
|
||||
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
|
||||
return BatchRecord{}, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||
resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))}
|
||||
if len(req.SYBProductIDs) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", req.SYBProductIDs).Find(&sybProducts).Error; err != nil {
|
||||
return resp, err
|
||||
}
|
||||
sybByID := make(map[uint64]models.SYBProduct, len(sybProducts))
|
||||
for _, p := range sybProducts {
|
||||
sybByID[p.ID] = p
|
||||
}
|
||||
|
||||
stages, err := purchase.NewService(s.DB).ProcessStages(ctx, req.SYBProductIDs)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
now := s.Now()
|
||||
eligible := make([]SYBCandidate, 0, len(req.SYBProductIDs))
|
||||
skipped := make(map[uint64]string)
|
||||
for _, id := range req.SYBProductIDs {
|
||||
syb, ok := sybByID[id]
|
||||
if !ok {
|
||||
skipped[id] = "SYB 商品不存在或已删除"
|
||||
continue
|
||||
}
|
||||
stage := stages[id]
|
||||
if !participatingStages[stage.Stage] {
|
||||
skipped[id] = "该处理阶段不参与匹配:" + stage.Label
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, SYBCandidate{
|
||||
SYBProductID: syb.ID, ShopeeItemID: syb.ShopeeItemID,
|
||||
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, CreatedAt: syb.CreatedAt,
|
||||
})
|
||||
}
|
||||
// Rule 4: process in SYB created_at DESC order.
|
||||
sortSYBCandidatesDesc(eligible)
|
||||
|
||||
returns, err := s.availableReturnPool(ctx)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
outcomes := SelectMatches(eligible, returns, now)
|
||||
outcomeBySYB := make(map[uint64]MatchOutcome, len(outcomes))
|
||||
for _, o := range outcomes {
|
||||
outcomeBySYB[o.SYBProductID] = o
|
||||
}
|
||||
|
||||
for _, id := range req.SYBProductIDs {
|
||||
if reason, isSkip := skipped[id]; isSkip {
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: reason})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
outcome, ok := outcomeBySYB[id]
|
||||
if !ok || !outcome.Matched {
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonNoCandidate, Reason: "没有满足条件的退货商品"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
|
||||
if insertErr != nil {
|
||||
if errors.Is(insertErr, errReturnNoLongerEligible) {
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
if errors.Is(insertErr, errStageNoLongerEligible) {
|
||||
// #338 review fix: the stage was re-checked under the same
|
||||
// FOR UPDATE lock purchase.create takes, right before insert.
|
||||
// A purchase task could have been created for this row
|
||||
// between the outer screening pass above and this point;
|
||||
// when that happens the product is no longer in a
|
||||
// participating stage and this row is skipped, not matched.
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
if isUniqueConstraintErr(insertErr) {
|
||||
// Rule 7 / acceptance item 9: a concurrent insert lost the
|
||||
// race for either side — skip this row and report it, never
|
||||
// fail the whole batch.
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonConflict, Reason: "并发匹配冲突,该商品或退货商品已被占用"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
return resp, insertErr
|
||||
}
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: true, MatchID: match.ID})
|
||||
resp.MatchedCount++
|
||||
}
|
||||
resp.ProcessedCount = len(resp.Items)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func sortSYBCandidatesDesc(items []SYBCandidate) {
|
||||
for i := 1; i < len(items); i++ {
|
||||
for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- {
|
||||
items[j], items[j-1] = items[j-1], items[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// availableReturnPool loads every yeeke return item with no active match and
|
||||
// a destroy deadline (rule 2: empty deadline is unavailable, handled by the
|
||||
// NULL exclusion below; "later than now" is enforced by SelectMatches).
|
||||
// #338 scope addition: an item, or an item whose package, is no longer
|
||||
// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is
|
||||
// excluded here so matching never offers a return yeeke has dropped.
|
||||
func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) {
|
||||
var rows []struct {
|
||||
ID uint64
|
||||
ItemID string
|
||||
VariationName string
|
||||
DestroyDeadLine *time.Time
|
||||
}
|
||||
err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||
Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line").
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
|
||||
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
|
||||
Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ReturnCandidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.DestroyDeadLine == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ReturnCandidate{ReturnItemID: r.ID, ItemID: r.ItemID, VariationName: r.VariationName, DestroyDeadline: *r.DestroyDeadLine})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// errStageNoLongerEligible is returned by matchOneWithLock when the SYB
|
||||
// product's process stage, re-checked under lock immediately before insert,
|
||||
// is no longer in participatingStages — e.g. a purchase task was created for
|
||||
// it between BatchMatch's outer screening pass and this point (#338 review
|
||||
// fix: race between matching and purchase creation).
|
||||
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
|
||||
var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship")
|
||||
|
||||
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
|
||||
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
|
||||
// process stage inside that transaction via purchase.NewService(tx) before
|
||||
// inserting the match, so a purchase task creation racing with this batch
|
||||
// match can never both succeed: whichever gets the row lock first commits,
|
||||
// and the other sees the now-current state (an active return_match row, or
|
||||
// a task_created/order_review/purchase_succeeded stage) and is rejected/
|
||||
// skipped instead of double-committing an inconsistent state.
|
||||
func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
|
||||
var match models.ReturnMatch
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var syb models.SYBProduct
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage := stages[sybID]
|
||||
if !participatingStages[stage.Stage] {
|
||||
return errStageNoLongerEligible
|
||||
}
|
||||
var returnItem models.YeekeReturnItem
|
||||
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Same package -> item lock order as sync upsert; avoid a lock cycle.
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" {
|
||||
return errReturnNoLongerEligible
|
||||
}
|
||||
sybIDCopy := syb.ID
|
||||
returnIDCopy := outcome.ReturnItemID
|
||||
deadline := outcome.DestroyDeadline
|
||||
now := s.Now()
|
||||
match = models.ReturnMatch{
|
||||
SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy,
|
||||
ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy,
|
||||
Status: models.ReturnMatchStatusMatched,
|
||||
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
|
||||
YeekeSpecText: returnItem.VariationName,
|
||||
NormalizedKey: outcome.NormalizedKey,
|
||||
PreviousProcessStage: stage.Stage,
|
||||
MatchedBy: operator,
|
||||
MatchedAt: now,
|
||||
DestroyDeadlineSnapshot: &deadline,
|
||||
}
|
||||
if err := tx.Create(&match).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
logRow := models.ReturnMatchLog{
|
||||
MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator,
|
||||
Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label),
|
||||
}
|
||||
return tx.Create(&logRow).Error
|
||||
})
|
||||
return match, err
|
||||
}
|
||||
|
||||
func isUniqueConstraintErr(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate")
|
||||
}
|
||||
|
||||
// Confirm/Cancel/Remark implement the human-review half of issue #338.
|
||||
|
||||
func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
|
||||
var match models.ReturnMatch
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if match.Status != models.ReturnMatchStatusMatched {
|
||||
return errStateConflict
|
||||
}
|
||||
now := s.Now()
|
||||
match.Status = models.ReturnMatchStatusConfirmed
|
||||
match.ConfirmedBy = operator
|
||||
match.ConfirmedAt = &now
|
||||
if err := tx.Save(&match).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error
|
||||
})
|
||||
return match, err
|
||||
}
|
||||
|
||||
// Cancel releases the active SYB product and return item pointers; actual
|
||||
// purchase readiness and return-pool eligibility are then re-evaluated.
|
||||
// Without a precondition it accepts either matched or confirmed state,
|
||||
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
|
||||
var errInvalidExpectedStatus = errors.New("expectedStatus must be matched")
|
||||
|
||||
// Optional matched precondition is checked under the same row lock as cancellation.
|
||||
// Legacy callers without it retain the confirmed-to-cancelled transition.
|
||||
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string, expectedStatus ...string) (models.ReturnMatch, error) {
|
||||
if len(expectedStatus) > 1 || (len(expectedStatus) == 1 && expectedStatus[0] != models.ReturnMatchStatusMatched) {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
var match models.ReturnMatch
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if match.Status == models.ReturnMatchStatusCancelled {
|
||||
return errStateConflict
|
||||
}
|
||||
if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) {
|
||||
return errStateConflict
|
||||
}
|
||||
now := s.Now()
|
||||
match.Status = models.ReturnMatchStatusCancelled
|
||||
match.ActiveSYBProductID = nil
|
||||
match.ActiveYeekeReturnItemID = nil
|
||||
match.CancelledBy = operator
|
||||
match.CancelledAt = &now
|
||||
if err := tx.Save(&match).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,释放 SYB 与退货商品占用,采购准备状态重新计算"}).Error
|
||||
})
|
||||
return match, err
|
||||
}
|
||||
|
||||
// errRemarkTooLong is returned instead of silently truncating; remark is a
|
||||
// varchar(500) column and MySQL VARCHAR length is a character count, so this
|
||||
// checks runes, not bytes.
|
||||
var errRemarkTooLong = errors.New("remark exceeds 500 characters")
|
||||
|
||||
const maxRemarkLength = 500
|
||||
|
||||
func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) {
|
||||
if utf8.RuneCountInString(remark) > maxRemarkLength {
|
||||
return models.ReturnMatch{}, errRemarkTooLong
|
||||
}
|
||||
var match models.ReturnMatch
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&match, matchID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
match.Remark = remark
|
||||
if err := tx.Save(&match).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error
|
||||
})
|
||||
return match, err
|
||||
}
|
||||
|
||||
// List supports both admin pages' filter/column needs: by SYB product ids,
|
||||
// by yeeke return item ids, or by status.
|
||||
type ListFilter struct {
|
||||
SYBProductIDs []uint64
|
||||
YeekeReturnItemIDs []uint64
|
||||
Status string
|
||||
}
|
||||
|
||||
// ListItem is one row of List's result: the match itself plus a few display
|
||||
// fields the SYB product page's match column needs (thumbnail, return order
|
||||
// sn, spec) so it never has to issue a second request per row.
|
||||
type ListItem struct {
|
||||
models.ReturnMatch
|
||||
OrderSN string `json:"orderSn,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
VariationName string `json:"variationName,omitempty"`
|
||||
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
|
||||
// current availability ("ok"/"missing") so the SYB products match column
|
||||
// and the compare dialog can warn even on an existing, already-matched
|
||||
// pair whose return later dropped out of a COMPLETE yeeke sync.
|
||||
SyncStatus string `json:"syncStatus,omitempty"`
|
||||
MissingSince *time.Time `json:"missingSince,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) {
|
||||
q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{})
|
||||
if len(filter.SYBProductIDs) > 0 {
|
||||
q = q.Where("syb_product_id IN ?", filter.SYBProductIDs)
|
||||
}
|
||||
if len(filter.YeekeReturnItemIDs) > 0 {
|
||||
q = q.Where("yeeke_return_item_id IN ?", filter.YeekeReturnItemIDs)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
q = q.Where("status = ?", filter.Status)
|
||||
}
|
||||
var rows []models.ReturnMatch
|
||||
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ListItem, len(rows))
|
||||
returnIDs := make([]uint64, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = ListItem{ReturnMatch: r}
|
||||
returnIDs = append(returnIDs, r.YeekeReturnItemID)
|
||||
}
|
||||
if len(returnIDs) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
// One bounded join query for every return item referenced on this page —
|
||||
// never a per-row lookup.
|
||||
var joined []struct {
|
||||
ID uint64
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
PackageSyncStat string `gorm:"column:package_sync_status"`
|
||||
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
|
||||
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
|
||||
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Where("i.id IN ?", returnIDs).
|
||||
Find(&joined).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type extraFields struct {
|
||||
OrderSN string
|
||||
Image string
|
||||
VariationName string
|
||||
DestroyDeadLine *time.Time
|
||||
SyncStatus string
|
||||
MissingSince *time.Time
|
||||
}
|
||||
byID := make(map[uint64]extraFields, len(joined))
|
||||
for _, j := range joined {
|
||||
// A missing package makes its items unavailable too (#338), even
|
||||
// if the item row itself is still "ok" — surface the package's
|
||||
// missing_since in that case since it is the more accurate reason.
|
||||
syncStatus, missingSince := j.SyncStatus, j.MissingSince
|
||||
if j.PackageSyncStat != "ok" {
|
||||
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
|
||||
}
|
||||
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
|
||||
}
|
||||
for i := range items {
|
||||
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
|
||||
items[i].OrderSN = extra.OrderSN
|
||||
items[i].Image = extra.Image
|
||||
items[i].VariationName = extra.VariationName
|
||||
items[i].DestroyDeadline = extra.DestroyDeadLine
|
||||
items[i].SyncStatus = extra.SyncStatus
|
||||
items[i].MissingSince = extra.MissingSince
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare
|
||||
// screen (screen 3): both sides' fields and images side by side, normalized
|
||||
// spec text for both, and the operation log.
|
||||
type SYBDetailView struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
OrderCode string `json:"orderCode"`
|
||||
ShopeeItemID string `json:"shopeeItemId"`
|
||||
ShopName string `json:"shopName"`
|
||||
ProductTitle string `json:"productTitle"`
|
||||
TargetColor string `json:"targetColor"`
|
||||
TargetSize string `json:"targetSize"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
CurrentStage string `json:"currentStage"`
|
||||
CurrentStageLabel string `json:"currentStageLabel"`
|
||||
}
|
||||
|
||||
type YeekeDetailView struct {
|
||||
YeekeReturnItemID uint64 `json:"yeekeReturnItemId"`
|
||||
OrderSN string `json:"orderSn"`
|
||||
ItemID string `json:"itemId"`
|
||||
VariationID string `json:"variationId"`
|
||||
ShopName string `json:"shopName"`
|
||||
ItemName string `json:"itemName"`
|
||||
VariationName string `json:"variationName"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Image string `json:"image,omitempty"`
|
||||
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
|
||||
// SyncStatus/MissingSince (#338): "missing" when either the item or its
|
||||
// package fell out of a COMPLETE yeeke sync, so the compare dialog can
|
||||
// show the warning even on a match made before the item went missing.
|
||||
SyncStatus string `json:"syncStatus"`
|
||||
MissingSince *time.Time `json:"missingSince,omitempty"`
|
||||
}
|
||||
|
||||
type MatchDetail struct {
|
||||
Match models.ReturnMatch `json:"match"`
|
||||
SYB *SYBDetailView `json:"syb,omitempty"`
|
||||
Yeeke *YeekeDetailView `json:"yeeke,omitempty"`
|
||||
NormalizedSYBSpec string `json:"normalizedSybSpec"`
|
||||
NormalizedYeekeSpec string `json:"normalizedYeekeSpec"`
|
||||
Logs []models.ReturnMatchLog `json:"logs"`
|
||||
}
|
||||
|
||||
func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) {
|
||||
var match models.ReturnMatch
|
||||
if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil {
|
||||
return MatchDetail{}, err
|
||||
}
|
||||
detail := MatchDetail{Match: match}
|
||||
|
||||
var syb models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil {
|
||||
stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID})
|
||||
var stage purchase.ProcessStageResult
|
||||
if stageErr == nil {
|
||||
stage = stages[syb.ID]
|
||||
}
|
||||
detail.SYB = &SYBDetailView{
|
||||
SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID,
|
||||
ShopName: syb.ShopName, ProductTitle: syb.ProductTitle,
|
||||
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity,
|
||||
ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label,
|
||||
}
|
||||
detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize)))
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return detail, err
|
||||
}
|
||||
|
||||
var item models.YeekeReturnItem
|
||||
if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil {
|
||||
var pkg models.YeekeReturnPackage
|
||||
if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) {
|
||||
return detail, pkgErr
|
||||
}
|
||||
syncStatus, missingSince := item.SyncStatus, item.MissingSince
|
||||
if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" {
|
||||
syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince
|
||||
}
|
||||
detail.Yeeke = &YeekeDetailView{
|
||||
YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID,
|
||||
ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName,
|
||||
Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine,
|
||||
SyncStatus: syncStatus, MissingSince: missingSince,
|
||||
}
|
||||
detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName))
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return detail, err
|
||||
}
|
||||
|
||||
var logs []models.ReturnMatchLog
|
||||
if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil {
|
||||
return detail, err
|
||||
}
|
||||
detail.Logs = logs
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
var errStateConflict = errors.New("return match state conflict")
|
||||
@@ -0,0 +1,486 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// shopeeLinkForItem memoizes one shopee_product row per (db, item id) test
|
||||
// case so multiple SYB rows for the same order/item share the link, same as
|
||||
// real data, while still landing on the PDD-unlinked stage (which
|
||||
// participates in matching per issue #338 rule 1) without needing the full
|
||||
// PDD/price fixture the purchase package's own tests use.
|
||||
func shopeeLinkForItem(t *testing.T, db *gorm.DB, itemID string) uint64 {
|
||||
t.Helper()
|
||||
var existing models.ShopeeProduct
|
||||
if err := db.Where("shopee_item_id = ?", itemID).First(&existing).Error; err == nil {
|
||||
return existing.ID
|
||||
}
|
||||
sp := models.ShopeeProduct{ShopeeItemID: itemID, Title: "蝦皮商品", ShopName: "测试店", SpecsJSON: `[]`, Currency: "CNY"}
|
||||
if err := db.Create(&sp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sp.ID
|
||||
}
|
||||
|
||||
func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color, size string, createdAt time.Time) models.SYBProduct {
|
||||
t.Helper()
|
||||
shopeeID := shopeeLinkForItem(t, db, "100")
|
||||
syb := models.SYBProduct{
|
||||
OrderCode: orderCode, DetailID: detailID, StockID: detailID, ShopeeItemID: "100", ShopeeProductID: &shopeeID,
|
||||
TargetColor: color, TargetSize: size, Quantity: 1, UnitPriceCent: 1000,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
|
||||
}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("created_at", createdAt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb.CreatedAt = createdAt
|
||||
return syb
|
||||
}
|
||||
|
||||
func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem {
|
||||
t.Helper()
|
||||
pkg := models.YeekeReturnPackage{
|
||||
ClaimStatus: "1",
|
||||
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
|
||||
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&pkg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := models.YeekeReturnItem{
|
||||
PackageID: pkg.ID, ExternalKey: "key-" + variationName + fmt.Sprint(time.Now().UnixNano()),
|
||||
ItemID: "100", VariationName: variationName, LastSyncedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func TestBatchMatch_EndToEnd(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
|
||||
t.Fatalf("expected one match: %+v", resp)
|
||||
}
|
||||
|
||||
var match models.ReturnMatch
|
||||
if err := db.First(&match, resp.Items[0].MatchID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match.Status != models.ReturnMatchStatusMatched || match.ActiveSYBProductID == nil || *match.ActiveSYBProductID != syb.ID {
|
||||
t.Fatalf("unexpected match row: %+v", match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
ids := make([]uint64, maxBatchMatchItems+1)
|
||||
for i := range ids {
|
||||
ids[i] = uint64(i + 1)
|
||||
}
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
|
||||
if !errors.Is(err, ErrTooManyItems) {
|
||||
t.Fatalf("expected ErrTooManyItems, got %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("expected empty response on rejection, got %+v", resp)
|
||||
}
|
||||
|
||||
var batchCount int64
|
||||
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batchCount != 0 {
|
||||
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
past := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L", &past)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
|
||||
t.Fatalf("expected skip due to expired deadline: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_MultiColourSameOrderCrossPairing(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
white := seedSYB(t, db, "SYB-1", 1, "白色", "2XL", time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC))
|
||||
purple := seedSYB(t, db, "SYB-1", 2, "紫色", "M", time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC))
|
||||
whiteReturn := seedReturn(t, db, "白色,2XL【建議65-75公斤】", &deadline)
|
||||
purpleReturn := seedReturn(t, db, "紫色,M【建議43-53公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{white.ID, purple.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 2 {
|
||||
t.Fatalf("expected both to match: %+v", resp)
|
||||
}
|
||||
var matches []models.ReturnMatch
|
||||
if err := db.Find(&matches).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[uint64]uint64{}
|
||||
for _, m := range matches {
|
||||
got[m.SYBProductID] = m.YeekeReturnItemID
|
||||
}
|
||||
if got[white.ID] != whiteReturn.ID {
|
||||
t.Fatalf("white SYB product should pair with white return, got %+v", got)
|
||||
}
|
||||
if got[purple.ID] != purpleReturn.ID {
|
||||
t.Fatalf("purple SYB product should pair with purple return, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_ManualActionStageDoesNotParticipate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// A parse-failed SYB product computes to 待人工处理, which the confirmed
|
||||
// rule says must NOT participate even though a candidate return exists.
|
||||
syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 1000, ParseStatus: models.SYBParseStatusFailed, RawJSON: `{}`}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedReturn(t, db, "白色,L", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || resp.Items[0].ReasonCode != ReasonStageIneligible {
|
||||
t.Fatalf("expected 待人工处理 to be excluded from matching: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmThenCancel_RestoresAvailability(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup match failed: %v %+v", err, resp)
|
||||
}
|
||||
matchID := resp.Items[0].MatchID
|
||||
|
||||
if _, err := s.Confirm(context.Background(), matchID, "op1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var confirmed models.ReturnMatch
|
||||
db.First(&confirmed, matchID)
|
||||
if confirmed.Status != models.ReturnMatchStatusConfirmed {
|
||||
t.Fatalf("expected confirmed: %+v", confirmed)
|
||||
}
|
||||
|
||||
if _, err := s.Cancel(context.Background(), matchID, "op2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cancelled models.ReturnMatch
|
||||
db.First(&cancelled, matchID)
|
||||
if cancelled.Status != models.ReturnMatchStatusCancelled || cancelled.ActiveSYBProductID != nil || cancelled.ActiveYeekeReturnItemID != nil {
|
||||
t.Fatalf("expected cancel to clear active columns: %+v", cancelled)
|
||||
}
|
||||
|
||||
// Rematch must be able to pick the same pair again (issue #338 rule 7).
|
||||
resp2, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp2.MatchedCount != 1 {
|
||||
t.Fatalf("expected rematch after cancel to succeed: %+v", resp2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338
|
||||
// review fix-2 regression: a purchase task gets created for the SYB product
|
||||
// (moving its stage to task_created, which does not participate) AFTER
|
||||
// BatchMatch's outer screening pass already judged it eligible but BEFORE
|
||||
// matchOneWithLock's own re-check runs. The row must be skipped with
|
||||
// stage_ineligible, not matched.
|
||||
func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L", &deadline)
|
||||
|
||||
// Simulate the exact race the review flagged: BatchMatch's outer
|
||||
// screening pass already computed `outcome` while the product was still
|
||||
// participating (pdd_unlinked). Before matchOneWithLock's own re-check
|
||||
// runs (which takes the same row lock purchase.create takes and
|
||||
// recomputes the stage inside that transaction), something else moves
|
||||
// the product out of a participating stage — here simulated directly by
|
||||
// flipping it to a parse-failed/manual_action state, same effect as a
|
||||
// purchase task having been created for it in the meantime.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"}
|
||||
|
||||
_, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester")
|
||||
if !errors.Is(err, errStageNoLongerEligible) {
|
||||
t.Fatalf("expected errStageNoLongerEligible, got %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup match failed: %v %+v", err, resp)
|
||||
}
|
||||
var logs []models.ReturnMatchLog
|
||||
if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" {
|
||||
t.Fatalf("expected one matched log row: %+v", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L", &deadline)
|
||||
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
|
||||
matchID := resp.Items[0].MatchID
|
||||
|
||||
if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var logs []models.ReturnMatchLog
|
||||
if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled}
|
||||
if len(logs) != len(wantActions) {
|
||||
t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs)
|
||||
}
|
||||
for i, action := range wantActions {
|
||||
if logs[i].Action != action {
|
||||
t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs)
|
||||
}
|
||||
}
|
||||
if logs[1].Detail != "看起来没问题" {
|
||||
t.Fatalf("remark log must carry the remark text: %+v", logs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L", &deadline)
|
||||
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
matchID := resp.Items[0].MatchID
|
||||
|
||||
tooLong := strings.Repeat("字", 501)
|
||||
if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) {
|
||||
t.Fatalf("expected errRemarkTooLong, got %v", err)
|
||||
}
|
||||
var match models.ReturnMatch
|
||||
db.First(&match, matchID)
|
||||
if match.Remark != "" {
|
||||
t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark)
|
||||
}
|
||||
|
||||
exactly500 := strings.Repeat("字", 500)
|
||||
if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil {
|
||||
t.Fatalf("exactly 500 runes must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
|
||||
if err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||
}
|
||||
matchID := resp.Items[0].MatchID
|
||||
if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
detail, err := s.Detail(context.Background(), matchID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" {
|
||||
t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB)
|
||||
}
|
||||
if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" {
|
||||
t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke)
|
||||
}
|
||||
if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) {
|
||||
t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke)
|
||||
}
|
||||
if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec {
|
||||
t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec)
|
||||
}
|
||||
if len(detail.Logs) != 2 {
|
||||
t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs)
|
||||
}
|
||||
if detail.Logs[0].Action != models.ReturnMatchLogActionRemark {
|
||||
t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs)
|
||||
}
|
||||
if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" {
|
||||
t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique
|
||||
// constraint directly: two goroutines racing to insert an active match for
|
||||
// the exact same SYB product must have exactly one winner, and the loser
|
||||
// must not error out the whole call (acceptance item 9).
|
||||
func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) {
|
||||
db := testDB(t)
|
||||
sybID := uint64(1)
|
||||
returnAID := uint64(101)
|
||||
returnBID := uint64(102)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i, retID := range []uint64{returnAID, returnBID} {
|
||||
wg.Add(1)
|
||||
go func(i int, retID uint64) {
|
||||
defer wg.Done()
|
||||
match := models.ReturnMatch{
|
||||
SYBProductID: sybID, YeekeReturnItemID: retID,
|
||||
ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &retID,
|
||||
Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
|
||||
}
|
||||
results[i] = db.Create(&match).Error
|
||||
}(i, retID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
conflictCount := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
} else if isUniqueConstraintErr(err) {
|
||||
conflictCount++
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 || conflictCount != 1 {
|
||||
t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil || resp.MatchedCount != 1 {
|
||||
t.Fatalf("setup failed: %v %+v", err, resp)
|
||||
}
|
||||
|
||||
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil {
|
||||
t.Fatalf("expected enriched list item: %+v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// #333: the real middleware sets userId=0 on every request; the operator used
|
||||
// for owned-device collection lookup and audit must come from the JWT identity.
|
||||
func TestCurrentUserIDReadsJWTIdentityNotUserIDKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
claims jwt.MapClaims
|
||||
want uint64
|
||||
}{
|
||||
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}, 3},
|
||||
{jwt.MapClaims{"rolekey": "purchaser"}, 0},
|
||||
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(-1)}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set(jwt.JwtPayloadKey, tc.claims)
|
||||
c.Set("userId", 0)
|
||||
if got := currentUserID(c); got != tc.want {
|
||||
t.Fatalf("claims %v: got %d want %d", tc.claims, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -120,6 +121,8 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
service.ReplacementEligibility = handler.ReplacementEligibility
|
||||
service.OperatorUserID = currentUserID(c)
|
||||
service.OperatorIsAdmin = currentRole(c) == "admin"
|
||||
response, err := service.LinkPDD(c.Request.Context(), id, request)
|
||||
respond(c, response, err)
|
||||
}
|
||||
@@ -424,22 +427,31 @@ func (handler Handler) service(c *gin.Context) (*Service, bool) {
|
||||
// purchasing roles per #40; role membership itself is enforced by the router
|
||||
// middleware, not here.
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
value, exists := c.Get("userId")
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
switch id := value.(type) {
|
||||
// #333: go-admin's Authorizator runs on every request with the
|
||||
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
|
||||
// always 0 there. The JWT "identity" claim is the authenticated user id.
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int:
|
||||
return uint64(id)
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int64:
|
||||
return uint64(id)
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case uint64:
|
||||
return id
|
||||
case float64:
|
||||
return uint64(id)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func currentRole(c *gin.Context) string {
|
||||
value, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func respond(c *gin.Context, response SaveResponse, err error) {
|
||||
@@ -489,6 +501,8 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusNotFound
|
||||
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
|
||||
status = http.StatusConflict
|
||||
case CodeDeviceOwnershipForbidden:
|
||||
status = http.StatusForbidden
|
||||
case CodeAIUnavailable:
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
const CodeDeviceOwnershipForbidden = "DEVICE_OWNERSHIP_FORBIDDEN"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
@@ -105,6 +106,9 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
if service.OperatorUserID > 0 && !service.OperatorIsAdmin && (device.OwnerUserID == nil || *device.OwnerUserID != service.OperatorUserID) {
|
||||
return &ServiceError{Code: CodeDeviceOwnershipForbidden, Message: "该手机不属于当前采购员,无法使用其采集记录", Retryable: false}
|
||||
}
|
||||
var task models.CollectionTask
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
|
||||
if replacement != nil && !replacement.Preview {
|
||||
|
||||
@@ -63,6 +63,21 @@ func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsDeviceOwnedByAnotherUser(t *testing.T) {
|
||||
s, id, req, _ := latestFixture(t)
|
||||
owner := uint64(21)
|
||||
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("owner_user_id", owner)
|
||||
s.OperatorUserID = 22
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeDeviceOwnershipForbidden {
|
||||
t.Fatalf("expected ownership rejection: %v", err)
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("ownership rejection wrote association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -62,6 +62,8 @@ type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
OperatorUserID uint64
|
||||
OperatorIsAdmin bool
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
|
||||
|
||||
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// DB exposes the store connection to the shared session manager; callers do
|
||||
// not receive any session data through this accessor.
|
||||
func (s *SessionStore) DB() *gorm.DB { return s.db }
|
||||
|
||||
// Session is one cached SYB login.
|
||||
type Session struct {
|
||||
Username string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user