Compare commits

...
12 changed files with 290 additions and 16 deletions
@@ -19,7 +19,7 @@ data class FinalConfirmationEvidence(
val activityName: String,
)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
data class PurchaseOrderReadFailure(
val code: String,
val message: String,
@@ -362,7 +362,17 @@ class PurchaseLiveAutomation(
var orderDetailEntryOpened = false
var wechatRestorePendingSamples = 0
var consecutiveEmptySnapshots = 0
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
var unpaidStartSample: Int? = null
var unpaidSwipes = 0
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
// The first unpaid page receives its own bounded budget, even after a long handoff.
// Never reset it on repeated labels or navigation back to payment.
val unpaidStart = unpaidStartSample
if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) ||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
}
val snapshot = driver.capture()
if (snapshot.packageName.isNullOrBlank()) {
consecutiveEmptySnapshots++
@@ -430,8 +440,16 @@ class PurchaseLiveAutomation(
return@repeat
}
consecutivePaymentSamplesAfterBack = 0
if (unpaidContextVisible) paymentPageObserved = true
if (unpaidContextVisible) {
paymentPageObserved = true
if (unpaidStartSample == null) unpaidStartSample = index
}
if (!orderContextVisible && !unpaidContextVisible) {
if (unpaidStartSample != null) {
// After reaching the unpaid page only observe; never click a newly exposed control.
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat
}
val entries = orderDetailEntryTargets(snapshot)
if (entries.size > 1) {
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
@@ -454,7 +472,13 @@ class PurchaseLiveAutomation(
}
currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it }
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
unpaidSwipes++
driver.swipePurchase(SwipeDirection.UP, 400)
pause(ORDER_RESULT_UNPAID_SETTLE_MS)
return@repeat
}
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
driver.swipePurchase(SwipeDirection.UP, 400)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
@@ -728,7 +752,18 @@ class PurchaseLiveAutomation(
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull()
} ?: return null
val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed)
return PurchaseOrderEvidence(orderNumbers.single(), output)
return PurchaseOrderEvidence(orderNumbers.single(), output, parsePayableAmount(labels))
}
private fun parsePayableAmount(labels: Collection<String>): Long? {
val amounts = labels.flatMap { label ->
PAYABLE_AMOUNT.findAll(label).map { match ->
runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull()
}.toList()
}
// Repeated parent/child labels are fine; conflicting/overflow amounts are omitted.
if (amounts.any { it == null }) return null
return amounts.distinct().singleOrNull()
}
private fun orderEvidenceFailure(labels: Collection<String>): PurchaseOrderReadFailure {
@@ -764,6 +799,7 @@ class PurchaseLiveAutomation(
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
val PAYABLE_AMOUNT = Regex("(?<![\\p{L}])应付[ \\t::,,]*[¥¥]?[ \\t]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver")
@@ -777,7 +813,10 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_MAX_SAMPLES = 60
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25
const val ORDER_RESULT_UNPAID_MAX_SAMPLES = 30
const val ORDER_RESULT_UNPAID_MAX_SWIPES = 4
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
@@ -79,6 +79,7 @@ data class PurchaseExecutionOutcome(
val actualUnitPriceCent: Long? = null,
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
val paymentPageObserved: Boolean = false,
val pddOrderAmountCent: Long? = null,
)
class PurchaseRehearsalExecutor(
@@ -154,7 +155,7 @@ class PurchaseRehearsalExecutor(
}
PurchaseActionType.READ_ORDER_RESULT -> {
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) }
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice, pddOrderAmountCent = it.pddOrderAmountCent) }
?: live.lastOrderReadFailure.let { readFailure ->
PurchaseExecutionOutcome(
"order_result_unknown",
@@ -724,7 +724,7 @@ class AgentForegroundService : Service() {
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
)
} else {
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent)
}
val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
@@ -24,5 +24,8 @@ internal fun purchaseResultPayload(
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
if (outcome.resultType == "order_created") {
outcome.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) }
}
}
.toString()
@@ -18,6 +18,104 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertEquals("2026-09-19T03:21:43Z", order?.submittedAt)
assertEquals(1300L, order?.pddOrderAmountCent)
assertEquals(1, driver.backs)
assertEquals(2, driver.swipes)
assertEquals(0, driver.clicks)
}
@Test
fun `unpaid page arriving near original limit gets independent read budget`() {
val driver = ReadOnlyOrderDriver(preludeSamples = 58)
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertTrue(driver.captures > 60)
assertEquals(2, driver.swipes)
assertEquals(0, driver.clicks)
}
@Test
fun `unpaid scrolling is immediate and bounded when order never becomes visible`() {
val driver = ReadOnlyOrderDriver(requiredSwipes = 100)
val automation = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it })
assertEquals(null, automation.readOrderResult())
assertEquals(4, driver.swipes)
assertEquals(0L, driver.firstSwipeAt)
assertTrue(driver.captures <= 30)
assertEquals(0, driver.clicks)
}
@Test
fun `already visible order never scrolls and missing or conflicting payable amount never blocks order`() {
val cases = listOf(
emptyList<String>() to null,
listOf("拼单价¥16", "平台优惠-¥3", "实付:13元") to null,
listOf("应付:13元", "应付:14元") to null,
listOf("应付:-13元", "应付:13.123元", "预计应付:13元") to null,
listOf("应付:999999999999999999999元") to null,
listOf("应付:13.20元", "应付:13.20元") to 1320L,
listOf("应付:0元") to 0L,
)
for ((amounts, expected) in cases) {
val driver = ReadOnlyOrderDriver(requiredSwipes = 0, amounts = amounts)
val order = PurchaseLiveAutomation(driver, pause = {}).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertEquals(expected, order?.pddOrderAmountCent)
assertEquals(0, driver.swipes)
assertEquals(0, driver.clicks)
}
}
private class ReadOnlyOrderDriver(
val paymentMs: Long = 0,
val preludeSamples: Int = 0,
val requiredSwipes: Int = 2,
val amounts: List<String> = listOf("拼单价¥16", "平台优惠-¥3", "已优惠3元使用3元平台无门槛券,应付:,13元,(免运费)"),
) : PurchaseUiDriver by LiveDriver() {
var elapsed = 0L
var captures = 0
var backs = 0
var swipes = 0
var clicks = 0
var firstSwipeAt: Long? = null
override fun capture(): UiSnapshot {
captures++
if (captures <= preludeSamples) return UiSnapshot(PDD, ACTIVITY, emptyList())
if (elapsed < paymentMs) return UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("立即支付")))
return UiSnapshot(PDD, ACTIVITY, buildList {
add(node("待付款"))
// Amount scrolls away before the order fields become visible.
if (swipes == 0) amounts.forEach { add(node(it)) }
add(node("订单号:PDD-DEMO-325", swipes >= requiredSwipes))
add(node("下单时间:2026-09-19 11:21:43", swipes >= requiredSwipes))
add(node("去支付"))
})
}
override fun backPurchase(): Boolean { backs++; return true }
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
assertEquals(SwipeDirection.UP, direction)
assertTrue(elapsed >= paymentMs)
if (firstSwipeAt == null) firstSwipeAt = elapsed
swipes++
return true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicks++
throw AssertionError("Read-only unpaid branch must never click")
}
private fun node(text: String, visible: Boolean = true) = SnapshotNode(
text, null, text, null, null, "android.widget.TextView",
NodeBounds(0, 100, 500, if (visible) 180 else 100),
false, false, false, false, true, visible,
)
}
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
@@ -358,10 +456,10 @@ class PurchaseLiveAutomationTest {
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
assertEquals(
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=25]",
automation.lastOrderReadFailure?.message,
)
assertEquals(4, driver.postSubmitCaptureCount)
assertEquals(26, driver.postSubmitCaptureCount)
assertEquals(1, driver.postSubmitBackCount)
assertEquals(0, driver.genericSwipes)
assertFalse(driver.clicked.any { it.contains("支付") })
@@ -8,6 +8,16 @@ import org.junit.Assert.assertFalse
import org.junit.Test
class PurchaseResultPayloadTest {
@Test
fun `created order serializes optional payable total separately from unit price`() {
val outcome = PurchaseExecutionOutcome("order_created", message = "test", actualUnitPriceCent = 1600, pddOrderAmountCent = 1300)
val payload = JSONObject(purchaseResultPayload("r", "a", outcome))
assertEquals(1300L, payload.getLong("pddOrderAmountCent"))
assertEquals(1600L, payload.getLong("actualUnitPriceCent"))
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(pddOrderAmountCent = null))).has("pddOrderAmountCent"))
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(resultType = "order_result_unknown"))).has("pddOrderAmountCent"))
}
@Test
fun `unknown order result keeps stable failure and verified price without order fields`() {
val payload = JSONObject(purchaseResultPayload(
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 53a68c1d31d5d03a53e365b407cffeaf3fc75800
synchronized_at: 2026-09-18T07:56:48Z
wiki_revision: a0ad0d4ed6b71676b56276d84d713e85c6b206b8
synchronized_at: 2026-09-19T03:52:01Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -492,3 +492,9 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 列表遍历限定虚拟列表订单卡片的商品行,排除店铺头和支付、确认收货、退款、售后、取消、更多等动作区;详情只展开收货/订单信息。未看到明确列表末尾证据时必须标记未完整扫描,不得宣称全量。
- 客户端密钥与冻结的 Admin Origin 只保存在扩展可信上下文;content script 无权读取。请求禁用 Cookie 与重定向,批次最多 50 条,稳定 UUID 可在 service worker 重启或超时后重放;已确认批次不重复计数。
- 服务端 `POST /api/client/v1/purchase-tasks/order-backfill` 复用 Agent 回填事务校验,但使用独立 client principal,不伪造 Device Token 或 deviceId。Agent 路径仍严格保持设备归属校验及失败后按设备范围回读。
## 下单结果读取及应付总额链路(#325)
分支 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,不能将旧入口的实付含义描述为本次已改成应付。
+16 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f353b476add5081d4d7a1493e5454b6400518923
synchronized_at: 2026-09-18T02:18:13Z
wiki_revision: 9320ce910ddb26881aafc4dfa4b497d62c144b5b
synchronized_at: 2026-09-19T03:52:04Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -630,3 +630,17 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
## Agent 回填订单入口兼容(#307)
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。
## 下单后待付款核单与应付金额(#325)
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 6e5ab1f9c569067f1b98795cbd6c20c2f2421e57
synchronized_at: 2026-09-18T07:57:55Z
wiki_revision: 17d42cfee87b84a306bfd991b24ca3f3948f1354
synchronized_at: 2026-09-19T03:52:23Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1369,3 +1369,11 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
- 客户端入口不要求任务归属某一 Android 设备,但仍复用任务状态、订单号唯一、同批冲突、金额和时间校验。Agent 入口继续要求认证设备归属,两个入口的错误回读范围不得互相扩大。
- `order_created` 且订单号相同只补空金额和空下单时间;已有金额或时间不覆盖。补入页面时间时保存 `backfill:page:` 来源标记并返回 `timeSource=page`;历史已有时间且无来源仍返回 `existing_unknown`。
- 扩展必须冻结启动时的 Origin、客户端身份和天数窗口;禁止 content script 接触密钥。网络超时或服务端逐项 `retryable=true` 时使用原稳定批次 UUID 重试,只有通过数量、索引和字段完整性校验的响应才确认批次。
## 下单结果可选应付总额(#325)
实现分支 fix/325-order-result,2026-09-19;未部署、未真机下单验收。POST /api/agent/v1/purchase-tasks/:taskId/result 的 resultType=order_created 新增可省略/可空 int64 字段 pddOrderAmountCent,人民币分,语义为本次待付款页明确“应付”的订单总额。actualUnitPriceCent 仍为商品单价。仅在唯一订单号和下单时间完整时随成功结果发送,金额缺失/冲突/无效/溢出时 Agent 省略金额,不影响订单事实。服务端拒绝负数或其他 resultType 携带非空金额,字符串/小数/溢出按现有严格 JSON 解码失败。
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
+6
View File
@@ -341,6 +341,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效")
}
if req.PDDOrderAmountCent != nil && (*req.PDDOrderAmountCent < 0 || req.ResultType != "order_created") {
return TaskPayload{}, fail(CodeInvalidRequest, "订单应付金额仅可随已创建订单提交且不能为负数")
}
now := s.Now()
next := ""
switch req.ResultType {
@@ -396,6 +399,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode, a.ErrorMessage = &failureCode, &message
}
if next == models.PurchaseTaskStatusOrderCreated && req.PDDOrderAmountCent != nil && t.PDDOrderAmountCent == nil {
t.PDDOrderAmountCent = req.PDDOrderAmountCent
}
case "order_result_unknown":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
@@ -0,0 +1,88 @@
package purchase
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestOrderResultPayableAmount(t *testing.T) {
ptr := func(n int64) *int64 { return &n }
for _, tc := range []struct {
name string
amount, existing, want *int64
result string
invalid bool
}{
{"payable distinct from unit price", ptr(1300), nil, ptr(1300), "order_created", false},
{"zero", ptr(0), nil, ptr(0), "order_created", false},
{"old agent omitted", nil, nil, nil, "order_created", false},
{"omitted preserves existing", nil, ptr(1200), ptr(1200), "order_created", false},
{"does not overwrite existing", ptr(1300), ptr(1200), ptr(1200), "order_created", false},
{"negative", ptr(-1), nil, nil, "order_created", true},
{"unknown cannot attach amount", ptr(1300), nil, nil, "order_result_unknown", true},
{"failed cannot attach amount", ptr(1300), nil, nil, "failed", true},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if tc.existing != nil {
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("pdd_order_amount_cent", *tc.existing).Error; err != nil {
t.Fatal(err)
}
}
now := s.Now()
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID,
ResultType: tc.result, PDDOrderNo: "PDD-DEMO-325", OrderSubmittedAt: &now,
ActualUnitPriceCent: ptr(1600), PDDOrderAmountCent: tc.amount}
_, err = s.SubmitResult(context.Background(), task.ID, req, f.token)
if tc.invalid {
if code(err) != CodeInvalidRequest {
t.Fatalf("want invalid request, got %v", err)
}
} else {
if err != nil {
t.Fatal(err)
}
replay, e := s.SubmitResult(context.Background(), task.ID, req, f.token)
if e != nil || !replay.Replayed {
t.Fatalf("replay failed: %v", e)
}
req.PDDOrderAmountCent = ptr(999)
if _, e = s.SubmitResult(context.Background(), task.ID, req, f.token); code(e) != CodeResultConflict {
t.Fatalf("changed amount replay accepted: %v", e)
}
queued := loadOrderWriteback(t, db, task.ID)
if queued.OrderNo != "PDD-DEMO-325" {
t.Fatal("SYB order number queue missing")
}
}
var saved models.PurchaseTask
if e := db.First(&saved, task.ID).Error; e != nil {
t.Fatal(e)
}
if (saved.PDDOrderAmountCent == nil) != (tc.want == nil) || (tc.want != nil && *saved.PDDOrderAmountCent != *tc.want) {
t.Fatal("unexpected saved payable amount")
}
if tc.invalid {
if saved.Status != models.PurchaseTaskStatusOrderSubmitStarted {
t.Fatal("invalid result changed status")
}
} else if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != 1600 {
t.Fatal("unit price changed")
}
})
}
}
+1
View File
@@ -157,6 +157,7 @@ type ResultRequest struct {
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`