Compare commits

..
31 changed files with 1350 additions and 87 deletions
@@ -194,16 +194,18 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(node, direction)
}
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
fun clickBackfill(target: SnapshotNode): Boolean {
/** Only the unique All navigation tab may use a gesture; order actions never do. */
fun clickBackfill(target: SnapshotNode, allTabConfirmed: Boolean = false): Boolean {
val page = capture()
BackfillPagePolicy.validate(page)
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
it.bounds == target.bounds && it.className == target.className } ?: return false
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
val allTab = BackfillPagePolicy.allTab(page)?.path == fresh.path
val allowed = BackfillPagePolicy.cards(page, allTabConfirmed).any { it.path == fresh.path } ||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
if (allTab && !fresh.clickable) return dispatchCenterTap(Rect(fresh.bounds.left, fresh.bounds.top, fresh.bounds.right, fresh.bounds.bottom))
if (!allowed || (!allTab && !BackfillPagePolicy.safe(page, fresh))) return false
val root = rootInActiveWindow ?: return false
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
var node = root
@@ -21,6 +21,26 @@ object BackfillPagePolicy {
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
// Only the navigation tab may use a bounded gesture, never an order action.
fun allTab(page: UiSnapshot): SnapshotNode? {
if (detail(page) || page.nodes.none { it.visible && it.label in setOf("我的订单", "全部订单") }) return null
val tab = page.nodes.filter { it.visible && it.enabled && it.label == "全部" }.singleOrNull() ?: return null
val peers = page.nodes.filter { it.visible && it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") &&
it.bounds.top < tab.bounds.bottom && it.bounds.bottom > tab.bounds.top }
val centerX = tab.bounds.left.toLong() + tab.bounds.width / 2
if (peers.map { it.label }.distinct().size < 2 || peers.any {
it.bounds.width <= 0 || it.bounds.left.toLong() + it.bounds.width / 2 <= centerX ||
centerX >= it.bounds.left && centerX < it.bounds.right
}) return null
// WebView navigation labels can overlap by a few pixels. Exclude only
// these verified peers (e.g. 待付款), not actual payment/action nodes.
val navigationPage = page.copy(nodes = page.nodes.filter { it !in peers })
return tab.takeIf { safe(navigationPage, it.copy(clickable = true)) }
}
fun confirmedList(page: UiSnapshot, tabConfirmed: Boolean): Boolean = list(page) ||
(tabConfirmed && allTab(page) != null && page.nodes.none { it.visible && it.selected &&
it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") })
fun expansion(page: UiSnapshot): SnapshotNode? {
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
@@ -28,8 +48,8 @@ object BackfillPagePolicy {
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
}
fun cards(page: UiSnapshot): List<SnapshotNode> {
if (!list(page)) return emptyList()
fun cards(page: UiSnapshot, tabConfirmed: Boolean = false): List<SnapshotNode> {
if (!confirmedList(page, tabConfirmed)) return emptyList()
return page.nodes.filter { node ->
if (!safe(page, node)) return@filter false
if (node.label in setOf("订单详情", "查看详情")) return@filter true
@@ -64,17 +84,38 @@ class OrderBackfillScanner(
private val submit: (BackfillItem) -> Unit,
private val progress: (Int) -> Unit,
) {
private var tabConfirmed = false
private fun listReady(page: UiSnapshot) = BackfillPagePolicy.confirmedList(page, tabConfirmed)
private fun awaitOrders(): UiSnapshot {
var previous: String? = null
repeat(8) {
checkActive()
val page = driver.capture()
if (page.packageName == BackfillPagePolicy.PDD && page.activityName?.startsWith(BackfillPagePolicy.PDD) == true) {
BackfillPagePolicy.validate(page)
if (BackfillPagePolicy.list(page) || BackfillPagePolicy.allTab(page) != null) {
val signature = page.nodes.filter { it.visible && it.label in setOf("我的订单", "全部订单", "全部", "待付款", "待发货", "待收货", "待评价") }
.joinToString { "${it.label}:${it.bounds}:${it.selected}" }
if (previous == signature) return page
previous = signature
} else previous = null
} else previous = null
driver.pause()
}
error("等待订单列表加载超时,未完整扫描")
}
fun scan(): String {
checkActive()
tabConfirmed = false
driver.openOrders()
driver.pause()
var page = read()
var page = awaitOrders()
if (!BackfillPagePolicy.list(page)) {
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
val tab = BackfillPagePolicy.allTab(page)
check(tab != null) { "未识别我的订单-全部" }
act { driver.click(tab) }
page = read()
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
tabConfirmed = true
page = awaitOrders()
check(listReady(page)) { "无法确认全部订单标签" }
}
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
val seenCards = mutableSetOf<String>()
@@ -82,8 +123,8 @@ class OrderBackfillScanner(
var noProgress = 0
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
checkActive()
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
check(listReady(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page, tabConfirmed).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
if (card == null) {
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
checkActive()
@@ -323,7 +323,10 @@ class AgentForegroundService : Service() {
private fun publishBackfill(state: OrderBackfillState) {
backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填:${state.message}")
if (!state.running) android.os.Handler(android.os.Looper.getMainLooper()).post {
android.widget.Toast.makeText(this, "订单回填:${state.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
@@ -358,13 +361,19 @@ class AgentForegroundService : Service() {
checkActive = ::checkActive,
)
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
private var allTabConfirmed = false
override fun openOrders() {
checkActive()
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun capture() = accessibility.capture()
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean {
checkActive()
val clicked = accessibility.clickBackfill(node, allTabConfirmed)
if (clicked && node.label == "全部") allTabConfirmed = true
return clicked
}
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
override fun back(): Boolean {
checkActive()
@@ -259,6 +259,74 @@ class OrderBackfillTest {
}
private val item = BackfillItem("_cg7", "TEST-000007", null)
private fun missingSelectedPage() = page(
node("0/0", "我的订单").copy(bounds = NodeBounds(0, 0, 500, 50)),
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 60, 100, 100)),
node("0/2", "待发货").copy(bounds = NodeBounds(100, 60, 200, 100)),
node("0/3", "待收货").copy(bounds = NodeBounds(200, 60, 300, 100)))
@Test fun `missing selected requires unique safe tab and explicit confirmation`() {
val p = missingSelectedPage()
assertNotNull(BackfillPagePolicy.allTab(p))
assertFalse(BackfillPagePolicy.confirmedList(p, false))
assertTrue(BackfillPagePolicy.confirmedList(p, true))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9"))))
assertFalse(BackfillPagePolicy.confirmedList(p.copy(nodes = p.nodes.map { if (it.label == "待收货") it.copy(selected = true) else it }), true))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9", text = "去支付"))))
}
@Test fun `real navigation bounds tolerate three pixel overlap but never payment actions`() {
val p = page(
node("0/0", "我的订单").copy(bounds = NodeBounds(438, 120, 642, 258)),
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 258, 201, 372)),
node("0/2", "待付款").copy(clickable = false, bounds = NodeBounds(198, 258, 399, 372)),
node("0/3", "待分享").copy(clickable = false, bounds = NodeBounds(396, 258, 597, 372)),
node("0/4", "待发货").copy(clickable = false, bounds = NodeBounds(594, 258, 795, 372)))
assertEquals(p.nodes[1], BackfillPagePolicy.allTab(p))
assertTrue(BackfillPagePolicy.confirmedList(p, true))
assertFalse(BackfillPagePolicy.confirmedList(p, false))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes +
node("0/9", "去支付").copy(bounds = NodeBounds(198, 258, 399, 372)))))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
if (it.label == "待付款") it.copy(bounds = NodeBounds(90, 258, 399, 372)) else it
})))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
if (it.label == "待付款") it.copy(bounds = NodeBounds(-100, 258, 90, 372)) else it
})))
}
@Test fun `scanner waits for stable list and taps only all when selected is missing`() {
var reads = 0
var clicks = 0
val driver = object : BackfillDriver {
override fun openOrders() = Unit
override fun pause() = Unit
override fun capture(): UiSnapshot = if (++reads < 3) UiSnapshot(null, null, emptyList()) else missingSelectedPage()
override fun click(node: SnapshotNode): Boolean { assertEquals("全部", node.label); clicks++; return true }
override fun scroll() = false
override fun back() = false
}
val reason = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, { fail("no upload") }, {}).scan()
assertEquals(1, clicks)
assertTrue(reads >= 6)
assertTrue(reason.contains("未完整扫描"))
}
@Test fun `missing order page times out without any click`() {
var pauses = 0
val driver = object : BackfillDriver {
override fun openOrders() = Unit
override fun pause() { pauses++ }
override fun capture() = UiSnapshot(null, null, emptyList())
override fun click(node: SnapshotNode): Boolean { fail("unexpected click"); return false }
override fun scroll() = false
override fun back() = false
}
try { OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, {}, {}).scan(); fail() }
catch (e: IllegalStateException) { assertTrue(e.message!!.contains("加载超时")) }
assertEquals(8, pauses)
}
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
+14 -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: d192f3976c44b15a53221211eaed903bdec4fcd6
synchronized_at: 2026-09-18T01:23:50Z
wiki_revision: c8dd6b1f25bf367d065e0969a4de858d857a3181
synchronized_at: 2026-09-18T02:18:10Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -473,3 +473,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 本次不修改 SYB 客户端或 #305,金额不进入 SYB 请求。
验证覆盖 Android 解析/载荷/回执缓存、Server 幂等/冲突/旧请求和局部 SQLite 迁移、Web 金额/空值展示;真机端到端、实际 MySQL 迁移和发布须另行授权,不能以本地测试替代。
## SYB 单号回填持久队列(#305)
实现绑定 e89de1a(2026-09-18,未部署),仅 Server/Web;Android 复用现有普通结果与人工订单回填。
- purchase/lifecycle.go、purchase/order_backfill.go → ensureOrderWriteback:与订单事实同事务建立 pending,purchase_task_id 唯一;外部失败不撤销已提交的订单事实。
- models/purchase_order_writeback.go:purchase_order_writeback 保存不可变 stock/detail/单号快照、状态、attempt_count、write_started、租约、脱敏错误和完成时间;purchase_order_writeback_command 保存 requestId/输入摘要/逐项接受结果;purchase_order_writeback_lease 保存全局单例租约。追加迁移 1789800200000,不改旧物流字段,不批量排入历史订单。
- purchase/order_writeback.go:批量命令事务/幂等/条件校验和 Admin 状态投影;order_writeback_handler.go/router.go 暴露 Admin 补偿;access/purchaser.go 纳入原采购权限启动对账目录。
- purchase/order_writeback_worker.go:由 cmd/api/server.go 每数据库启动一个3秒轮询 worker;数据库2分钟单例租约使跨进程及不同任务的相同SYB明细写入串行。写前核对当前任务/关联快照,持久化写入起始标记,并验证租约所有者/剩余时间;不持有数据库事务跨网络请求。失联running恢复只回读,unknown不自动调度;其他同目标未确认写入阻止新写。
- sybclient/order_number.go:一次 POST updateDetailPurchaseCode,不跟随重定向、不使用读接口重试策略。会话仅从现有 SessionStore 恢复,不在 worker 自动登录/OCR;外部原始错误不写入任务原因。
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
+20 -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: d11ff945f559869ad638de2c0da198cbf6f31d7e
synchronized_at: 2026-09-18T01:23:55Z
wiki_revision: 4b61d5dcc75c459831e6b1f0d556b09d6acd0310
synchronized_at: 2026-09-18T02:58:16Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -618,3 +618,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
不判断是否付款,不改变人工支付复核状态。没看到实付、格式异常或多个不同实付值时,仅跳过金额,订单号和时间照常按原规则回填。采购员可稍后再点既有“回填”,为空金额补写;已有相同值幂等,不同值保留旧值并返回金额冲突提示,不撤销正确订单事实。只允许当前设备自己的、原有允许状态的任务;不新增 attempt 或采购动作。
金额仅保存 Admin;#305 订单号回写 SYB 的数据流不得使用该字段或将其映射为 cost,金额缺失/冲突不得成为订单号回写的前置条件。本单不代表 #305 已实现。Admin 现有订单详情卡片增加“实付价格”,缺失显示“尚未获取”,不新增列表列、入口或支付动作。安全点击集合及不保存原始控件树/个人信息的规则不变。
## PDD 订单号自动回填 SYB(#305)
实现绑定 e89de1a(2026-09-18),代码与隔离测试已完成,真实迁移/发布/SYB写入尚未验证。以下补充 #306 的单号后续流程,不改变其金额规则。
正式 SYB 订单采购取得 PDD 单号(普通结果或人工遍历回填)后,在订单事实的同一个数据库事务中保存独立回填记录,后台异步写入对应 stock_id + detail_id。已人工回填相同单号也幂等确保记录存在;不重新采购,不等待支付复核。备货、演练、未取得单号或关联失效不回填;不自动扫描历史单据,历史补偿由采购管理勾选发起。
写前精确回读唯一目标明细:同单号且 purchasePlatform=pdd 直接成功;非空不同单号/不兼容平台标记 conflict,不覆盖。远端为空才允许一次写请求,写后回读确认才成功。未知结果不得自动重复写;重启恢复的 running 只回读,不能因租约过期盲重发。失败/unknown 可人工补偿,仍先回读,且在途租约未到期时不能再发起。
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
## Agent 回填订单入口兼容(#307)
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。
+16 -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: 05441afb3c22828b8d17d4b9dcf2592075092076
synchronized_at: 2026-09-18T01:24:22Z
wiki_revision: 5a61e4f2cf2bb33b824ca722e9cee03071e1dffd
synchronized_at: 2026-09-18T02:18:33Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1345,3 +1345,17 @@ POST /api/agent/v1/purchase-tasks/order-backfill 的 items 可增加 pddOrderAmo
Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实付:,金额元,(免运费)及中英文冒号/逗号/空格,金额整数或最多两位小数,以十进制定点转换。不使用裸人民币数字、不跨节点拼金额;缺失、无效、溢出或多个不同金额时只省略金额。同一订单跨视口去重,切换订单清空。并不据此判断已付款。
追加迁移:1789800100000_purchase_order_amount.go,仅扩展 purchase_task 的可空非负 pdd_order_amount_cent;旧行 NULL,不推算单价×数量。先授权迁移并发布新 Server,再升级 Agent;旧 Server 严格拒绝未知字段,不能先装新 Agent 后向旧 Server 发送金额。本单不扩展普通采购 readOrderResult,也不增加 SYB cost 数据流。
## SYB 采购单号回填(#305)
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + order_created + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录;网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
- Admin 列表/详情 task 新增 orderWriteback={status,reason?,completedAt?,canSubmit}。无队列时符合资格显示 not_started,否则 not_applicable;队列状态 pending/running/succeeded/failed/conflict/unknown。仅未发起、failed/unknown 且任务仍有资格、租约已过期时 canSubmit=true;running/pending/succeeded/conflict 不提供重复发起。
- 回填只写 PDD 订单号;cost 固定0,created为空,绝不传 pddOrderAmountCent 或 actualUnitPriceCent。旧 writebackStatus/支付/物流字段不复用,旧物流 paid 规则不扩展到本接口。
- 批量勾选取“可采购重试 OR 可单号回填”的并集,两个按钮各自过滤子集;不得把 order_created 任务送入采购重试。冲突只查看,不提供覆盖。unknown 人工补偿仍先回读,成功仅指回读确认相同单号与 pdd 平台。
追加迁移 1789800200000_purchase_order_writeback.go 建立独立记录、命令和单例租约表。先授权迁移及权限对账后再运行新版 Server/Web;本实现基于 #306,发布时同时核验其金额列迁移依赖。历史记录不在迁移时自动入队。
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
synchronized_at: 2026-09-11T09:11:21Z
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
synchronized_at: 2026-09-18T02:18:53Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -679,3 +679,15 @@ settings:
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## PDD 采购单号写入(#305)
实现绑定 e89de1a(2026-09-18,未部署)。协议依据用户本地 update_syb_pdd_order_number.har 单次样本;HAR/真实标识/Cookie不进入源码、Wiki或工单。本节不宣称远端幂等或原子比较写入。
POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stock.id>、detailId=<details[].id>、code=<PDD单号>、type=pdd、created=""、cost=0。ID必须为正整数,单号非空、无首尾空白/控制换行、最多100字符。cost是抓包确认的固定参数,不是实际金额;不得传Agent实付价格。
样本成功信封 HTTP200、status=true;随后 listByStock?hist=0 对应明细 purchaseCode 等于提交单号、purchasePlatform=pdd、purchaseStatus=1,purchaseTime由SYB生成。更新存在采购状态/时间副作用。
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
+24 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: a8da51a554b9c43da4b72104d707d3c72c5cd534
synchronized_at: 2026-09-17T08:57:51Z
wiki_revision: 26ff59a7203d84dfe1a8e929fda24204960f1d7c
synchronized_at: 2026-09-18T02:32:37Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -113,3 +113,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- Admin Web 由 `scripts/start-web.ps1` 启动,监听 `0.0.0.0` 的 `ports.web`,因此同一局域网设备可通过 `http://<本机局域网IP>:<web端口>/` 访问。
- 浏览器使用同源相对 API;Vite 将 `/api` 与 `/static` 代理到本机 `127.0.0.1:<server端口>`,避免局域网客户端错误访问自身的 `127.0.0.1`。
- 修改脚本或配置后,只需重启 Supervisor 的 `goauto-admin-ui`;API 未变化时无需重启 `goauto-admin-api`。若局域网仍无法连接,检查 Windows 防火墙是否允许 `ports.web` 的 TCP 入站。
## SYB 订单号回填 worker 部署与排错(#305)
代码绑定 e89de1a(2026-09-18),基于 #306 的07a3817;本节是部署要求,不表示已发布或业务库已迁移。
1. 另行取得明确目标环境的数据库迁移、权限对账和发布授权。核对 #306 的1789800100000金额列前置;本单追加1789800200000_purchase_order_writeback.go,建立purchase_order_writeback、purchase_order_writeback_command、purchase_order_writeback_lease三表和单例行,不改任务/付款事实,不排入历史回填、不修改定时任务开关。
2. 配套构建并发布Server/Web,沿用现有启动权限对账为管理员/采购员登记POST /api/admin/v1/purchase-tasks/syb-order-writeback。不新增角色、菜单或支付门槛。旧常驻实例使用SkipMigration时,仅重启并不能替代授权迁移。
3. API启动后每数据库恢复一个3秒轮询的持久队列worker,独立于Admin定时任务列表;只处理已排入pending及租约过期running。正式采购结果、人工订单回填成功/同号回放会排队;历史补偿仅用户勾选发起。部署重启后已有pending会执行真实SYB写入,必须包含在发布授权范围内。
4. 全局租约2分钟;会话恢复预算25秒,每次外部读/写20秒(只读客户端重试亦受该context限制),写前再次核验租约。崩溃恢复只回读,不盲目重写;unknown不自动重试,等租约到期再人工核对/补偿。同目标其他未确认写入先解决,不能删除记录规避保护。
5. 排错先看采购管理的“SYB 单号回填”状态、脱敏原因、完成时间;需要结构诊断时查询记录的taskId/status/attempt_count/error_code/lease_expires_at,不输出单号、Cookie或完整原始响应。SYB会话失败应先恢复既有登录,再勾选失败任务补偿;按钮受理不等于SYB成功。conflict只核对,不强制覆盖。
6. 不执行真实支付;实付金额只存Admin,SYB请求cost固定0。停止新版API可停止新轮询,但不保证已发送远端请求取消;回滚保留追加表和未知状态,不删除记录、不回退采购事实。迁移/多实例MySQL/真实SYB端到端仍需授权验收。
### #305/#306 线上发布验证(2026-09-18)
用户授权合并main、迁移并发布线上;main已快进至7e257ca(包含#306及#305),Server/Web从该提交重新构建。此前线上RELEASE记录60c7526;新发布目录为/home/goauto/releases/20260918-7e257ca-305,current已原子切换,goauto.service重启、Nginx校验及reload成功。先前各节“未部署”是对应提交阶段记录,本节为后续发布事实。
- 迁移前51个版本已应用,唯一缺失为1789800100000和1789800200000;服务器内受限备份/home/goauto/backups/20260918-305/database.sql(目录0700、文件0600),不下载生产数据。执行后53版本齐全,purchase_task.pdd_order_amount_cent为nullable BIGINT,独立三表和单例租约行已建立,历史回填队列0条。
- 启动对账已登记新POST接口及purchaser权限,管理员沿用原授权;已登录采购列表返回orderWriteback,空批次请求返回422且不入队。未执行真实订单回填或付款,不将空队列健康验证宣称为SYB端到端验收。
- 保持sys_job原启停值不变(任务1/2为1,3/4/5为2),没有更改Cron或主动触发同步。发布前采集/采购运行数均0。
- 原20260916目录的static/var与20260914目录互相循环,不能继续通过旧current间接链接。新release分别直接引用已确认真实存在的/home/goauto/releases/20260907-29ba16e-236/static和/home/goauto/releases/20260903-2d6d244-r1/var;不删除或改写历史资源,不修改其他站点Nginx配置。后续清理旧release前必须检查这些被引用的数据目录。
- 公网入口http://185.216.248.75:9527首页和10项JS/CSS资源HTTP200,/api/v1/health HTTP200;未认证采购接口业务码401。二进制SHA256=4833d779843f0613455ea2b56cadff6efd88d2b96c13e00cb4423cac02e72cb7;发布后结构日志检查无panic/fatal/1146/1054。
- 回滚代码目标保留/home/goauto/releases/20260916-03647dd,追加数据库表/列保留;该旧目录原有static/var循环是已知问题,回退不能宣称资源链自动恢复。不要删除新队列记录或恢复旧数据覆盖后续业务;停止服务亦不保证已发送远端请求取消。
+1
View File
@@ -110,6 +110,7 @@ var AdminAPIs = []APIPermission{
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
@@ -2,6 +2,15 @@ package access
import "testing"
func TestPurchaserMayWritebackOrderNumber(t *testing.T) {
for _, permission := range PurchaserAPIs() {
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/syb-order-writeback" {
return
}
}
t.Fatal("missing purchaser order number writeback permission")
}
func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, permission := range AdminAPIs {
+3
View File
@@ -56,6 +56,9 @@ func MigratedModels() []any {
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.PurchaseOrderWriteback{},
&models.PurchaseOrderWritebackLease{},
&models.PurchaseOrderWritebackCommand{},
&models.PurchaseSpecMatchWorkItem{},
&models.CollectionRule{},
&models.PurchaseRule{},
@@ -0,0 +1,43 @@
package models
import "time"
// PurchaseOrderWriteback is independent of the legacy logistics writeback fields.
type PurchaseOrderWriteback struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
PurchaseTaskID uint64 `gorm:"not null;uniqueIndex"`
StockID int64 `gorm:"not null;index:idx_order_writeback_target,priority:1"`
DetailID int64 `gorm:"not null;index:idx_order_writeback_target,priority:2"`
OrderNo string `json:"-" gorm:"size:100;not null"`
Status string `gorm:"size:24;not null;index"`
AttemptCount int `gorm:"not null;default:0"`
WriteStarted bool `gorm:"not null;default:false"`
LeaseOwner string `gorm:"size:36"`
LeaseExpiresAt *time.Time
ErrorCode string `gorm:"size:80"`
ErrorMessage string `gorm:"size:300"`
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (PurchaseOrderWriteback) TableName() string { return "purchase_order_writeback" }
// A singleton lease serializes all SYB order-number writers, including different
// tasks that resolve to the same remote stock/detail identity.
type PurchaseOrderWritebackLease struct {
ID uint64 `gorm:"primaryKey"`
Owner string `gorm:"size:36"`
ExpiresAt *time.Time
}
func (PurchaseOrderWritebackLease) TableName() string { return "purchase_order_writeback_lease" }
type PurchaseOrderWritebackCommand struct {
RequestID string `gorm:"primaryKey;size:36"`
InputHash string `gorm:"size:64;not null"`
ResultJSON string `json:"-" gorm:"type:text;not null"`
CreatedAt time.Time
}
func (PurchaseOrderWritebackCommand) TableName() string { return "purchase_order_writeback_command" }
+64 -53
View File
@@ -25,59 +25,60 @@ type AdminListRequest struct {
}
type AdminTaskItem struct {
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
OrderWriteback OrderWritebackView `json:"orderWriteback"`
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
}
type AdminAttemptItem struct {
@@ -170,8 +171,13 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
return AdminListResponse{}, err
}
items := make([]AdminTaskItem, 0, len(tasks))
writebacks, err := s.OrderWritebackViews(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
}
for _, task := range tasks {
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching[task.ID]
if task.ShopeeProductID != nil {
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
@@ -237,6 +243,11 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
return AdminDetailResponse{}, matchErr
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
if wbErr != nil {
return AdminDetailResponse{}, wbErr
}
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
if err != nil {
@@ -125,7 +125,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} {
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry, (Handler{}).AdminOrderWriteback} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
+3
View File
@@ -448,6 +448,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if e := tx.Save(t).Error; e != nil {
return TaskPayload{}, conflictOrInternal(e)
}
if e := ensureOrderWriteback(tx, *t); e != nil {
return TaskPayload{}, internal(e)
}
return valuePayload(s, t, a, false)
})
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
+2 -2
View File
@@ -146,7 +146,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
}
}
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
return nil
return ensureOrderWriteback(tx, task)
}
var submitted time.Time
source := "page"
@@ -186,7 +186,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
return err
}
r.Result, r.Code = "backfilled", "BACKFILLED"
return nil
return ensureOrderWriteback(tx, task)
})
if err != nil {
r.Result, r.Code = "failed", CodeInternal
@@ -0,0 +1,186 @@
package purchase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type OrderWritebackView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CanSubmit bool `json:"canSubmit"`
}
type OrderWritebackRequest struct {
RequestID string `json:"requestId"`
PurchaseTaskIDs []uint64 `json:"purchaseTaskIds"`
}
type OrderWritebackAcceptance struct {
TaskID uint64 `json:"taskId"`
Result string `json:"result"`
Reason string `json:"reason,omitempty"`
}
type OrderWritebackResponse struct {
Items []OrderWritebackAcceptance `json:"items"`
}
func orderWritebackEligible(t models.PurchaseTask) bool {
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
}
// Called within the same transaction as the order fact. Never contacts SYB.
func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
if !orderWritebackEligible(t) {
return nil
}
var syb models.SYBProduct
if err := tx.First(&syb, *t.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if syb.StockID <= 0 || syb.DetailID <= 0 {
return nil
}
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
return nil
}
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
}
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
out := map[uint64]OrderWritebackView{}
ids := make([]uint64, 0, len(tasks))
for _, t := range tasks {
ids = append(ids, t.ID)
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
if orderWritebackEligible(t) {
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
}
out[t.ID] = v
}
if len(ids) == 0 {
return out, nil
}
var rows []models.PurchaseOrderWriteback
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&rows).Error; err != nil {
return nil, internal(err)
}
for _, r := range rows {
v := out[r.PurchaseTaskID]
v.Status = r.Status
v.Reason = r.ErrorMessage
v.CompletedAt = r.CompletedAt
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
out[r.PurchaseTaskID] = v
}
return out, nil
}
func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackRequest) (OrderWritebackResponse, error) {
out := OrderWritebackResponse{Items: []OrderWritebackAcceptance{}}
if uuid.Validate(req.RequestID) != nil || len(req.PurchaseTaskIDs) == 0 || len(req.PurchaseTaskIDs) > 100 {
return out, fail(CodeInvalidRequest, "请选择1~100条采购任务")
}
ids := append([]uint64(nil), req.PurchaseTaskIDs...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for i, id := range ids {
if id == 0 || (i > 0 && id == ids[i-1]) {
return out, fail(CodeInvalidRequest, "任务编号无效或重复")
}
}
data, _ := json.Marshal(ids)
sum := sha256.Sum256(data)
fingerprint := hex.EncodeToString(sum[:])
db := s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
err := db.Transaction(func(tx *gorm.DB) error {
// Serializes batch command replay without holding a lock across remote IO.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
return err
}
var guard models.PurchaseOrderWritebackLease
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&guard, 1).Error; err != nil {
return err
}
var command models.PurchaseOrderWritebackCommand
err := tx.First(&command, "request_id = ?", req.RequestID).Error
if err == nil {
if command.InputHash != fingerprint {
return fail(CodeInvalidRequest, "同一requestId不能改变任务集合")
}
return json.Unmarshal([]byte(command.ResultJSON), &out)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
for _, id := range ids {
a := OrderWritebackAcceptance{TaskID: id, Result: "skipped", Reason: "任务不存在或不符合回填条件"}
var task models.PurchaseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, id).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
out.Items = append(out.Items, a)
continue
}
if !orderWritebackEligible(task) {
out.Items = append(out.Items, a)
continue
}
if err := ensureOrderWriteback(tx, task); err != nil {
return err
}
var row models.PurchaseOrderWriteback
if err := tx.Where("purchase_task_id = ?", id).First(&row).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
a.Reason = "SYB明细关联已失效"
out.Items = append(out.Items, a)
continue
}
switch {
case row.OrderNo != *task.PDDOrderNo:
a.Result, a.Reason = "conflict", "订单号快照不一致,请人工核对"
case row.Status == "succeeded":
a.Result, a.Reason = "succeeded", "已回填"
case row.Status == "conflict":
a.Result, a.Reason = "conflict", row.ErrorMessage
case row.Status == "running" || (row.LeaseExpiresAt != nil && row.LeaseExpiresAt.After(s.Now())):
a.Reason = "正在回填或等待在途请求结束"
case row.Status == "pending":
a.Result, a.Reason = "pending", "已加入回填"
default:
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
return err
}
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
}
out.Items = append(out.Items, a)
}
result, _ := json.Marshal(out)
return tx.Create(&models.PurchaseOrderWritebackCommand{RequestID: req.RequestID, InputHash: fingerprint, ResultJSON: string(result)}).Error
})
if err != nil {
var e *ServiceError
if errors.As(err, &e) {
return out, err
}
return out, internal(err)
}
return out, nil
}
@@ -0,0 +1,26 @@
package purchase
import (
"github.com/gin-gonic/gin"
)
func (h Handler) AdminOrderWriteback(c *gin.Context) {
if !allowedOperator(c) {
return
}
var req OrderWritebackRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.RequestOrderWriteback(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
writeAdminData(c, out)
}
@@ -0,0 +1,285 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"gorm.io/gorm"
)
type fakeOrderNumberClient struct {
code, platform string
reads, writes int
writeErr, readErr error
apply bool
duplicate bool
beforeWrite func()
}
func (f *fakeOrderNumberClient) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
f.reads++
if f.readErr != nil {
return nil, f.readErr
}
d := sybclient.DetailItem{ID: 1, Raw: map[string]any{"purchaseCode": f.code, "purchasePlatform": f.platform}}
details := []sybclient.DetailItem{{ID: 99, Raw: map[string]any{"purchaseCode": "UNRELATED", "purchasePlatform": "pdd"}}, d}
if f.duplicate {
details = append(details, d)
}
return []sybclient.StockDetail{{ID: ids[0], Details: details}}, nil
}
func (f *fakeOrderNumberClient) UpdateDetailPurchaseCode(_ context.Context, stock, detail int64, code string) error {
if stock != 2 || detail != 1 {
panic("wrong remote identity")
}
f.writes++
if f.beforeWrite != nil {
f.beforeWrite()
}
if f.apply {
f.code, f.platform = code, "pdd"
}
return f.writeErr
}
func orderWritebackFixture(t *testing.T) (*Service, models.PurchaseTask) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
result := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "EXAMPLE-ORDER"))[0]
if result.Code != "BACKFILLED" {
t.Fatalf("backfill=%s", result.Code)
}
return s, loadBackfillTask(t, db, task.ID)
}
func loadOrderWriteback(t *testing.T, db *gorm.DB, id uint64) models.PurchaseOrderWriteback {
t.Helper()
var r models.PurchaseOrderWriteback
if err := db.Where("purchase_task_id = ?", id).First(&r).Error; err != nil {
t.Fatal(err)
}
return r
}
func wbWorker(s *Service, f *fakeOrderNumberClient) *OrderWritebackWorker {
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return f, nil }}
}
func TestOrderWritebackRemoteOutcomes(t *testing.T) {
for _, tc := range []struct {
name string
fake fakeOrderNumberClient
want string
writes int
}{
{"write_and_verify", fakeOrderNumberClient{apply: true}, "succeeded", 1},
{"same_value", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "pdd"}, "succeeded", 0},
{"different_value", fakeOrderNumberClient{code: "OTHER", platform: "pdd"}, "conflict", 0},
{"different_platform", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "other"}, "conflict", 0},
{"empty_code_other_platform", fakeOrderNumberClient{platform: "other"}, "conflict", 0},
{"unknown_applied", fakeOrderNumberClient{apply: true, writeErr: sybclient.ErrWriteResultUnknown}, "succeeded", 1},
{"unknown_unapplied", fakeOrderNumberClient{writeErr: sybclient.ErrWriteResultUnknown}, "unknown", 1},
{"success_not_visible", fakeOrderNumberClient{}, "unknown", 1},
{"explicit_rejection", fakeOrderNumberClient{writeErr: sybclient.ErrSessionInvalid}, "failed", 1},
{"read_failure", fakeOrderNumberClient{readErr: errors.New("offline")}, "failed", 0},
{"ambiguous_target", fakeOrderNumberClient{duplicate: true}, "failed", 0},
} {
t.Run(tc.name, func(t *testing.T) {
s, task := orderWritebackFixture(t)
f := tc.fake
w := wbWorker(s, &f)
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWriteback(t, s.DB, task.ID)
if row.Status != tc.want || f.writes != tc.writes {
t.Fatalf("status=%s writes=%d", row.Status, f.writes)
}
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if f.writes != tc.writes {
t.Fatal("automatically repeated write")
}
after := loadBackfillTask(t, s.DB, task.ID)
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
t.Fatal("changed purchase/payment/logistics facts")
}
})
}
}
func TestOrderWritebackRecoveryNeverBlindWrites(t *testing.T) {
s, task := orderWritebackFixture(t)
row := loadOrderWriteback(t, s.DB, task.ID)
expired := s.Now().Add(-time.Minute)
if err := s.DB.Model(&row).Updates(map[string]any{"status": "running", "write_started": true, "lease_owner": "crashed", "lease_expires_at": expired}).Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || f.writes != 0 {
t.Fatal("crash recovery wrote remotely")
}
// Manual compensation remains disabled while the previous lease could be alive.
r, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || r.Items[0].Result != "skipped" {
t.Fatalf("in-flight manual retry: %v %+v", err, r)
}
s.Now = func() time.Time { return expired.Add(5 * time.Minute) }
r, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || r.Items[0].Result != "pending" {
t.Fatal("manual compensation rejected")
}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "succeeded" || f.writes != 1 {
t.Fatal("manual compensation failed")
}
}
func TestOrderWritebackBatchReplayAndPartialAcceptance(t *testing.T) {
s, task := orderWritebackFixture(t)
req := OrderWritebackRequest{uuid.NewString(), []uint64{task.ID, 9999}}
first, err := s.RequestOrderWriteback(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if first.Items[0].Result != "pending" || first.Items[1].Result != "skipped" {
t.Fatalf("%+v", first)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
again, err := s.RequestOrderWriteback(context.Background(), req)
if err != nil || again.Items[0].Result != "pending" || f.writes != 1 {
t.Fatal("replay changed acceptance or wrote")
}
req.PurchaseTaskIDs = []uint64{task.ID}
if _, err = s.RequestOrderWriteback(context.Background(), req); err == nil {
t.Fatal("changed requestId content accepted")
}
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || views[task.ID].Status != "succeeded" || views[task.ID].CanSubmit {
t.Fatal("incorrect admin view")
}
}
func TestOrderWritebackGlobalClaimSerializesWriters(t *testing.T) {
s, task := orderWritebackFixture(t)
f := &fakeOrderNumberClient{apply: true}
other := &fakeOrderNumberClient{apply: true}
f.beforeWrite = func() {
if ok, err := wbWorker(s, other).RunOnce(context.Background()); err != nil || ok {
t.Fatalf("parallel claim %v %v", ok, err)
}
}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if f.writes != 1 || other.writes != 0 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
t.Fatal("concurrent writer")
}
}
func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
s, task := orderWritebackFixture(t)
if err := s.DB.Where("purchase_task_id = ?", task.ID).Delete(&models.PurchaseOrderWriteback{}).Error; err != nil {
t.Fatal(err)
}
wantErr := errors.New("rollback")
err := s.DB.Transaction(func(tx *gorm.DB) error {
if e := ensureOrderWriteback(tx, task); e != nil {
return e
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatal(err)
}
var count int64
s.DB.Model(&models.PurchaseOrderWriteback{}).Count(&count)
if count != 0 {
t.Fatal("queue escaped transaction")
}
var dev models.AgentDevice
s.DB.First(&dev, *task.DeviceID)
// Directly exercise the already_backfilled branch without needing a raw token.
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
if r.Result != "already_backfilled" {
t.Fatal(r.Code)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "pending" {
t.Fatal("same-order replay did not ensure queue")
}
}
func TestOrderWritebackAdminEnvelopeAndOperator(t *testing.T) {
gin.SetMode(gin.TestMode)
s, task := orderWritebackFixture(t)
for _, role := range []string{"admin", "purchaser"} {
body, _ := json.Marshal(OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
r := httptest.NewRecorder()
c, _ := gin.CreateTestContext(r)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/syb-order-writeback", strings.NewReader(string(body)))
(Handler{DB: s.DB}).AdminOrderWriteback(c)
var response struct {
Code int `json:"code"`
Data OrderWritebackResponse `json:"data"`
}
if err := json.Unmarshal(r.Body.Bytes(), &response); err != nil || r.Code != 200 || response.Code != 200 || len(response.Data.Items) != 1 || response.Data.Items[0].Result != "pending" {
t.Fatalf("admin contract rejected role=%s status=%d", role, r.Code)
}
if r.Header().Get("Cache-Control") != "no-store" {
t.Fatal("missing cache policy")
}
}
}
func TestOrderWritebackOtherUnknownTargetBlocksNewWrite(t *testing.T) {
s, task := orderWritebackFixture(t)
other := models.PurchaseOrderWriteback{PurchaseTaskID: task.ID + 100, StockID: 2, DetailID: 1, OrderNo: "OTHER", Status: "unknown", WriteStarted: true}
if err := s.DB.Create(&other).Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || got.ErrorCode != "SYB_TARGET_IN_FLIGHT" || f.writes != 0 {
t.Fatal("another unresolved target was overwritten")
}
}
func TestOrderWritebackSnapshotChangeDoesNotWrite(t *testing.T) {
s, task := orderWritebackFixture(t)
if err := s.DB.Model(&task).Update("pdd_order_no", "CHANGED").Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "conflict" || f.writes != 0 {
t.Fatal("changed snapshot written")
}
}
@@ -0,0 +1,213 @@
package purchase
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"go-admin/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type OrderNumberClient interface {
DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error)
UpdateDetailPurchaseCode(context.Context, int64, int64, string) error
}
type OrderWritebackWorker struct {
DB *gorm.DB
Now func() time.Time
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
}
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
cfg := config.ExtConfig.SYB.Resolved()
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
if err != nil {
return nil, err
}
c, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
return nil, err
}
return c, nil
}
// One short-lived claim at a time across processes. No business writes occur
// during startup itself; only explicitly persisted pending records are handled.
func RecoverOrderWritebacks(db *gorm.DB) {
go func() {
w := OrderWritebackWorker{DB: db, Now: func() time.Time { return time.Now().UTC() }, Factory: restoreOrderWritebackClient}
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
_, _ = w.RunOnce(context.Background())
}
}()
}
func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if w.Now == nil {
w.Now = func() time.Time { return time.Now().UTC() }
}
if w.Factory == nil {
w.Factory = restoreOrderWritebackClient
}
db := w.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
return false, err
}
owner := uuid.NewString()
now := w.Now()
expires := now.Add(2 * time.Minute)
claim := db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND (expires_at IS NULL OR expires_at <= ?)", now).Updates(map[string]any{"owner": owner, "expires_at": expires})
if claim.Error != nil {
return false, claim.Error
}
if claim.RowsAffected != 1 {
return false, nil
}
defer db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND owner = ?", owner).Updates(map[string]any{"owner": "", "expires_at": nil})
var item models.PurchaseOrderWriteback
recovering := false
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
return err
}
recovering = item.Status == "running"
return tx.Model(&item).Updates(map[string]any{"status": "running", "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": owner, "lease_expires_at": expires}).Error
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
finish := func(status, code, message string) error {
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
if status != "unknown" {
updates["lease_expires_at"] = nil
}
if status == "succeeded" {
updates["completed_at"] = w.Now()
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
}
var task models.PurchaseTask
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
}
var syb models.SYBProduct
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
}
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
}
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
client, err := w.Factory(callCtx, db)
cancel()
if err != nil {
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
}
read := func() (string, string, error) {
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
defer stop()
rows, e := client.DetailListByStock(readCtx, []int64{item.StockID})
if e != nil {
return "", "", e
}
found := 0
code, platform := "", ""
for _, stock := range rows {
if stock.ID != item.StockID {
continue
}
for _, detail := range stock.Details {
if detail.ID == item.DetailID {
found++
var ok bool
code, ok = detail.Raw["purchaseCode"].(string)
if !ok && detail.Raw["purchaseCode"] != nil {
return "", "", errors.New("invalid purchase code")
}
platform, ok = detail.Raw["purchasePlatform"].(string)
if !ok && detail.Raw["purchasePlatform"] != nil {
return "", "", errors.New("invalid platform")
}
}
}
}
if found != 1 {
return "", "", errors.New("ambiguous remote detail")
}
return code, platform, nil
}
code, platform, err := read()
if err != nil {
status := "failed"
if recovering || item.WriteStarted {
status = "unknown"
}
return true, finish(status, "SYB_READ_FAILED", "无法回读SYB目标明细,请恢复连接后重试")
}
if code == item.OrderNo && platform == "pdd" {
return true, finish("succeeded", "", "")
}
if code != "" || (platform != "" && platform != "pdd") {
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB已有不同单号或平台,未覆盖,请人工核对")
}
if recovering || item.WriteStarted {
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "上次写入结果未确认,未自动重发,请人工核对后重试")
}
var blocked int64
if err = db.Model(&models.PurchaseOrderWriteback{}).Where("id <> ? AND stock_id = ? AND detail_id = ? AND write_started = ? AND status IN ?", item.ID, item.StockID, item.DetailID, true, []string{"running", "unknown"}).Count(&blocked).Error; err != nil {
return true, err
}
if blocked > 0 {
return true, finish("unknown", "SYB_TARGET_IN_FLIGHT", "同一SYB明细有其他未确认写入,请先核对该记录")
}
// Fence immediately before the sole external write. A recovered owner never
// writes; a durable marker survives crashes between request and acknowledgment.
err = db.Transaction(func(tx *gorm.DB) error {
var lease models.PurchaseOrderWritebackLease
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&lease, 1).Error; e != nil {
return e
}
if lease.Owner != owner || lease.ExpiresAt == nil || !lease.ExpiresAt.After(w.Now().Add(25*time.Second)) {
return errors.New("write lease lost")
}
r := tx.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND lease_owner = ? AND status = 'running'", item.ID, owner).Update("write_started", true)
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return errors.New("item lease lost")
}
return nil
})
if err != nil {
return true, err
}
writeCtx, stop := context.WithTimeout(ctx, 20*time.Second)
writeErr := client.UpdateDetailPurchaseCode(writeCtx, item.StockID, item.DetailID, item.OrderNo)
stop()
code, platform, err = read()
if err == nil && code == item.OrderNo && platform == "pdd" {
return true, finish("succeeded", "", "")
}
if err == nil && (code != "" || (platform != "" && platform != "pdd")) {
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB单号或平台与目标不一致,未覆盖")
}
if writeErr != nil && !errors.Is(writeErr, sybclient.ErrWriteResultUnknown) {
return true, finish("failed", "SYB_WRITE_REJECTED", "SYB拒绝回填,请检查会话与明细后重试")
}
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "写入后尚未回读确认,请人工核对后重试")
}
+1
View File
@@ -32,6 +32,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
admin.POST("/batch", h.AdminBatchCreate)
admin.POST("/batch-retry", h.AdminBatchRetry)
admin.POST("/syb-order-writeback", h.AdminOrderWriteback)
admin.POST("/stock", h.AdminCreateStock)
admin.GET("/:taskId", h.AdminDetail)
admin.POST("", h.AdminCreate)
@@ -289,6 +289,10 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
queued := loadOrderWriteback(t, db, task.ID)
if queued.Status != "pending" || queued.OrderNo != "PDD-1" {
t.Fatalf("order result did not enqueue writeback: status=%s", queued.Status)
}
replay, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
if err != nil || !replay.Replayed {
t.Fatalf("result replay failed: %+v %v", replay, err)
@@ -0,0 +1,29 @@
package sybclient
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"unicode/utf8"
)
// UpdateDetailPurchaseCode sends exactly one write. No payment or amount is
// inferred: cost=0 and created="" are the confirmed SYB protocol constants.
func (c *Client) UpdateDetailPurchaseCode(ctx context.Context, stockID, detailID int64, code string) error {
if stockID <= 0 || detailID <= 0 || strings.TrimSpace(code) == "" || code != strings.TrimSpace(code) || utf8.RuneCountInString(code) > 100 || strings.ContainsAny(code, "\r\n\t") {
return fmt.Errorf("采购单号回填参数无效")
}
// A 307/308 redirect must not replay a mutation or forward credentials.
client := *c
httpClient := *c.http
httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
client.http = &httpClient
_, err := client.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailPurchaseCode", url.Values{
"id": {strconv.FormatInt(stockID, 10)}, "detailId": {strconv.FormatInt(detailID, 10)},
"code": {code}, "type": {"pdd"}, "created": {""}, "cost": {"0"},
}, nil)
return classifyInnerCodeWriteError(err)
}
@@ -0,0 +1,63 @@
package sybclient
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestPurchaseCodeSingleWriteContract(t *testing.T) {
for _, tc := range []struct {
name, body string
status int
unknown bool
}{
{"success", `{"status":true,"data":null}`, 200, false},
{"business", `{"status":false,"msg":"rejected"}`, 200, false},
{"login", `{"status":false,"msg":"未登录"}`, 200, false},
{"bad_json", `broken`, 200, true},
{"server_error", ``, 502, true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
q := r.URL.Query()
body, _ := io.ReadAll(r.Body)
if r.Method != "POST" || r.URL.Path != "/am/stock/detail/updateDetailPurchaseCode" || q.Get("id") != "2" || q.Get("detailId") != "1" || q.Get("code") != "EXAMPLE-ORDER" || q.Get("type") != "pdd" || q.Get("cost") != "0" || !q.Has("created") || q.Get("created") != "" || len(q) != 6 || len(body) != 0 {
t.Error("invalid contract")
}
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER")
if calls != 1 || errors.Is(err, ErrWriteResultUnknown) != tc.unknown {
t.Fatalf("calls=%d unknown=%v", calls, errors.Is(err, ErrWriteResultUnknown))
}
if (err == nil) != (tc.name == "success") {
t.Fatal("unexpected outcome")
}
if e := c.UpdateDetailPurchaseCode(context.Background(), 0, 1, "EXAMPLE-ORDER"); e == nil || calls != 1 {
t.Fatal("invalid request sent")
}
})
}
}
func TestPurchaseCodeDoesNotFollowRedirect(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Redirect(w, r, "/again", http.StatusTemporaryRedirect)
}))
defer srv.Close()
c, _ := New(srv.URL)
if err := c.UpdateDetailPurchaseCode(context.Background(), 2, 1, "EXAMPLE-ORDER"); err == nil || calls != 1 {
t.Fatalf("redirect must not repeat write: calls=%d", calls)
}
}
+1
View File
@@ -119,6 +119,7 @@ func run() error {
}
goautoreplacement.RecoverMatching(db)
goautopurchase.RecoverPurchaseMatching(db)
goautopurchase.RecoverOrderWritebacks(db)
goautotask.RecoverReplacementActivations(db)
}
offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background())
@@ -0,0 +1,28 @@
package version_local
import (
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"runtime"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migratePurchaseOrderWriteback)
}
// Append-only schema. Never queues historical purchases or changes job switches.
func migratePurchaseOrderWriteback(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.PurchaseOrderWriteback{}, &models.PurchaseOrderWritebackCommand{}, &models.PurchaseOrderWritebackLease{}); err != nil {
return err
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,50 @@
package version_local
import (
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"testing"
)
func TestPurchaseOrderWritebackMigrationPreservesFacts(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text)").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("INSERT INTO purchase_task VALUES (1, 'order_created')").Error; err != nil {
t.Fatal(err)
}
if err = migratePurchaseOrderWriteback(db, "1789800200000"); err != nil {
t.Fatal(err)
}
if err = db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1").Update("owner", "active-owner").Error; err != nil {
t.Fatal(err)
}
// Repeating schema work must neither queue history nor reset an active lease.
if err = migratePurchaseOrderWriteback(db, "repeat-schema-test"); err != nil {
t.Fatal(err)
}
var lease models.PurchaseOrderWritebackLease
if err = db.First(&lease, 1).Error; err != nil || lease.Owner != "active-owner" {
t.Fatal("lease reset", err)
}
var count int64
if err = db.Model(&models.PurchaseOrderWriteback{}).Count(&count).Error; err != nil || count != 0 {
t.Fatal("history queued", err)
}
var status string
if err = db.Table("purchase_task").Select("status").Where("id = 1").Scan(&status).Error; err != nil || status != "order_created" {
t.Fatal("purchase changed", err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", "1789800200000").Count(&count).Error; err != nil || count != 1 {
t.Fatal("version missing", err)
}
}
+1
View File
@@ -6,6 +6,7 @@ export function previewPurchaseTasks(data, options = {}) { return request({ url:
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data, timeout: 60000 }) }
export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) }
export function writebackPurchaseOrderNumbers(data) { return request({ url: '/api/admin/v1/purchase-tasks/syb-order-writeback', method: 'post', data }) }
export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) }
export function createPurchaseTask(data) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'post', data, timeout: 60000 }) }
export function getPurchaseMatching(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching`, method: 'get' }) }
+35 -5
View File
@@ -11,11 +11,11 @@
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item class="retry-action"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retrySelection.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retrySelection.length }})</el-button></el-form-item>
</el-form>
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
<el-table ref="taskTable" v-loading="loading" :data="tasks" row-key="id" border stripe empty-text="暂无采购任务" :row-class-name="retryRowClass" @selection-change="handleRetrySelection">
<el-table-column type="selection" width="48" :selectable="isRetrySelectable" />
<el-table-column type="selection" width="48" :selectable="isSelectable" />
<el-table-column label="任务" width="105"><template #default="{ row }"><div class="primary">CG-{{ row.id }}</div><el-tag size="small" effect="plain" :type="row.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(row.executionMode) }}</el-tag></template></el-table-column>
<el-table-column label="类型" width="110"><template #default="{ row }"><el-tag size="small" effect="plain" :type="taskTypeTag(row.taskType)">{{ taskTypeLabel(row.taskType) }}</el-tag></template></el-table-column>
<el-table-column label="蝦皮订单号" min-width="160"><template #default="{ row }"><span class="order-number">{{ row.shopeeOrderNoSnapshot || '—' }}</span></template></el-table-column>
@@ -26,6 +26,7 @@
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
<el-table-column label="SYB 单号回填" min-width="170"><template #default="{ row }"><span>{{ orderWritebackLabel(row.orderWriteback?.status) }}</span><div class="muted">{{ row.orderWriteback?.reason || '' }}</div></template></el-table-column>
<el-table-column label="创建时间" width="170"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
@@ -59,9 +60,11 @@
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small><small>实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}</small></div>
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
<div class="state-card"><span>物流</span><strong>{{ logisticsLabel(detail.task.logisticsStatus) }}</strong><small>{{ detail.task.trackingNo || '尚未取得快递单号' }}</small></div>
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 物流回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>
</div></section>
<section><h3>SYB 单号回填</h3><div class="state-card"><strong>{{ orderWritebackLabel(detail.task.orderWriteback?.status) }}</strong><small>{{ detail.task.orderWriteback?.reason || '仅回填 PDD 订单号,不回填实付价格,不要求已支付。' }}</small><small>完成时间:{{ formatTime(detail.task.orderWriteback?.completedAt) }}</small><div><el-button v-if="canOrderWriteback(detail.task)" type="primary" plain :loading="orderWriteback.saving" @click="submitOrderWriteback([detail.task])">{{ detail.task.orderWriteback.status === 'not_started' ? '回填 SYB 单号' : '重试回填 SYB 单号' }}</el-button><el-button :loading="detail.loading" @click="refreshOrderWritebackDetail">刷新状态</el-button></div></div></section>
<section><h3>执行记录</h3><el-table :data="detail.attempts" border size="small" empty-text="暂无执行记录">
<el-table-column label="次数" width="70"><template #default="{ row }">#{{ row.attemptNumber }}</template></el-table-column><el-table-column label="阶段" prop="phase" min-width="120" /><el-table-column label="状态" prop="status" min-width="110" /><el-table-column label="结果" min-width="150"><template #default="{ row }">{{ row.resultType || row.errorCode || '—' }}</template></el-table-column><el-table-column label="说明" min-width="220"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column><el-table-column label="开始时间" width="170"><template #default="{ row }">{{ formatTime(row.startedAt || row.createdAt) }}</template></el-table-column>
</el-table></section>
@@ -112,6 +115,12 @@
<template #footer><el-button @click="retryResult.open=false">留在当前页</el-button><el-button v-if="firstRetriedTask" type="primary" @click="openRetriedTask(firstRetriedTask)">查看成功任务</el-button></template>
</el-dialog>
<el-dialog v-model="orderWriteback.open" title="SYB 单号回填提交结果" width="760px" :close-on-click-modal="false">
<el-alert title="待回填表示已受理,不代表 SYB 已写入。请刷新列表或详情查看最终状态;冲突项不会覆盖原单号。" type="info" :closable="false" show-icon class="notice" />
<el-table :data="orderWriteback.items" border size="small" max-height="360" empty-text="暂无处理结果"><el-table-column label="任务" width="110"><template #default="{ row }">CG-{{ row.taskId }}</template></el-table-column><el-table-column label="结果" width="130"><template #default="{ row }">{{ orderWritebackLabel(row.result) }}</template></el-table-column><el-table-column label="说明" min-width="280" prop="reason" /></el-table>
<template #footer><el-button @click="orderWriteback.open=false">关闭</el-button><el-button type="primary" :loading="loading" @click="load">刷新列表状态</el-button></template>
</el-dialog>
<el-dialog v-model="cancelDialog.open" title="标记采购任务已取消" width="520px" :close-on-click-modal="false">
<el-alert title="这里只记录人工已在 PDD 取消的结果,不会替你操作 PDD。" type="info" :closable="false" show-icon class="notice" />
<el-form label-position="top"><el-form-item label="取消原因(必填)"><el-input v-model="cancelDialog.reason" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></el-form>
@@ -133,7 +142,7 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
const statusOptions = [
@@ -147,6 +156,7 @@ export default {
data() {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions,
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
@@ -159,6 +169,8 @@ export default {
}
},
computed: {
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) },
detailAlert() {
const task = this.detail.task || {}
if (task.status === 'order_result_unknown') return { type: 'error', title: '订单结果未知,禁止重复采购', description: '请先到 PDD 人工核对订单,再在本页处理结果。' }
@@ -214,9 +226,26 @@ export default {
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
requestId() { return createRequestId() },
isRetrySelectable(row) { return row.taskType !== 'stock' && row.status === 'failed' && row.retryable === true },
canOrderWriteback(row) { return row.orderWriteback?.canSubmit === true },
isSelectable(row) { return this.isRetrySelectable(row) || this.canOrderWriteback(row) },
orderWritebackLabel(value) { return { not_started: '未发起', pending: '待回填', running: '回填中', succeeded: '已回填', failed: '回填失败', unknown: '结果待核对', conflict: '单号冲突', skipped: '已跳过', not_applicable: '不适用' }[value] || '未记录' },
async refreshOrderWritebackDetail() { this.detail.loading = true; try { await this.refreshDetail() } finally { this.detail.loading = false } },
async submitOrderWriteback(rows) {
if (this.orderWriteback.saving) return
const ids = rows.filter(this.canOrderWriteback).map(row => row.id)
if (!ids.length) return
this.orderWriteback.saving = true
try {
const r = await writebackPurchaseOrderNumbers({ requestId: this.requestId(), purchaseTaskIds: ids })
this.orderWriteback.items = r.data.items || []
this.orderWriteback.open = true
await this.load()
if (this.detail.open && this.detail.task) await this.refreshDetail()
} catch (error) { ElMessage.error('回填提交结果未确认,请刷新任务状态后再试。') } finally { this.orderWriteback.saving = false }
},
retryRowClass({ row }) { return row.status === 'failed' && !row.retryable ? 'retry-disabled-row' : '' },
handleRetrySelection(rows) { this.retrySelection = rows },
openRetryDialog() { if (!this.retrySelection.length) return; this.retryDialog = { open: true, saving: false, items: [...this.retrySelection] } },
openRetryDialog() { if (!this.retryCandidates.length) return; this.retryDialog = { open: true, saving: false, items: [...this.retryCandidates] } },
async submitRetry() {
if (!this.retryDialog.items.length) return
this.retryDialog.saving = true
@@ -251,5 +280,6 @@ export default {
</script>
<style lang="scss" scoped>
.batch-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}.batch-actions .el-button+.el-button{margin-left:0}
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
</style>
@@ -0,0 +1,78 @@
import { expect, test } from '@playwright/test'
async function setup(page: any, state = 'not_started') {
await page.setViewportSize({ width: 1920, height: 1080 })
await page.context().addCookies([{ name: 'Admin-Token', value: 'test-only', domain: 'localhost', path: '/' }])
const requests: { path: string, body: any }[] = []
const items = [
{ id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', pddOrderNo: 'EXAMPLE-11', paymentReviewStatus: 'unpaid', orderWriteback: { status: state, canSubmit: ['not_started', 'failed', 'unknown'].includes(state), reason: state === 'conflict' ? 'SYB 已有不同单号,未覆盖' : '' } },
{ id: 12, taskType: 'syb_order', executionMode: 'live', status: 'failed', retryable: true, orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } },
{ id: 13, taskType: 'stock', executionMode: 'live', status: 'order_created', orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } }
]
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } })
if (path.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'GoAutoPurchaseTasks', title: '采购管理', visible: '0' }] }] } })
if (route.request().method() === 'POST') {
const body = route.request().postDataJSON()
requests.push({ path, body })
if (path.endsWith('/syb-order-writeback')) {
items[0].orderWriteback = { status: 'pending', canSubmit: false, reason: '' }
return route.fulfill({ json: { code: 200, data: { items: body.purchaseTaskIds.map((taskId: number) => ({ taskId, result: 'pending', reason: '已受理' })) } } })
}
if (path.endsWith('/batch-retry')) return route.fulfill({ json: { code: 200, data: { items: [], createdCount: 1, failedCount: 0 } } })
throw new Error('unexpected write: ' + path)
}
if (path.endsWith('/purchase-tasks/11')) return route.fulfill({ json: { code: 200, data: { task: items[0], attempts: [] } } })
if (path.endsWith('/purchase-tasks')) return route.fulfill({ json: { code: 200, data: { items, total: items.length } } })
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/purchase-tasks/index')
await expect(page.getByText('CG-11', { exact: true })).toBeVisible()
return requests
}
test('混选任务分别回填与重试,不把已下单任务交给采购重试', async ({ page }) => {
const requests = await setup(page)
await page.locator('.el-table__header-wrapper .el-checkbox').click()
await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled()
await expect(page.getByRole('button', { name: '批量回填 SYB(1)', exact: true })).toBeEnabled()
await page.getByRole('button', { name: '重试(1)', exact: true }).click()
const retry = page.getByRole('dialog', { name: '确认重试失败任务' })
await expect(retry.getByText('CG-12', { exact: true })).toBeVisible()
await expect(retry.getByText('CG-11', { exact: true })).toHaveCount(0)
await retry.getByRole('button', { name: '确认重试', exact: true }).click()
await expect.poll(() => requests.length).toBe(1)
expect(requests[0].body.taskIds).toEqual([12])
await page.getByRole('dialog', { name: '批量重试结果' }).getByRole('button', { name: '留在当前页' }).click()
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '批量回填 SYB(1)', exact: true }).click()
const result = page.getByRole('dialog', { name: 'SYB 单号回填提交结果' })
await expect(result.getByText('待回填', { exact: true })).toBeVisible()
expect(requests[1].body.purchaseTaskIds).toEqual([11])
expect(requests[1].body.requestId).toMatch(/^[0-9a-f-]{36}$/)
await result.getByRole('button', { name: '关闭', exact: true }).click()
await expect(result).toBeHidden()
await expect(page.getByRole('button', { name: '批量回填 SYB(0)', exact: true })).toBeDisabled()
})
test('未支付订单可从详情补偿回填,旧物流流程保持独立', async ({ page }) => {
const requests = await setup(page, 'failed')
await page.getByRole('button', { name: '详情', exact: true }).first().click()
const drawer = page.getByRole('dialog', { name: '采购任务详情' })
await expect(drawer.getByText('SYB 物流回填', { exact: true })).toBeVisible()
await drawer.getByRole('button', { name: '重试回填 SYB 单号', exact: true }).click()
await expect(page.getByRole('dialog', { name: 'SYB 单号回填提交结果' })).toBeVisible()
expect(requests).toHaveLength(1)
expect(requests[0].path).toContain('/syb-order-writeback')
expect(requests[0].body.purchaseTaskIds).toEqual([11])
})
test('冲突显示原因且没有覆盖按钮', async ({ page }) => {
await setup(page, 'conflict')
await page.getByRole('button', { name: '详情', exact: true }).first().click()
const drawer = page.getByRole('dialog', { name: '采购任务详情' })
await expect(drawer.getByText('单号冲突', { exact: true })).toBeVisible()
await expect(drawer.getByText('SYB 已有不同单号,未覆盖', { exact: true })).toBeVisible()
await expect(drawer.getByRole('button', { name: /回填 SYB 单号/ })).toHaveCount(0)
})