Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0e48bcea | ||
|
|
edd1cb15df | ||
|
|
482ba3408a | ||
|
|
ca7f768a79 | ||
|
|
72b8b5d4d0 | ||
|
|
290a17ea15 | ||
|
|
fdd26afaf0 | ||
|
|
d403f3b3a0 | ||
|
|
c6a962d522 | ||
|
|
9d5242b371 | ||
|
|
9a10d82cf4 | ||
|
|
ac3c63ead6 | ||
|
|
71f7751754 | ||
|
|
76c94e33e0 | ||
|
|
57b0f1595f | ||
|
|
29ba16e4f9 |
+33
@@ -194,6 +194,39 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(node, direction)
|
||||
}
|
||||
|
||||
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
|
||||
fun clickBackfill(target: SnapshotNode): Boolean {
|
||||
val page = capture()
|
||||
BackfillPagePolicy.validate(page)
|
||||
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
|
||||
it.bounds == target.bounds && it.className == target.className } ?: return false
|
||||
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
|
||||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
|
||||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
|
||||
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
|
||||
var node = root
|
||||
for (index in fresh.path.split('/').drop(1)) node = node.getChild(index.toInt()) ?: return false
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
|
||||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
|
||||
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
|
||||
fun scrollBackfill(): Boolean {
|
||||
BackfillPagePolicy.validate(capture())
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { if (it.isScrollable && it.isVisibleToUser && it.isEnabled) candidates += it }
|
||||
// Prefer the unique largest vertical viewport. Ambiguous panes fail closed.
|
||||
val areas = candidates.map { it to Rect().also(it::getBoundsInScreen) }
|
||||
val maxArea = areas.maxOfOrNull { it.second.width().toLong() * it.second.height() } ?: return false
|
||||
val target = areas.filter { it.second.width().toLong() * it.second.height() == maxArea }.singleOrNull() ?: return false
|
||||
return target.first.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
|
||||
}
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList())
|
||||
val rootPackage = root.packageName?.toString()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Runtime evidence only: a sorted prefix cannot prove the unvisited tail is sorted. */
|
||||
class OrderBackfillWindow(days: String, val confirmedAt: Long) {
|
||||
val cutoff: Long
|
||||
var checked = 0
|
||||
private set
|
||||
var nonDescending = false
|
||||
private set
|
||||
private var previous: Long? = null
|
||||
private var missingTime = false
|
||||
private var timed = 0
|
||||
|
||||
init {
|
||||
require(days.matches(Regex("[0-9]+")) && BigInteger(days) > BigInteger.ZERO)
|
||||
cutoff = BigInteger.valueOf(confirmedAt).subtract(BigInteger(days).multiply(BigInteger.valueOf(86_400_000)))
|
||||
.max(BigInteger.valueOf(Long.MIN_VALUE)).toLong()
|
||||
}
|
||||
|
||||
fun observe(time: Long?): Boolean {
|
||||
checked++
|
||||
if (time == null) missingTime = true else {
|
||||
if (previous != null && time > previous!!) nonDescending = true
|
||||
previous = time
|
||||
timed++
|
||||
}
|
||||
// Check at least five details before using a sampled ordering assumption.
|
||||
return !nonDescending && !missingTime && timed >= ORDERING_SAMPLE && time != null && time < cutoff
|
||||
}
|
||||
|
||||
fun includes(time: Long?): Boolean = time == null || time in cutoff..confirmedAt
|
||||
|
||||
companion object {
|
||||
const val ORDERING_SAMPLE = 5
|
||||
const val MAX_ORDERS = 200
|
||||
const val MAX_DURATION_MS = 10 * 60_000L
|
||||
const val UNORDERED = "列表非严格倒序,已改为有界扫描,可能未覆盖全部"
|
||||
}
|
||||
}
|
||||
|
||||
data class BackfillItem(val addressSuffix: String, val pddOrderNo: String, val orderSubmittedAt: String?)
|
||||
data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?)
|
||||
|
||||
/** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */
|
||||
class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) {
|
||||
private val suffixes = mutableSetOf<String>()
|
||||
private val orders = mutableSetOf<String>()
|
||||
private val times = mutableSetOf<Long>()
|
||||
|
||||
fun accept(text: String) {
|
||||
SUFFIX.findAll(text).forEach { suffixes += it.value }
|
||||
ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] }
|
||||
ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) }
|
||||
}
|
||||
|
||||
fun finish(): BackfillDetail {
|
||||
val time = times.singleOrNull()
|
||||
val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) {
|
||||
BackfillItem(suffixes.single(), orders.single(), time?.let {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it)
|
||||
})
|
||||
} else null
|
||||
suffixes.clear()
|
||||
orders.clear()
|
||||
times.clear()
|
||||
return BackfillDetail(item, time)
|
||||
}
|
||||
|
||||
private fun parseTime(raw: String): Long? {
|
||||
val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
|
||||
val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm"
|
||||
val position = java.text.ParsePosition(0)
|
||||
val date = SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = zone }.parse(normalized, position)
|
||||
return date?.time?.takeIf { position.index == normalized.length }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)")
|
||||
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object BackfillPagePolicy {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
val forbidden = listOf("确认收货", "申请退款", "催发货", "去支付", "立即支付", "提交订单", "付款", "退款", "取消订单", "再次购买", "删除订单")
|
||||
private val risk = listOf("验证码", "安全验证", "人机验证", "登录", "账号异常", "风险验证", "拖动滑块")
|
||||
fun validate(page: UiSnapshot) {
|
||||
require(page.packageName == PDD && page.activityName?.startsWith(PDD) == true) { "PDD 页面身份不符" }
|
||||
require(page.nodes.none { it.visible && risk.any { word -> it.label.contains(word) } }) { "遇到登录或安全验证,已停止" }
|
||||
}
|
||||
private fun subtree(page: UiSnapshot, node: SnapshotNode) = page.nodes.filter { it.path == node.path || it.path.startsWith(node.path + "/") }
|
||||
fun safe(page: UiSnapshot, node: SnapshotNode): Boolean = node.visible && node.enabled && node.clickable &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
subtree(page, node).none { child -> forbidden.any { child.label.contains(it) } } &&
|
||||
page.nodes.none { other -> other.visible && forbidden.any { other.label.contains(it) } &&
|
||||
other.bounds.left < node.bounds.right && other.bounds.right > node.bounds.left &&
|
||||
other.bounds.top < node.bounds.bottom && other.bounds.bottom > node.bounds.top }
|
||||
|
||||
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
|
||||
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
|
||||
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
|
||||
fun expansion(page: UiSnapshot): SnapshotNode? {
|
||||
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
|
||||
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
|
||||
val snapshot = page.nodes.filter { it.visible && it.label == "商品快照" }.singleOrNull() ?: return null
|
||||
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
|
||||
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
|
||||
}
|
||||
fun cards(page: UiSnapshot): List<SnapshotNode> {
|
||||
if (!list(page)) return emptyList()
|
||||
return page.nodes.filter { node ->
|
||||
if (!safe(page, node)) return@filter false
|
||||
if (node.label in setOf("订单详情", "查看详情")) return@filter true
|
||||
val children = subtree(page, node)
|
||||
val product = children.any { it.className?.endsWith("ImageView") == true } && children.any { it.label.length >= 4 }
|
||||
val parent = page.nodes.firstOrNull { it.path == node.parentPath } ?: return@filter false
|
||||
val context = subtree(page, parent)
|
||||
product && context.any { it.label in setOf("查看物流", "确认收货", "去支付", "待发货", "待收货", "交易成功", "再次购买") }
|
||||
}.sortedBy { it.bounds.top }.let { candidates ->
|
||||
candidates.filter { node -> candidates.none { it !== node && it.path.startsWith(node.path + "/") } }
|
||||
}
|
||||
}
|
||||
fun fingerprint(page: UiSnapshot, card: SnapshotNode): String {
|
||||
val labels = subtree(page, card).joinToString("|") { it.label }
|
||||
return MessageDigest.getInstance("SHA-256").digest(labels.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
interface BackfillDriver {
|
||||
fun openOrders()
|
||||
fun capture(): UiSnapshot
|
||||
fun click(node: SnapshotNode): Boolean
|
||||
fun scroll(): Boolean
|
||||
fun back(): Boolean
|
||||
fun pause()
|
||||
}
|
||||
|
||||
class OrderBackfillScanner(
|
||||
private val driver: BackfillDriver,
|
||||
private val window: OrderBackfillWindow,
|
||||
private val checkActive: () -> Unit,
|
||||
private val submit: (BackfillItem) -> Unit,
|
||||
private val progress: (Int) -> Unit,
|
||||
) {
|
||||
fun scan(): String {
|
||||
checkActive()
|
||||
driver.openOrders()
|
||||
driver.pause()
|
||||
var page = read()
|
||||
if (!BackfillPagePolicy.list(page)) {
|
||||
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
|
||||
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
|
||||
act { driver.click(tab) }
|
||||
page = read()
|
||||
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
|
||||
}
|
||||
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
|
||||
val seenCards = mutableSetOf<String>()
|
||||
val seenOrders = mutableSetOf<String>()
|
||||
var noProgress = 0
|
||||
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
|
||||
checkActive()
|
||||
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
|
||||
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
|
||||
if (card == null) {
|
||||
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
|
||||
checkActive()
|
||||
if (!driver.scroll()) return finish("列表滚动结束或不可滚动,未完整扫描")
|
||||
driver.pause()
|
||||
page = read()
|
||||
continue
|
||||
}
|
||||
noProgress = 0
|
||||
seenCards += BackfillPagePolicy.fingerprint(page, card)
|
||||
act { driver.click(card) }
|
||||
var detail = read()
|
||||
check(BackfillPagePolicy.detail(detail)) { "点击后未识别订单详情" }
|
||||
val reader = BackfillDetailReader()
|
||||
var expanded = false
|
||||
// Limited detail scrolling; only this accumulator associates fields across these frames.
|
||||
for (step in 0 until 6) {
|
||||
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
|
||||
val expand = if (expanded) null else BackfillPagePolicy.expansion(detail)
|
||||
if (expand != null) {
|
||||
act { driver.click(expand) }
|
||||
expanded = true
|
||||
} else {
|
||||
checkActive()
|
||||
if (!driver.scroll()) break
|
||||
driver.pause()
|
||||
}
|
||||
detail = read()
|
||||
check(BackfillPagePolicy.detail(detail)) { "滚动后无法确认订单详情,未完整扫描" }
|
||||
}
|
||||
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
|
||||
val found = reader.finish()
|
||||
val stopForTime = window.observe(found.timeMillis)
|
||||
progress(window.checked)
|
||||
found.item?.takeIf { window.includes(found.timeMillis) && seenOrders.add(it.pddOrderNo) }?.let(submit)
|
||||
if (stopForTime) return "已达指定天数(已读序列倒序);未完整扫描,后续列表时序未经验证"
|
||||
act { driver.back() }
|
||||
page = read()
|
||||
}
|
||||
return finish("达到 200 单内部上限,未完整扫描")
|
||||
}
|
||||
|
||||
private fun finish(reason: String): String = reason + if (window.nonDescending) "\n${OrderBackfillWindow.UNORDERED}" else ""
|
||||
|
||||
private fun read(): UiSnapshot {
|
||||
checkActive()
|
||||
return driver.capture().also(BackfillPagePolicy::validate)
|
||||
}
|
||||
private fun act(action: () -> Boolean) {
|
||||
checkActive()
|
||||
check(action()) { "安全页面操作失败,未完整扫描" }
|
||||
driver.pause()
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ class AgentApiException(
|
||||
) : Exception(message)
|
||||
|
||||
class AgentApiClient(private val serverUrl: String) {
|
||||
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
|
||||
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
|
||||
|
||||
fun testConnection() {
|
||||
requireNotNull(request("GET", "/api/v1/health", null, null))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package cn.ilapage.goauto.agent.network
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.BackfillItem
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
data class BackfillResult(
|
||||
val index: Int, val taskId: Long, val result: String, val code: String,
|
||||
val status: String, val statusVersion: Long, val pddOrderNo: String?,
|
||||
val orderSubmittedAt: String?, val timeSource: String,
|
||||
) {
|
||||
val success get() = result in setOf("backfilled", "already_backfilled")
|
||||
val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR"
|
||||
fun display(): String = if (success) {
|
||||
val source = when (timeSource) {
|
||||
"page" -> "页面下单时间"
|
||||
"irreversible_at" -> "估算时间(提交订单时刻)"
|
||||
else -> "已有时间(来源未知)"
|
||||
}
|
||||
"CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}"
|
||||
} else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)"
|
||||
}
|
||||
|
||||
internal fun backfillPayload(requestId: String, items: List<BackfillItem>): JSONObject {
|
||||
require(items.size in 1..50)
|
||||
return JSONObject().put("requestId", requestId).put("items", JSONArray().apply {
|
||||
items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply {
|
||||
item.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
|
||||
internal fun parseBackfillResults(data: JSONObject): List<BackfillResult> {
|
||||
val items = data.getJSONArray("items")
|
||||
return (0 until items.length()).map { index -> items.getJSONObject(index).let {
|
||||
BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"),
|
||||
it.optString("status"), it.optLong("statusVersion"),
|
||||
if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"),
|
||||
if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"))
|
||||
} }
|
||||
}
|
||||
|
||||
/** Separate from the purchase outbox: only transport failures / INTERNAL_ERROR retry, three attempts total. */
|
||||
class OrderBackfillUpload(
|
||||
private val submit: (String, List<BackfillItem>) -> List<BackfillResult>,
|
||||
private val checkActive: () -> Unit,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun upload(items: List<BackfillItem>, confirmed: (BackfillResult) -> Unit) {
|
||||
var pending = items.mapIndexed { index, item -> index to item }
|
||||
var requestId = UUID.randomUUID().toString()
|
||||
repeat(3) { attempt ->
|
||||
checkActive()
|
||||
val results = try {
|
||||
submit(requestId, pending.map { it.second }).also { values ->
|
||||
check(values.size == pending.size && values.map { it.index }.toSet() == pending.indices.toSet())
|
||||
values.filter { it.success }.forEach { value ->
|
||||
val input = pending[value.index].second
|
||||
check(value.taskId.toString() == input.addressSuffix.removePrefix("_cg") && value.pddOrderNo == input.pddOrderNo)
|
||||
check(value.status.isNotBlank())
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
val code = when (error) {
|
||||
is AgentApiException -> error.code
|
||||
is IOException -> "NETWORK_ERROR"
|
||||
else -> throw error
|
||||
}
|
||||
if (code in setOf("INTERNAL_ERROR", "NETWORK_ERROR") && attempt < 2) {
|
||||
pause((attempt + 1) * 1_000L)
|
||||
return@repeat // same request ID for an unknown transport result
|
||||
}
|
||||
pending.forEach { (index, _) -> confirmed(BackfillResult(index, 0, "failed", code, "", 0, null, null, "")) }
|
||||
return
|
||||
}
|
||||
// A completed HTTP response is evidence, even when cancellation arrived while waiting.
|
||||
val retry = mutableListOf<Pair<Int, BackfillItem>>()
|
||||
results.forEach { result ->
|
||||
val original = pending[result.index]
|
||||
if (!result.success && result.code == "INTERNAL_ERROR" && attempt < 2) retry += original
|
||||
else confirmed(result.copy(index = original.first))
|
||||
}
|
||||
if (retry.isEmpty()) return
|
||||
pending = retry
|
||||
requestId = UUID.randomUUID().toString() // payload changed after partial acknowledgement
|
||||
checkActive()
|
||||
pause((attempt + 1) * 1_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,32 @@ import org.json.JSONObject
|
||||
class TaskHistoryCache(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
/** Only acknowledged server facts may overwrite cached task facts. Never store scanned candidates. */
|
||||
fun applyBackfill(result: cn.ilapage.goauto.agent.network.BackfillResult, environment: String) = synchronized(BACKFILL_LOCK) {
|
||||
if (!result.success) {
|
||||
if (result.needsReview) check(preferences.edit().putString("backfill_review_${result.taskId}_${result.code}",
|
||||
JSONObject().put("taskId", result.taskId).put("code", result.code).put("environment", environment).toString()).commit())
|
||||
return@synchronized
|
||||
}
|
||||
val values = JSONArray(preferences.getString(PURCHASE, "[]"))
|
||||
for (index in 0 until values.length()) {
|
||||
applyBackfillFacts(values.getJSONObject(index), result)
|
||||
}
|
||||
// Also retain the small acknowledged summary when this task is absent from downloaded history.
|
||||
val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status)
|
||||
.put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo)
|
||||
.putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource)
|
||||
.put("environment", environment)
|
||||
check(preferences.edit().putString(PURCHASE, values.toString())
|
||||
.putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit())
|
||||
}
|
||||
|
||||
fun backfillTimeSource(taskId: Long, environment: String): String? {
|
||||
val raw = preferences.getString("backfill_confirmed_$taskId", null) ?: return null
|
||||
val confirmed = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
return confirmed.optString("timeSource").takeIf { confirmed.optString("environment") == environment }
|
||||
}
|
||||
|
||||
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
|
||||
items.forEach { item -> put(JSONObject()
|
||||
.put("taskId", item.taskId).put("attemptNumber", item.attemptNumber).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
|
||||
@@ -90,9 +116,18 @@ class TaskHistoryCache(context: Context) {
|
||||
private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key)
|
||||
|
||||
private companion object {
|
||||
val BACKFILL_LOCK = Any()
|
||||
const val PREFERENCES = "goauto_task_history_cache"
|
||||
const val COLLECTION = "collection"
|
||||
const val PURCHASE = "purchase"
|
||||
const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agent.network.BackfillResult) {
|
||||
if (!result.success || task.optLong("taskId") != result.taskId) return
|
||||
task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL)
|
||||
.put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL)
|
||||
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
|
||||
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
|
||||
}
|
||||
|
||||
+114
-2
@@ -75,6 +75,7 @@ class AgentForegroundService : Service() {
|
||||
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
private val working = AtomicBoolean(false)
|
||||
private val manualCheckRequested = AtomicBoolean(false)
|
||||
@@ -132,6 +133,14 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_START) {
|
||||
requestOrderBackfill(intent.getStringExtra("days").orEmpty(), intent.getLongExtra("confirmedAt", 0))
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
|
||||
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
|
||||
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
|
||||
@@ -147,6 +156,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
collectionCooldownFuture.getAndSet(null)?.cancel(false)
|
||||
@@ -165,7 +175,7 @@ class AgentForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun triggerSync() {
|
||||
if (!working.compareAndSet(false, true)) return
|
||||
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
|
||||
executor.execute {
|
||||
val manualCheck = manualCheckRequested.getAndSet(false)
|
||||
try {
|
||||
@@ -307,6 +317,98 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun publishBackfill(state: OrderBackfillState) {
|
||||
backfillState = state
|
||||
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
|
||||
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
|
||||
}
|
||||
|
||||
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
|
||||
// Refuse while polling/dispatch is in flight too: never queue behind another PDD operation.
|
||||
val acquired = synchronized(taskMutex) {
|
||||
!working.get() && purchaseStore.activeTaskId() == null && backfillGuard.tryAcquire()
|
||||
}
|
||||
if (!acquired) {
|
||||
android.widget.Toast.makeText(this, "设备忙碌,请稍后操作", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
|
||||
val server = settingsStore.serverUrl()
|
||||
val credentials = identityStore.credentials() ?: error("设备尚未注册")
|
||||
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
cancelIdleReturn("人工订单回填")
|
||||
publishBackfill(OrderBackfillState(running = true, message = "正在检查订单列表时序…"))
|
||||
taskExecutor.execute {
|
||||
var state = backfillState
|
||||
fun checkActive() {
|
||||
check(!backfillGuard.cancelled.get() && !Thread.currentThread().isInterrupted) { "用户停止,未完整扫描" }
|
||||
check(SystemClock.elapsedRealtime() - started < cn.ilapage.goauto.agent.automation.OrderBackfillWindow.MAX_DURATION_MS) { "达到 10 分钟内部上限,未完整扫描" }
|
||||
check(settingsStore.serverUrl() == server && identityStore.credentials() == credentials) { "服务器或设备身份已变化,未完整扫描" }
|
||||
}
|
||||
try {
|
||||
acquireTaskWakeLock()
|
||||
val api = AgentApiClient(server)
|
||||
val uploader = cn.ilapage.goauto.agent.network.OrderBackfillUpload(
|
||||
submit = { id, items -> api.backfillOrders(id, items, credentials.token) },
|
||||
checkActive = ::checkActive,
|
||||
)
|
||||
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
|
||||
override fun openOrders() {
|
||||
checkActive()
|
||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
|
||||
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
override fun capture() = accessibility.capture()
|
||||
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
|
||||
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
|
||||
override fun back(): Boolean {
|
||||
checkActive()
|
||||
cn.ilapage.goauto.agent.automation.BackfillPagePolicy.validate(capture())
|
||||
return accessibility.back()
|
||||
}
|
||||
override fun pause() { repeat(10) { checkActive(); Thread.sleep(100) } }
|
||||
}
|
||||
val reason = cn.ilapage.goauto.agent.automation.OrderBackfillScanner(driver, window, ::checkActive,
|
||||
submit = { item -> uploader.upload(listOf(item)) { result ->
|
||||
state = state.copy(success = state.success + if (result.result == "backfilled") 1 else 0,
|
||||
already = state.already + if (result.result == "already_backfilled") 1 else 0,
|
||||
failed = state.failed + if (!result.success) 1 else 0,
|
||||
evidence = state.evidence + result.display())
|
||||
// The frozen API receives all submissions. Never write an old environment's response into a new cache.
|
||||
if (settingsStore.serverUrl() == server && identityStore.credentials() == credentials) {
|
||||
cn.ilapage.goauto.agent.persistence.TaskHistoryCache(this).applyBackfill(result, "$server|${credentials.deviceId}")
|
||||
}
|
||||
publishBackfill(state)
|
||||
check(result.success || result.needsReview) { "网络或服务端瞬时错误重试耗尽,未完整扫描" }
|
||||
} },
|
||||
progress = { checked ->
|
||||
state = state.copy(checked = checked, message = if (window.nonDescending) cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED else "正在扫描(已读 $checked 单)…")
|
||||
publishBackfill(state)
|
||||
}).scan()
|
||||
state = state.copy(message = reason)
|
||||
} catch (error: Exception) {
|
||||
// Never echo raw page text, HTTP bodies or credentials in UI/logs.
|
||||
val reason = if (error is IllegalStateException || error is IllegalArgumentException) error.message else null
|
||||
val message = reason?.takeIf { it.length < 100 } ?: "网络或页面异常"
|
||||
state = state.copy(message = message + if (message.contains("未完整扫描")) "" else ",未完整扫描")
|
||||
} finally {
|
||||
val warning = if (window.nonDescending && !state.message.contains(cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED))
|
||||
"\n${cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED}" else ""
|
||||
try {
|
||||
publishBackfill(state.copy(running = false, message = state.message + warning))
|
||||
} finally {
|
||||
try { releaseTaskWakeLock() } finally { backfillGuard.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
backfillGuard.release()
|
||||
publishBackfill(OrderBackfillState(message = "回填未启动,请检查天数、设备连接与无障碍服务"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCurrentPageCollection(
|
||||
requestId: String,
|
||||
replacementOriginType: String?,
|
||||
@@ -1038,10 +1140,15 @@ class AgentForegroundService : Service() {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
if (backfillState.running) {
|
||||
val stop = PendingIntent.getService(this, 242, Intent(this, AgentForegroundService::class.java).setAction(ACTION_BACKFILL_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(content)
|
||||
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
@@ -1067,6 +1174,11 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
|
||||
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@Volatile var backfillState = OrderBackfillState()
|
||||
private set
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class OrderBackfillGuard(private val mutex: TaskExecutionMutex) {
|
||||
private val active = AtomicBoolean(false)
|
||||
val cancelled = AtomicBoolean(false)
|
||||
fun tryAcquire(): Boolean {
|
||||
if (!active.compareAndSet(false, true)) return false
|
||||
if (!mutex.tryAcquire(RESERVATION)) {
|
||||
active.set(false)
|
||||
return false
|
||||
}
|
||||
cancelled.set(false)
|
||||
return true
|
||||
}
|
||||
fun release() {
|
||||
mutex.release(RESERVATION)
|
||||
active.set(false)
|
||||
}
|
||||
companion object { const val RESERVATION = Long.MAX_VALUE - 1 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
data class OrderBackfillState(
|
||||
val running: Boolean = false,
|
||||
val checked: Int = 0,
|
||||
val success: Int = 0,
|
||||
val already: Int = 0,
|
||||
val failed: Int = 0,
|
||||
val message: String = "",
|
||||
val evidence: List<String> = emptyList(),
|
||||
) {
|
||||
fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" +
|
||||
if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n")
|
||||
}
|
||||
@@ -141,8 +141,16 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
|
||||
renderBackfill()
|
||||
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
|
||||
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
|
||||
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
|
||||
@@ -182,6 +190,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
pageColumn = context.column()
|
||||
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
|
||||
pageColumn.addView(buildSearch())
|
||||
if (!collection) {
|
||||
backfillPanel = context.column(0)
|
||||
pageColumn.addView(backfillPanel)
|
||||
renderBackfill()
|
||||
}
|
||||
pageColumn.addView(buildFilters())
|
||||
resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) }
|
||||
pageColumn.addView(resultColumn, resultColumn.fullWidth())
|
||||
@@ -207,6 +220,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
backfillPanel = null
|
||||
requestGeneration++
|
||||
cancelImageRequests()
|
||||
super.onDestroyView()
|
||||
@@ -273,6 +287,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
contentDescription = "采集当前拼多多商品"
|
||||
setOnClickListener { confirmCurrentPageCollection() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
} else {
|
||||
row.addView(MaterialButton(context).apply {
|
||||
text = "回填"
|
||||
textSize = 14f
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "回填拼多多订单号和下单时间"
|
||||
setOnClickListener { showBackfillInput() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
}
|
||||
addView(row, row.fullWidth())
|
||||
}).apply {
|
||||
@@ -280,6 +302,75 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBackfillInput() {
|
||||
if (AgentForegroundService.backfillState.running) {
|
||||
toast("设备忙碌,请稍后操作")
|
||||
return
|
||||
}
|
||||
val context = requireContext()
|
||||
val input = TextInputEditText(context).apply {
|
||||
setText("2")
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "回填天数"
|
||||
selectAll()
|
||||
}
|
||||
val field = TextInputLayout(context).apply {
|
||||
hint = "天数"
|
||||
helperText = "从确认时刻往前 N×24 小时"
|
||||
addView(input)
|
||||
}
|
||||
val dialog = MaterialAlertDialogBuilder(context).setTitle("回填订单")
|
||||
.setView(context.cardColumn().apply { addView(field) })
|
||||
.setNegativeButton("取消", null).setPositiveButton("确认", null).create()
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val days = input.text.toString().trim()
|
||||
val now = System.currentTimeMillis()
|
||||
if (runCatching { cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, now) }.isFailure) {
|
||||
field.error = "请输入正整数天数"
|
||||
return@setOnClickListener
|
||||
}
|
||||
val intent = Intent(context, AgentForegroundService::class.java)
|
||||
.setAction(AgentForegroundService.ACTION_BACKFILL_START).putExtra("days", days).putExtra("confirmedAt", now)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun renderBackfill() {
|
||||
val panel = backfillPanel ?: return
|
||||
val context = context ?: return
|
||||
val state = AgentForegroundService.backfillState
|
||||
panel.removeAllViews()
|
||||
if (state.message.isBlank()) return
|
||||
panel.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(if (state.running) "正在回填订单" else "回填结果", 16f))
|
||||
addView(context.label(state.copy(evidence = emptyList()).text(), 14f))
|
||||
if (state.running) {
|
||||
addView(MaterialButton(context).apply {
|
||||
text = "停止"
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
context.startService(Intent(context, AgentForegroundService::class.java).setAction(AgentForegroundService.ACTION_BACKFILL_STOP))
|
||||
isEnabled = false
|
||||
text = "正在停止…"
|
||||
}
|
||||
})
|
||||
}
|
||||
if (state.evidence.isNotEmpty()) addView(MaterialButton(context).apply {
|
||||
text = "查看逐条结果"
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context).setTitle("回填明细")
|
||||
.setMessage(state.evidence.joinToString("\n")).setPositiveButton("关闭", null).show()
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
private fun buildFilters(): View {
|
||||
val context = requireContext()
|
||||
val statuses = if (collection) {
|
||||
@@ -632,6 +723,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
private fun registerCurrentPageReceiver() {
|
||||
if (currentPageReceiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -724,6 +816,12 @@ class TaskHistoryFragment : Fragment() {
|
||||
append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n")
|
||||
append("PDD 订单号:${task.pddOrderNo ?: "—"}\n")
|
||||
append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}")
|
||||
val environment = "${AgentSettingsStore(context).serverUrl()}|${runCatching { SecureDeviceStore(context).credentials()?.deviceId }.getOrNull()}"
|
||||
when (TaskHistoryCache(context).backfillTimeSource(task.taskId, environment)) {
|
||||
"page" -> append("(页面读取)")
|
||||
"irreversible_at" -> append("(估算:提交订单时刻)")
|
||||
"existing_unknown" -> append("(已有值,来源未知)")
|
||||
}
|
||||
}
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.network.*
|
||||
import cn.ilapage.goauto.agent.service.*
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class OrderBackfillTest {
|
||||
private val zone = TimeZone.getTimeZone("Asia/Shanghai")
|
||||
private fun time(raw: String): Long = BackfillDetailReader(zone).apply { accept("下单时间:$raw") }.finish().timeMillis!!
|
||||
private val now = time("2026-09-08 12:00:00")
|
||||
|
||||
@Test fun `expanded detail reads order time not group time across frames`() {
|
||||
val reader = BackfillDetailReader(zone)
|
||||
reader.accept("测试地址_cg7\n订单编号:TEST-000007\n展开")
|
||||
reader.accept("下单时间:2026-09-08 10:50:29\n拼单时间:2026-09-08 11:20:35")
|
||||
val detail = reader.finish()
|
||||
assertEquals("2026-09-08T10:50:29+08:00", detail.item!!.orderSubmittedAt)
|
||||
assertEquals("_cg7", detail.item!!.addressSuffix)
|
||||
assertNull(reader.finish().item)
|
||||
}
|
||||
|
||||
@Test fun `untagged order never becomes retained or uploaded candidate`() {
|
||||
val reader = BackfillDetailReader(zone)
|
||||
reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00")
|
||||
assertNull(reader.finish().item)
|
||||
reader.accept("测试地址_cg7")
|
||||
assertNull(reader.finish().item) // previous order number has been cleared
|
||||
val uploaded = mutableListOf<BackfillItem>()
|
||||
scanner(FakeDriver(tagged = false), uploaded).scan()
|
||||
assertTrue(uploaded.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `ambiguous and noncanonical suffixes are skipped and missing time remains optional`() {
|
||||
for (suffix in listOf("_cg07", "_cg+7", "_cg7", "_cg7abc", "_cg7 _cg8")) {
|
||||
assertNull(BackfillDetailReader(zone).apply { accept("$suffix\n订单号:TEST-000007") }.finish().item)
|
||||
}
|
||||
assertNull(BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n订单号:TEST-000008") }.finish().item)
|
||||
val missing = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n拼单时间:2026-09-08 10:00:00") }.finish()
|
||||
assertNotNull(missing.item)
|
||||
assertNull(missing.item!!.orderSubmittedAt)
|
||||
}
|
||||
|
||||
@Test fun `rolling hours boundary and sampled descending cutoff`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
assertEquals(now - 48 * 3_600_000L, window.cutoff)
|
||||
assertTrue(window.includes(window.cutoff))
|
||||
assertFalse(window.includes(window.cutoff - 1))
|
||||
repeat(4) { assertFalse(window.observe(window.cutoff + 10 - it)) }
|
||||
assertTrue(window.observe(window.cutoff - 1))
|
||||
assertEquals(Long.MIN_VALUE, OrderBackfillWindow("99999999999999999999999999", now).cutoff)
|
||||
}
|
||||
|
||||
@Test fun `time reversal and missing times disable early stop`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
assertFalse(window.observe(now - 100))
|
||||
assertFalse(window.observe(now))
|
||||
repeat(10) { assertFalse(window.observe(window.cutoff - it - 1)) }
|
||||
assertTrue(window.nonDescending)
|
||||
val unknown = OrderBackfillWindow("2", now)
|
||||
unknown.observe(null)
|
||||
repeat(10) { assertFalse(unknown.observe(unknown.cutoff - it - 1)) }
|
||||
}
|
||||
|
||||
@Test fun `scanner expands and stops after validated prefix passes rolling cutoff`() {
|
||||
val driver = FakeDriver()
|
||||
val items = mutableListOf<BackfillItem>()
|
||||
val result = scanner(driver, items).scan()
|
||||
assertEquals(5, driver.opened)
|
||||
assertEquals(5, driver.expansions)
|
||||
assertTrue(result.contains("已达指定天数"))
|
||||
assertTrue(result.contains("未完整扫描"))
|
||||
assertTrue(items.all { !it.orderSubmittedAt.orEmpty().contains("11:20") })
|
||||
}
|
||||
|
||||
@Test fun `unordered list scans to internal cap and reports incomplete`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
val driver = FakeDriver(unordered = true)
|
||||
val result = OrderBackfillScanner(driver, window, {}, {}, {}).scan()
|
||||
assertTrue(window.nonDescending)
|
||||
assertEquals(200, driver.opened)
|
||||
assertTrue(result.contains("未完整扫描"))
|
||||
assertTrue(result.contains(OrderBackfillWindow.UNORDERED))
|
||||
}
|
||||
|
||||
@Test fun `mutex occupied rejects and simultaneous double tap has one winner and finally releases`() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
val guard = OrderBackfillGuard(mutex)
|
||||
mutex.tryAcquire(7)
|
||||
assertFalse(guard.tryAcquire())
|
||||
mutex.release(7)
|
||||
val start = CountDownLatch(1)
|
||||
val complete = CountDownLatch(2)
|
||||
val won = AtomicInteger()
|
||||
val pool = Executors.newFixedThreadPool(2)
|
||||
repeat(2) { pool.execute { start.await(); if (guard.tryAcquire()) won.incrementAndGet(); complete.countDown() } }
|
||||
start.countDown()
|
||||
complete.await()
|
||||
pool.shutdownNow()
|
||||
assertEquals(1, won.get())
|
||||
assertFalse(mutex.tryAcquire(8))
|
||||
try { guard.cancelled.set(true); throw IllegalStateException("cancelled") } catch (_: IllegalStateException) { } finally { guard.release() }
|
||||
assertNull(mutex.currentTaskId())
|
||||
assertTrue(guard.tryAcquire())
|
||||
assertFalse(guard.cancelled.get())
|
||||
guard.release()
|
||||
}
|
||||
|
||||
@Test fun `permanent business errors never retry regardless of server retryable flag`() {
|
||||
val codes = listOf("PURCHASE_BACKFILL_SUFFIX_INVALID", "PURCHASE_TASK_NOT_FOUND", "PURCHASE_BACKFILL_DEVICE_MISMATCH",
|
||||
"PURCHASE_STATE_CONFLICT", "PURCHASE_INVALID_REQUEST", "PURCHASE_ORDER_TIME_INVALID", "PURCHASE_ORDER_TIME_MISSING",
|
||||
"PURCHASE_BACKFILL_ORDER_CONFLICT", "PURCHASE_BACKFILL_BATCH_CONFLICT", "PURCHASE_BACKFILL_ORDER_ALREADY_USED")
|
||||
codes.forEach { code ->
|
||||
var calls = 0
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(failure(code)) }, {}, {}).upload(listOf(item), results::add)
|
||||
assertEquals(1, calls)
|
||||
assertTrue(results.single().display().contains("需人工检查"))
|
||||
}
|
||||
var calls = 0
|
||||
OrderBackfillUpload({ _, _ -> calls++; throw AgentApiException(409, codes.first(), "hidden", true) }, {}, {})
|
||||
.upload(listOf(item)) { assertTrue(it.needsReview) }
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test fun `network and internal errors retry bounded with stable transport request id`() {
|
||||
val ids = mutableListOf<String>()
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
OrderBackfillUpload({ id, _ -> ids += id; throw IOException("private body") }, {}, {})
|
||||
.upload(listOf(item), results::add)
|
||||
assertEquals(3, ids.size)
|
||||
assertEquals(1, ids.toSet().size)
|
||||
assertFalse(results.single().needsReview)
|
||||
assertFalse(results.single().display().contains("private"))
|
||||
var calls = 0
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(failure("INTERNAL_ERROR")) }, {}, {})
|
||||
.upload(listOf(item)) { assertTrue(it.display().contains("重试耗尽")) }
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test fun `partial response only retries transient items and preserves acknowledged success`() {
|
||||
val sizes = mutableListOf<Int>()
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
val second = item.copy(addressSuffix = "_cg8", pddOrderNo = "TEST-000008")
|
||||
OrderBackfillUpload({ _, items ->
|
||||
sizes += items.size
|
||||
if (items.size == 2) listOf(success(), failure("INTERNAL_ERROR").copy(index = 1))
|
||||
else listOf(failure("PURCHASE_STATE_CONFLICT"))
|
||||
}, {}, {}).upload(listOf(item, second), results::add)
|
||||
assertEquals(listOf(2, 1), sizes)
|
||||
assertTrue(results.first().success)
|
||||
assertEquals(1, results.last().index)
|
||||
}
|
||||
|
||||
@Test fun `cancellation prevents upload and invalid response cannot confirm cache`() {
|
||||
var calls = 0
|
||||
try {
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(success()) }, { error("stopped") }, {}).upload(listOf(item)) { fail() }
|
||||
fail()
|
||||
} catch (_: IllegalStateException) { }
|
||||
assertEquals(0, calls)
|
||||
try {
|
||||
OrderBackfillUpload({ _, _ -> listOf(success().copy(taskId = 99)) }, {}, {}).upload(listOf(item)) { fail() }
|
||||
fail()
|
||||
} catch (_: IllegalStateException) { }
|
||||
}
|
||||
|
||||
@Test fun `payload contains only suffix order and optional RFC3339 time`() {
|
||||
val payload = backfillPayload("test", listOf(item))
|
||||
assertEquals(setOf("requestId", "items"), payload.keySet())
|
||||
assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet())
|
||||
val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}"""))
|
||||
assertTrue(parsed.single().display().contains("估算"))
|
||||
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
|
||||
}
|
||||
|
||||
@Test fun `cache updates confirmed server facts only and clears old errors`() {
|
||||
val task = JSONObject().put("taskId", 7).put("status", "order_result_unknown").put("errorCode", "OLD").put("errorMessage", "old")
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, failure("PURCHASE_BACKFILL_ORDER_CONFLICT"))
|
||||
assertEquals("order_result_unknown", task.getString("status"))
|
||||
assertEquals("OLD", task.getString("errorCode"))
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, success())
|
||||
assertEquals("order_created", task.getString("status"))
|
||||
assertEquals(item.pddOrderNo, task.getString("pddOrderNo"))
|
||||
assertTrue(task.isNull("errorCode"))
|
||||
assertTrue(task.isNull("errorMessage"))
|
||||
assertFalse(task.getBoolean("retryable"))
|
||||
}
|
||||
|
||||
@Test fun `expansion requires order information and product snapshot row`() {
|
||||
val expand = node("0/1", "展开")
|
||||
assertNull(BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand)))
|
||||
assertEquals(expand, BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand, node("0/2", "商品快照"))))
|
||||
}
|
||||
|
||||
@Test fun `dangerous nodes ancestors and overlapping actions never become click targets`() {
|
||||
BackfillPagePolicy.forbidden.forEach { label ->
|
||||
val dangerous = node("0/0", label)
|
||||
assertFalse(BackfillPagePolicy.safe(page(dangerous), dangerous))
|
||||
val parent = node("0", "查看详情")
|
||||
assertFalse(BackfillPagePolicy.safe(page(parent, dangerous), parent))
|
||||
val adjacent = node("0/1", "查看详情")
|
||||
assertFalse(BackfillPagePolicy.safe(page(adjacent, dangerous), adjacent))
|
||||
}
|
||||
}
|
||||
|
||||
private val item = BackfillItem("_cg7", "TEST-000007", null)
|
||||
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
|
||||
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
|
||||
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
|
||||
private fun node(path: String, label: String, selected: Boolean = false) = SnapshotNode(path, path.substringBeforeLast('/'), label, null, null, "View",
|
||||
NodeBounds(0, 0, 200, 60), true, false, selected, false, true, true)
|
||||
private fun page(vararg nodes: SnapshotNode) = UiSnapshot(BackfillPagePolicy.PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", nodes.toList())
|
||||
|
||||
private inner class FakeDriver(val tagged: Boolean = true, val unordered: Boolean = false) : BackfillDriver {
|
||||
var opened = 0
|
||||
var expansions = 0
|
||||
var index = 0
|
||||
var inDetail = false
|
||||
var expanded = false
|
||||
override fun openOrders() = Unit
|
||||
override fun pause() = Unit
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!inDetail) return page(node("0/0", "我的订单"), node("0/1", "全部", true), node("0/2", "查看详情"), node("0/2/0", "合成卡片 $index"))
|
||||
val date = if (unordered && index == 1) "2026-09-08 11:00:00" else if (index < 4) "2026-09-08 10:00:00" else "2026-09-05 10:00:00"
|
||||
return page(node("0/0", "订单详情"), node("0/1", "订单编号:TEST-${100000 + index}"),
|
||||
node("0/2", if (tagged) "合成地址_cg${index + 1}" else "无后缀合成地址"),
|
||||
node("0/4", "商品快照"),
|
||||
node("0/3", if (expanded) "下单时间:$date\n拼单时间:2026-09-08 11:20:00" else "展开"))
|
||||
}
|
||||
override fun click(node: SnapshotNode): Boolean {
|
||||
if (node.label == "展开") { expanded = true; expansions++ } else { inDetail = true; opened++ }
|
||||
return true
|
||||
}
|
||||
override fun scroll() = false
|
||||
override fun back(): Boolean { inDetail = false; expanded = false; index++; return true }
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
|
||||
synchronized_at: 2026-09-05T07:16:39Z
|
||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -61,6 +61,7 @@ GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布
|
||||
## 环境与凭据
|
||||
|
||||
- 服务端正式环境默认必须使用 HTTPS。仅当管理员接受 Device Token、任务内容和执行结果明文传输风险,并显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,Agent `/api/agent/v1/**` 可通过 HTTP;管理端和第三方服务不因此放宽。
|
||||
- #237 客户端密钥例外(用户 2026-09-07 明确接受风险):提交 `71f7751` 起,管理员密钥管理及 `/api/client/v1` 默认兼容 HTTP/HTTPS,无开关;HTTP 明文传输密钥及业务数据,建议优先 HTTPS。实际迁移部署仍须单独授权;此例外不改变其他第三方服务的安全边界。
|
||||
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
|
||||
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
|
||||
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
|
||||
|
||||
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:50Z
|
||||
wiki_revision: d547c17924ac53422232ad9d6c55a34c8cd8d63c
|
||||
synchronized_at: 2026-09-08T07:16:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -68,6 +68,8 @@ Android Portal/Agent
|
||||
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
|
||||
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
|
||||
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
|
||||
|
||||
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
|
||||
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
@@ -333,3 +335,34 @@ PddProductDetailCollector
|
||||
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
|
||||
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
|
||||
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
|
||||
|
||||
## 客户端密钥访问架构(#237)
|
||||
|
||||
代码基线 `71f7751`(含用户确认的默认 HTTP/HTTPS 兼容),2026-09-07 完成 Server/Web 代码与隔离测试;本机迁移、管理员菜单写入与 Server/Web 启动已于 2026-09-07 授权完成,真实 HTTP 管理列表和模块目录加载通过;线上仍未部署。
|
||||
|
||||
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
|
||||
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
|
||||
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
|
||||
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
|
||||
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
|
||||
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
|
||||
|
||||
## 采购规格面板预滑动兼容(#238)
|
||||
|
||||
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
|
||||
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
|
||||
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
|
||||
|
||||
@@ -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: 670a5592a6db8301cd115295bf820f7f4b6e06d7
|
||||
synchronized_at: 2026-09-07T03:21:47Z
|
||||
wiki_revision: 1eb380d07183157a8430c7ced868479e72fbdfe3
|
||||
synchronized_at: 2026-09-08T07:49:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -67,13 +74,13 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
|
||||
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
|
||||
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
|
||||
|
||||
## SYB 后台导入记录
|
||||
|
||||
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
|
||||
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
|
||||
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
|
||||
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
|
||||
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
|
||||
@@ -168,7 +175,7 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
|
||||
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
|
||||
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
|
||||
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
|
||||
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
|
||||
@@ -230,7 +237,7 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
|
||||
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
|
||||
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
|
||||
|
||||
## cmautobuy 商品导入
|
||||
@@ -444,3 +451,34 @@ synchronized_at: 2026-09-07T03:21:47Z
|
||||
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
|
||||
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
|
||||
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
|
||||
|
||||
## 客户端密钥与可编辑模块授权(#237)
|
||||
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
|
||||
|
||||
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
|
||||
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
|
||||
- 勾选模块默认只读,至少保留一个模块。读写仅开放清单中的普通写操作;同步、采集、采购、删除、导入、重解析、匹配、回写及切换当前采购规则分别独立授权,默认关闭。没有普通写接口的模块不允许勾选读写。
|
||||
- 编辑既有密钥不会更换密钥,名称和前缀只读;移除模块同时移除其动作。取消保留原授权;失败保留输入;版本冲突要求刷新重开。停用不可编辑或恢复。
|
||||
- 保存后新请求按最新授权校验,已经通过校验的在途请求可能完成,不追溯回滚。模块授权不是行级、店铺级或租户隔离,授予读取即允许读取该模块明确接口可返回的业务范围。
|
||||
- 用户于 2026-09-07 明确接受明文风险:密钥管理及客户端接口默认兼容 HTTP/HTTPS,无需开关。HTTP 会明文传输密钥与业务数据,建议优先使用 HTTPS;兼容不改变管理员身份与模块授权边界。
|
||||
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
|
||||
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
|
||||
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
|
||||
|
||||
## 打开采购规格面板后按需滚动(#238)
|
||||
|
||||
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
@@ -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: f3242938de4ac55c47f5c6eebd69184024e6a0ea
|
||||
synchronized_at: 2026-09-05T09:04:42Z
|
||||
wiki_revision: 1f5ee1b29c66773fa571d862241b63f02dae283b
|
||||
synchronized_at: 2026-09-08T07:09:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -75,9 +75,9 @@ POST /api/admin/v1/syb-products/reparse-batch
|
||||
PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
```
|
||||
|
||||
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
@@ -472,7 +472,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
|---|---:|---:|---:|---:|
|
||||
| `openProduct` | 是 | 是 | 是 | 否 |
|
||||
| `verifyProduct` | 是 | 是 | 否 | 否 |
|
||||
| `openSpecPanel` | 是 | 是 | 是 | 否 |
|
||||
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
|
||||
| `selectSpec` | 是 | 是 | 是 | 否 |
|
||||
| `setQuantity` | 是 | 是 | 否 | 否 |
|
||||
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
|
||||
@@ -486,7 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
|
||||
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
|
||||
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
|
||||
- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
|
||||
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
|
||||
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
|
||||
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
|
||||
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
|
||||
@@ -556,7 +556,9 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
|
||||
|
||||
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
@@ -867,3 +869,194 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
|
||||
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
|
||||
|
||||
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
|
||||
|
||||
## 客户端 API 与管理员密钥管理(#237)
|
||||
|
||||
实现基线 `71f7751`;以下接口已通过隔离测试;2026-09-07 本机 MySQL 迁移及管理员通过 HTTP 读取列表、模块目录已验证,真实密钥创建/编辑/停用及业务访问仍未联调,线上尚未部署。Android 接口不变。
|
||||
|
||||
### 管理接口
|
||||
|
||||
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
|
||||
|
||||
| 方法与相对路径 | 输入 | 成功 data |
|
||||
|---|---|---|
|
||||
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
|
||||
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
|
||||
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
|
||||
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
|
||||
| POST /:keyId/disable | version | 停用后的元数据 |
|
||||
|
||||
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
|
||||
|
||||
### 客户端访问与错误语义
|
||||
|
||||
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
|
||||
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
|
||||
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
|
||||
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
|
||||
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
|
||||
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
|
||||
|
||||
### 明确开放的接口清单
|
||||
|
||||
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
|
||||
|
||||
| 方法 | 路径 | 模块键 | 能力 |
|
||||
|---|---|---|---|
|
||||
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
|
||||
| GET | `/devices` | devices | read |
|
||||
| GET | `/pdd-products` | pdd_products | read |
|
||||
| GET | `/pdd-products/:productId` | pdd_products | read |
|
||||
| POST | `/pdd-products` | pdd_products | write |
|
||||
| PATCH | `/pdd-products/:productId` | pdd_products | write |
|
||||
| GET | `/shopee-products` | shopee_products | read |
|
||||
| GET | `/shopee-products/:productId` | shopee_products | read |
|
||||
| POST | `/shopee-products` | shopee_products | write |
|
||||
| PATCH | `/shopee-products/:productId` | shopee_products | write |
|
||||
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
|
||||
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
|
||||
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
|
||||
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
|
||||
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
|
||||
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
|
||||
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
|
||||
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
|
||||
| GET | `/syb-products` | syb_products | read |
|
||||
| GET | `/syb-products/:productId` | syb_products | read |
|
||||
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
|
||||
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
|
||||
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
|
||||
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
|
||||
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
|
||||
| POST | `/syb-products/import` | syb_sync_runs | sync |
|
||||
| GET | `/syb-inner-codes` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
|
||||
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
|
||||
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
|
||||
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
|
||||
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
|
||||
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
|
||||
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
|
||||
| GET | `/syb-shops` | syb_shops | read |
|
||||
| POST | `/syb-shops` | syb_shops | write |
|
||||
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
|
||||
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
|
||||
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
|
||||
| GET | `/collection-rules` | collection_rules | read |
|
||||
| POST | `/collection-rules` | collection_rules | write |
|
||||
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
|
||||
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
|
||||
| GET | `/purchase-rules` | purchase_rules | read |
|
||||
| GET | `/purchase-rules/current` | purchase_rules | read |
|
||||
| POST | `/purchase-rules` | purchase_rules | write |
|
||||
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
|
||||
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
|
||||
| PUT | `/purchase-rules/current` | purchase_rules | activate |
|
||||
| GET | `/collection-tasks` | collection_tasks | read |
|
||||
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
|
||||
| POST | `/collection-tasks` | collection_tasks | collect |
|
||||
| POST | `/collection-tasks/batch` | collection_tasks | collect |
|
||||
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
|
||||
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
|
||||
| GET | `/purchase-tasks` | purchase_tasks | read |
|
||||
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
|
||||
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
|
||||
| GET | `/ai-matching-settings` | ai_matching | read |
|
||||
|
||||
### openSpecPanel 后置滑动兼容与诊断(#238)
|
||||
|
||||
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
|
||||
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
|
||||
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## Agent 采购订单批量回填(#241)
|
||||
|
||||
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
|
||||
|
||||
`POST /api/agent/v1/purchase-tasks/order-backfill`
|
||||
|
||||
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
|
||||
|
||||
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
|
||||
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
|
||||
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
|
||||
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
|
||||
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
|
||||
|
||||
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
|
||||
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
|
||||
|
||||
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
|
||||
|
||||
| 条目 code | result | 含义 |
|
||||
|---|---|---|
|
||||
| `BACKFILLED` | backfilled | 本条完成回填 |
|
||||
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
|
||||
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
|
||||
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
|
||||
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
|
||||
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
|
||||
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
|
||||
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
|
||||
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
|
||||
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
|
||||
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
|
||||
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
|
||||
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
|
||||
|
||||
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
|
||||
|
||||
### 状态、幂等与并发
|
||||
|
||||
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
|
||||
|
||||
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
|
||||
|
||||
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
|
||||
|
||||
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
|
||||
|
||||
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
|
||||
|
||||
@@ -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: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
|
||||
synchronized_at: 2026-09-07T06:25:27Z
|
||||
wiki_revision: 214485db630141aeb03d4b49b22a65e572fb592a
|
||||
synchronized_at: 2026-09-08T02:24:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -220,25 +220,19 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
|
||||
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
|
||||
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
|
||||
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
|
||||
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
|
||||
|
||||
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
|
||||
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
|
||||
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
|
||||
|
||||
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
|
||||
|
||||
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
|
||||
|
||||
|
||||
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
|
||||
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
|
||||
由操作员明确选择日期,单次仍不得超过 31 天。
|
||||
@@ -255,7 +249,7 @@ expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应
|
||||
或超过 `max_matches` 时不推进游标。
|
||||
|
||||
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
|
||||
可以保留,但只有所有日期全部成功才推进游标。
|
||||
|
||||
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
|
||||
@@ -497,9 +491,9 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
|
||||
|
||||
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
|
||||
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
|
||||
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
|
||||
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
|
||||
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
@@ -654,3 +648,13 @@ settings:
|
||||
- 客户端实现:`server/app/goauto/sybclient/`
|
||||
- 落库与解析:`server/app/goauto/sybimport/`
|
||||
- 相关工单:[#48 客户端移植](https://git.ilapage.cn/OPC/goauto/issues/48)、[#41 SYB 货运单商品导入](https://git.ilapage.cn/OPC/goauto/issues/41)、[#37 物流调度与货运宝回填](https://git.ilapage.cn/OPC/goauto/issues/37)
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:08Z
|
||||
wiki_revision: df033d06fd94a4b02a447a27ced8d711521342f3
|
||||
synchronized_at: 2026-09-08T02:23:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -70,3 +70,26 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
|
||||
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
|
||||
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
|
||||
|
||||
## 客户端密钥部署与验证(#237)
|
||||
|
||||
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。2026-09-08 经用户授权,线上已发布 Server/Web 基线 `d403f3b`,执行迁移 `1788798000000` 并重启 GoAuto/Nginx;迁移记录、两张密钥表、仅管理员菜单关联已回读。HTTP 页面与健康检查通过;未登录管理请求 HTTP 200、业务码 401。真实密钥创建及客户端业务读写闭环尚未验证。
|
||||
|
||||
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
|
||||
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。2026-09-08 线上部署及未登录拒绝检查已通过,真实密钥读写验收仍待进行。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
|
||||
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
|
||||
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
|
||||
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
|
||||
- 隔离验证:Server `go test ./app/goauto/clientkey ./app/goauto/clientapi ./cmd/migrate/migration/version-local`;Web `pnpm exec jest tests/unit/client-keys.spec.js --runInBand` 与 `pnpm run build:prod`。浏览器模拟入口 `/tests/fixtures/client-keys.html` 仅由本地 Vite 开发服务承载,使用内存模拟请求和无效示例密钥,不连接真实数据库,不证明线上鉴权已验收。
|
||||
- 真实部署验收须另行验证实际 HTTP/HTTPS 入口、管理员创建/编辑/停用、普通用户拒绝、读写/独立动作隔离、停用后的后续请求拒绝及日志无密钥;任何真实业务执行继续按独立授权范围进行。
|
||||
|
||||
|
||||
## Windows 本机运行目录与 #237 迁移验证(2026-09-07)
|
||||
|
||||
- Supervisor 实际配置 `D:/supervisor/programs/goauto.conf`;程序仅 `goauto-admin-api` 和 `goauto-admin-ui`。从已有干净工作区 `D:/OPC/goauto-worktrees/main-runtime` 的 main 分支运行;源码运行基线 `ac3c63e`,版本化启动模板更新提交 `9a10d82`。
|
||||
- 原目录 `D:/OPC/goauto` 的用户改动保持原样;本机敏感配置继续读取 `D:/OPC/goauto/config.yaml`,不复制凭据到运行工作区或版本库。数据库 `127.0.0.1:3307/goauto`,API `http://127.0.0.1:8010`,Web `http://127.0.0.1:9527`。
|
||||
- API 命令:`pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration`;Web 同目录 `scripts/start-web.ps1` 与相同 ConfigPath。已验证无需 ExecutionPolicy Bypass。
|
||||
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
|
||||
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
|
||||
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
|
||||
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[program:goauto-admin-api]
|
||||
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto
|
||||
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration
|
||||
directory=D:/OPC/goauto-worktrees/main-runtime
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=50MB
|
||||
stdout_logfile_backups=5
|
||||
|
||||
[program:goauto-admin-ui]
|
||||
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto
|
||||
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto-worktrees/main-runtime
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
goautoclientapi "go-admin/app/goauto/clientapi"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -53,6 +54,7 @@ func InitRouter() {
|
||||
|
||||
// 注册 GoAuto Agent 与设备管理路由。
|
||||
goautodevice.InitRouter(r, authMiddleware)
|
||||
goautoclientapi.InitRouter(r, authMiddleware)
|
||||
goautoapprelease.InitRouter(r, authMiddleware)
|
||||
goautoaimatching.InitRouter(r, authMiddleware)
|
||||
goautotask.InitRouter(r, authMiddleware)
|
||||
|
||||
@@ -56,7 +56,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
|
||||
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
|
||||
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
|
||||
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
|
||||
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-products/import": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
|
||||
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
|
||||
}
|
||||
|
||||
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
|
||||
// Transport does not grant identity or permissions; secrets remain non-cacheable.
|
||||
func NoStore(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Next()
|
||||
}
|
||||
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
deny := func(status int, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
|
||||
}
|
||||
// Never accept a credential supplied through a URL or cookie fallback.
|
||||
for k := range c.Request.URL.Query() {
|
||||
if sensitive(k) || strings.EqualFold(k, "token") {
|
||||
deny(400, "密钥只能通过 Authorization 请求头发送")
|
||||
return
|
||||
}
|
||||
}
|
||||
header := strings.Fields(c.GetHeader("Authorization"))
|
||||
if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") {
|
||||
deny(401, "需要客户端密钥")
|
||||
return
|
||||
}
|
||||
connection := db
|
||||
var err error
|
||||
if connection == nil {
|
||||
connection, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil || connection == nil {
|
||||
deny(503, "客户端认证暂不可用")
|
||||
return
|
||||
}
|
||||
key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1])
|
||||
if err != nil {
|
||||
if errors.Is(err, clientkey.ErrCredential) {
|
||||
deny(401, "客户端密钥无效或已停用")
|
||||
} else {
|
||||
deny(503, "客户端认证暂不可用")
|
||||
}
|
||||
return
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err = rand.Read(random); err != nil {
|
||||
deny(503, "审计暂不可用")
|
||||
return
|
||||
}
|
||||
requestID := hex.EncodeToString(random)
|
||||
c.Header("X-Client-Request-Id", requestID)
|
||||
record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path}
|
||||
if !key.Allows(e.Module, e.Capability) {
|
||||
record.Status = 403
|
||||
_ = connection.Create(&record).Error
|
||||
deny(403, "密钥未授权此模块或执行动作")
|
||||
return
|
||||
}
|
||||
// The intent must be durable before any business handler can run.
|
||||
if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil {
|
||||
deny(503, "审计暂不可用,未执行操作")
|
||||
return
|
||||
}
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
original := c.Writer
|
||||
buffer := &responseBuffer{ResponseWriter: original, status: 200}
|
||||
c.Writer = buffer
|
||||
defer func() { c.Writer = original }()
|
||||
c.Next()
|
||||
c.Writer = original
|
||||
status := buffer.status
|
||||
var value any
|
||||
if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil {
|
||||
status = 502
|
||||
value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"}
|
||||
} else {
|
||||
value = redact(value)
|
||||
}
|
||||
auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error
|
||||
now := time.Now().UTC()
|
||||
_ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error
|
||||
c.JSON(status, value)
|
||||
}
|
||||
}
|
||||
|
||||
type responseBuffer struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
status int
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func (w *responseBuffer) WriteHeader(status int) { w.status = status }
|
||||
func (w *responseBuffer) WriteHeaderNow() {}
|
||||
func (w *responseBuffer) Status() int { return w.status }
|
||||
func (w *responseBuffer) Size() int { return w.body.Len() }
|
||||
func (w *responseBuffer) Written() bool { return w.body.Len() > 0 }
|
||||
func (w *responseBuffer) Write(b []byte) (int, error) {
|
||||
if w.body.Len()+len(b) > 32<<20 {
|
||||
w.overflow = true
|
||||
return len(b), nil
|
||||
}
|
||||
return w.body.Write(b)
|
||||
}
|
||||
func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
|
||||
func (w *responseBuffer) Flush() {}
|
||||
|
||||
func sensitive(k string) bool {
|
||||
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", ""))
|
||||
for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} {
|
||||
if strings.Contains(key, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func redact(v any) any {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
for k, item := range value {
|
||||
if sensitive(k) {
|
||||
delete(value, k)
|
||||
} else {
|
||||
value[k] = redact(item)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, item := range value {
|
||||
value[i] = redact(item)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
sql.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { sql.Close() })
|
||||
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
|
||||
}
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
seen := map[string]bool{}
|
||||
for _, e := range routes {
|
||||
key := e.Method + e.Path
|
||||
if seen[key] {
|
||||
t.Fatal("duplicate route")
|
||||
}
|
||||
seen[key] = true
|
||||
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
|
||||
t.Fatal("forbidden route")
|
||||
}
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
|
||||
if e.Method != "GET" && e.Capability == "read" {
|
||||
t.Fatal("mutating read permission")
|
||||
}
|
||||
}
|
||||
for _, e := range routes {
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
grant := clientkey.Grant{Module: e.Module}
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := e.Path
|
||||
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
|
||||
path = strings.ReplaceAll(path, param, "1")
|
||||
}
|
||||
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
want := 200
|
||||
if e.Capability != "read" {
|
||||
want = 403
|
||||
}
|
||||
if rr.Code != want {
|
||||
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
|
||||
}
|
||||
if e.Capability == "write" {
|
||||
grant.Write = true
|
||||
} else if e.Capability != "read" {
|
||||
grant.Actions = []string{e.Capability}
|
||||
}
|
||||
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req.Clone(context.Background()))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
|
||||
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
|
||||
router := gin.New()
|
||||
calls := 0
|
||||
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
|
||||
calls++
|
||||
id, ok := clientprincipal.Get(c)
|
||||
if !ok || id.KeyID != v.ID {
|
||||
t.Error("client attribution missing")
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
|
||||
})
|
||||
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
|
||||
if tlsOn {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+credential)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
path := "/api/client/v1/pdd-products"
|
||||
for _, tlsOn := range []bool{false, true} {
|
||||
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
|
||||
t.Fatal("credential fallback accepted")
|
||||
}
|
||||
rr := send(tlsOn, token, path)
|
||||
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
|
||||
t.Fatal("redaction failed")
|
||||
}
|
||||
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
|
||||
t.Fatal("cache or audit headers missing")
|
||||
}
|
||||
}
|
||||
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
|
||||
t.Fatal("revocation not immediate")
|
||||
}
|
||||
var logs []clientkey.Audit
|
||||
db.Find(&logs)
|
||||
raw, _ := json.Marshal(logs)
|
||||
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
|
||||
t.Fatal("audit leaked payload")
|
||||
}
|
||||
}
|
||||
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Migrator().DropTable(&clientkey.Audit{})
|
||||
router := gin.New()
|
||||
called := false
|
||||
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
|
||||
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
|
||||
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 503 || called {
|
||||
t.Fatal("executed without audit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/product"
|
||||
"go-admin/common/middleware"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.PDDProduct{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() })
|
||||
router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create)
|
||||
for n := 0; n < 2; n++ {
|
||||
req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("product create failed status %d", rr.Code)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &body)
|
||||
if (n == 1) != body.Data.Replayed {
|
||||
t.Fatal("business idempotency changed")
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PDDProduct{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("duplicate business write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
h := clientkey.Handler{DB: db, Modules: s.Modules}
|
||||
for _, role := range []string{"admin", "purchaser", "client", ""} {
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
|
||||
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
want := 403
|
||||
if role == "admin" {
|
||||
want = 200
|
||||
}
|
||||
if rr.Code != want {
|
||||
t.Fatalf("role %q status %d", role, rr.Code)
|
||||
}
|
||||
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("one-time secret cacheable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// resolveSpecs accepts only specification text, never a provider URL/key,
|
||||
// prompt, raw order, address or account. It returns a suggestion, not an order.
|
||||
func resolveSpecs(c *gin.Context) {
|
||||
var req struct {
|
||||
TargetColor string `json:"targetColor"`
|
||||
TargetSize string `json:"targetSize"`
|
||||
Colors []string `json:"colors"`
|
||||
Sizes []string `json:"sizes"`
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"})
|
||||
return
|
||||
}
|
||||
for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) {
|
||||
if len([]rune(s)) > 255 {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "规格值过长"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"code": 503})
|
||||
return
|
||||
}
|
||||
v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes})
|
||||
if err != nil {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package clientapi exposes only the reviewed client route inventory. It never
|
||||
// forwards arbitrary URLs or synthesizes a logged-in administrator identity.
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/product"
|
||||
"go-admin/app/goauto/purchase"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
"go-admin/app/goauto/rule"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
"go-admin/app/goauto/sybinnercode"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/app/goauto/task"
|
||||
"go-admin/common/middleware"
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Method, Path, Module, Capability string
|
||||
Handle gin.HandlerFunc
|
||||
}
|
||||
|
||||
func Inventory() []Endpoint {
|
||||
p := product.Handler{}
|
||||
s := shopeeproduct.Handler{}
|
||||
y := sybimport.Handler{}
|
||||
i := sybinnercode.Handler{}
|
||||
shop := sybshop.Handler{}
|
||||
r := rule.Handler{}
|
||||
pr := purchaserule.Handler{}
|
||||
t := task.Handler{}
|
||||
buy := purchase.Handler{}
|
||||
return []Endpoint{
|
||||
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
|
||||
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
|
||||
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
|
||||
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
|
||||
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
|
||||
{"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update},
|
||||
{"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List},
|
||||
{"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail},
|
||||
{"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create},
|
||||
{"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update},
|
||||
{"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD},
|
||||
{"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue},
|
||||
{"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping},
|
||||
{"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue},
|
||||
{"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping},
|
||||
{"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete},
|
||||
{"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings},
|
||||
{"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping},
|
||||
{"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List},
|
||||
{"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail},
|
||||
{"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect},
|
||||
{"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse},
|
||||
{"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch},
|
||||
{"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns},
|
||||
{"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail},
|
||||
{"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import},
|
||||
{"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List},
|
||||
{"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail},
|
||||
{"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob},
|
||||
{"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch},
|
||||
{"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch},
|
||||
{"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import},
|
||||
{"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete},
|
||||
{"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview},
|
||||
{"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply},
|
||||
{"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck},
|
||||
{"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List},
|
||||
{"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create},
|
||||
{"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename},
|
||||
{"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled},
|
||||
{"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete},
|
||||
{"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List},
|
||||
{"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create},
|
||||
{"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update},
|
||||
{"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete},
|
||||
{"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List},
|
||||
{"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current},
|
||||
{"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create},
|
||||
{"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update},
|
||||
{"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete},
|
||||
{"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent},
|
||||
{"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList},
|
||||
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
|
||||
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
|
||||
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
|
||||
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
{"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail},
|
||||
{"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview},
|
||||
{"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate},
|
||||
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
|
||||
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
|
||||
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
|
||||
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500})
|
||||
return
|
||||
}
|
||||
v, err := aimatching.NewService(db).Settings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}})
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func Catalog(routes []Endpoint) []clientkey.Module {
|
||||
mods := map[string]clientkey.Module{}
|
||||
for _, m := range access.GoAutoModules() {
|
||||
mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}}
|
||||
}
|
||||
for _, e := range routes {
|
||||
m := mods[e.Module]
|
||||
if e.Capability == "write" {
|
||||
m.Writable = true
|
||||
} else if e.Capability != "read" {
|
||||
found := false
|
||||
for _, a := range m.Actions {
|
||||
if a == e.Capability {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
m.Actions = append(m.Actions, e.Capability)
|
||||
}
|
||||
}
|
||||
mods[e.Module] = m
|
||||
}
|
||||
out := []clientkey.Module{}
|
||||
for _, g := range access.GoAutoMenuGroups() {
|
||||
for _, key := range g.ModuleKeys {
|
||||
m := mods[key]
|
||||
m.Group = g.Title
|
||||
sort.Strings(m.Actions)
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
routes := Inventory()
|
||||
catalog := Catalog(routes)
|
||||
h := clientkey.Handler{Modules: catalog}
|
||||
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
|
||||
management.GET("", h.List)
|
||||
management.GET("/modules", h.Catalog)
|
||||
management.POST("", h.Create)
|
||||
management.PATCH("/:keyId/grants", h.Edit)
|
||||
management.POST("/:keyId/disable", h.Disable)
|
||||
for _, e := range routes {
|
||||
engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Modules []Module
|
||||
}
|
||||
|
||||
func (h Handler) service(c *gin.Context) (Service, bool) {
|
||||
db := h.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil || db == nil {
|
||||
failure(c, errors.New("database unavailable"))
|
||||
return Service{}, false
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
return Service{db, h.Modules}, true
|
||||
}
|
||||
func failure(c *gin.Context, err error) {
|
||||
status, message := 500, "服务端处理失败"
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalid):
|
||||
status, message = 422, ErrInvalid.Error()
|
||||
case errors.Is(err, ErrConflict):
|
||||
status, message = 409, ErrConflict.Error()
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
status, message = 404, "密钥不存在"
|
||||
}
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
|
||||
}
|
||||
func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) }
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := 20
|
||||
var total int64
|
||||
var keys []Key
|
||||
if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]View, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
items = append(items, view(k))
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}})
|
||||
}
|
||||
func decode(c *gin.Context, v any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF {
|
||||
failure(c, ErrInvalid)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c)))
|
||||
if err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}})
|
||||
}
|
||||
func (h Handler) Edit(c *gin.Context) { h.update(c, false) }
|
||||
func (h Handler) Disable(c *gin.Context) { h.update(c, true) }
|
||||
func (h Handler) update(c *gin.Context, disable bool) {
|
||||
id, err := strconv.ParseUint(c.Param("keyId"), 10, 64)
|
||||
if err != nil {
|
||||
failure(c, ErrInvalid)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Version uint64 `json:"version"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable)
|
||||
if err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package clientkey implements independent, revocable client credentials (#237).
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("名称或授权无效")
|
||||
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
|
||||
var ErrCredential = errors.New("客户端密钥无效或已停用")
|
||||
|
||||
type Grant struct {
|
||||
Module string `json:"module"`
|
||||
Write bool `json:"write"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
type Module struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Group string `json:"group"`
|
||||
Writable bool `json:"writable"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
type Key struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:80;not null" json:"name"`
|
||||
Prefix string `gorm:"size:24;not null" json:"prefix"`
|
||||
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
GrantsJSON string `gorm:"type:text;not null" json:"-"`
|
||||
Enabled bool `gorm:"not null" json:"enabled"`
|
||||
Version uint64 `gorm:"not null" json:"version"`
|
||||
CreatedBy uint64 `json:"createdBy"`
|
||||
UpdatedBy uint64 `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
func (Key) TableName() string { return "client_api_key" }
|
||||
|
||||
type Audit struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
KeyID uint64 `gorm:"index;not null"`
|
||||
ActorID uint64
|
||||
Event string `gorm:"size:32;not null"`
|
||||
RequestID string `gorm:"size:32;index"`
|
||||
Method string `gorm:"size:10"`
|
||||
Route string `gorm:"size:180"`
|
||||
Status int
|
||||
Changes string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (Audit) TableName() string { return "client_api_key_audit" }
|
||||
|
||||
type View struct {
|
||||
Key
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
|
||||
func view(k Key) View {
|
||||
var g []Grant
|
||||
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
|
||||
return View{Key: k, Grants: g}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Modules []Module
|
||||
}
|
||||
|
||||
func (s Service) Normalize(in []Grant) ([]Grant, error) {
|
||||
if len(in) == 0 || len(in) > len(s.Modules) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
catalog := map[string]Module{}
|
||||
for _, m := range s.Modules {
|
||||
catalog[m.Key] = m
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]Grant, 0, len(in))
|
||||
for _, g := range in {
|
||||
m, ok := catalog[g.Module]
|
||||
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
seen[g.Module] = true
|
||||
allowed := map[string]bool{}
|
||||
for _, a := range m.Actions {
|
||||
allowed[a] = true
|
||||
}
|
||||
used := map[string]bool{}
|
||||
actions := []string{}
|
||||
for _, a := range g.Actions {
|
||||
if !allowed[a] || used[a] {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
used[a] = true
|
||||
actions = append(actions, a)
|
||||
}
|
||||
sort.Strings(actions)
|
||||
out = append(out, Grant{g.Module, g.Write, actions})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
|
||||
return out, nil
|
||||
}
|
||||
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 80 || actor == 0 {
|
||||
return View{}, "", ErrInvalid
|
||||
}
|
||||
g, err := s.Normalize(grants)
|
||||
if err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
raw, _ := json.Marshal(g)
|
||||
secret := make([]byte, 32)
|
||||
if _, err = rand.Read(secret); err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
token := "gak_" + hex.EncodeToString(secret)
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&k).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
return view(k), token, nil
|
||||
}
|
||||
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
|
||||
if id == 0 || version == 0 || actor == 0 {
|
||||
return View{}, ErrInvalid
|
||||
}
|
||||
var raw []byte
|
||||
if !disable {
|
||||
g, err := s.Normalize(grants)
|
||||
if err != nil {
|
||||
return View{}, err
|
||||
}
|
||||
raw, _ = json.Marshal(g)
|
||||
}
|
||||
var k Key
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&k, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
|
||||
event := "edit"
|
||||
if disable {
|
||||
changes["enabled"] = false
|
||||
event = "disable"
|
||||
} else {
|
||||
changes["grants_json"] = string(raw)
|
||||
}
|
||||
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
|
||||
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&k, id).Error
|
||||
})
|
||||
return view(k), err
|
||||
}
|
||||
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
|
||||
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
if _, err := hex.DecodeString(token[4:]); err != nil {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
var k Key
|
||||
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
return k, err
|
||||
}
|
||||
func (k Key) Allows(module, capability string) bool {
|
||||
var grants []Grant
|
||||
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range grants {
|
||||
if g.Module != module {
|
||||
continue
|
||||
}
|
||||
if capability == "read" {
|
||||
return true
|
||||
}
|
||||
if capability == "write" {
|
||||
return g.Write
|
||||
}
|
||||
for _, a := range g.Actions {
|
||||
if a == capability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
sql.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { sql.Close() })
|
||||
if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}}
|
||||
}
|
||||
func TestCredentialLifecycle(t *testing.T) {
|
||||
s := testService(t)
|
||||
ctx := context.Background()
|
||||
v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 68 || v.Digest == token {
|
||||
t.Fatal("invalid credential generation")
|
||||
}
|
||||
wire, _ := json.Marshal(v)
|
||||
if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) {
|
||||
t.Fatal("secret serialized")
|
||||
}
|
||||
k, err := s.Authenticate(ctx, token)
|
||||
if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") {
|
||||
t.Fatal("default grants")
|
||||
}
|
||||
updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k, err = s.Authenticate(ctx, token)
|
||||
if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest {
|
||||
t.Fatal("edit changed credential or failed to apply grants")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("stale edit accepted")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) {
|
||||
t.Fatal("disabled credential accepted")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("disabled key edited")
|
||||
}
|
||||
var logs []Audit
|
||||
s.DB.Find(&logs)
|
||||
if len(logs) != 3 {
|
||||
t.Fatalf("audit count %d", len(logs))
|
||||
}
|
||||
data, _ := json.Marshal(logs)
|
||||
if strings.Contains(string(data), token) {
|
||||
t.Fatal("secret in audit")
|
||||
}
|
||||
}
|
||||
func TestInvalidGrantsAndCredentials(t *testing.T) {
|
||||
s := testService(t)
|
||||
for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} {
|
||||
if _, err := s.Normalize(g); err == nil {
|
||||
t.Fatal("invalid grant accepted")
|
||||
}
|
||||
}
|
||||
for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} {
|
||||
if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) {
|
||||
t.Fatal("invalid credential accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAuditFailureRollsBack(t *testing.T) {
|
||||
s := testService(t)
|
||||
ctx := context.Background()
|
||||
v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.DB.Migrator().DropTable(&Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil {
|
||||
t.Fatal("audit failure ignored")
|
||||
}
|
||||
var k Key
|
||||
s.DB.First(&k, v.ID)
|
||||
if !k.Enabled || k.Version != 1 {
|
||||
t.Fatal("mutation not rolled back")
|
||||
}
|
||||
if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" {
|
||||
t.Fatal("creation audit failure ignored")
|
||||
}
|
||||
var count int64
|
||||
s.DB.Model(&Key{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("key persisted without audit")
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,15 @@ func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
|
||||
return task.syncPurchaseGuardSlots()
|
||||
}
|
||||
|
||||
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
|
||||
func (task *PurchaseTask) BeforeSave(tx *gorm.DB) error {
|
||||
if err := task.syncPurchaseGuardSlots(); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" {
|
||||
return CheckPurchaseOrderNumber(tx, task.ID, *task.PDDOrderNo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (task *PurchaseTask) SetStatus(status string) error {
|
||||
task.Status = status
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrPurchaseOrderNumberUsed = errors.New("purchase order number belongs to another task")
|
||||
|
||||
type PurchaseOrderNumberUsedError struct {
|
||||
TaskID uint64
|
||||
}
|
||||
|
||||
func (e *PurchaseOrderNumberUsedError) Error() string {
|
||||
return fmt.Sprintf("订单号已属于任务 CG-%d", e.TaskID)
|
||||
}
|
||||
|
||||
func (e *PurchaseOrderNumberUsedError) Unwrap() error { return ErrPurchaseOrderNumberUsed }
|
||||
|
||||
// CheckPurchaseOrderNumber must run inside the caller's write transaction.
|
||||
// The existing singleton setting row serializes order assignments across
|
||||
// processes, including an absent order number, without relying on gap locks or
|
||||
// a new schema constraint. Locking reads see the latest committed assignment.
|
||||
// A missing singleton fails closed. Deadlocks roll back the losing transaction.
|
||||
func CheckPurchaseOrderNumber(tx *gorm.DB, taskID uint64, orderNo string) error {
|
||||
var setting PurchaseRuleSetting
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var others []PurchaseTask
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("pdd_order_no = ? AND id <> ?", orderNo, taskID).Find(&others).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(others) != 0 {
|
||||
return &PurchaseOrderNumberUsedError{TaskID: others[0].ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go-admin/common/clientprincipal"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) {
|
||||
}
|
||||
|
||||
func allowedOperator(c *gin.Context) bool {
|
||||
if _, ok := clientprincipal.Get(c); ok {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
return true
|
||||
@@ -538,12 +542,15 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusForbidden
|
||||
case CodeTaskNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired:
|
||||
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed:
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
}
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
switch v := claims["identity"].(type) {
|
||||
case float64:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -378,6 +379,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
t.PDDOrderNo = &req.PDDOrderNo
|
||||
t.OrderSubmittedAt = req.OrderSubmittedAt
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
// Keep the assignment lock until commit. A conflicting observation
|
||||
// after the irreversible boundary must reach manual resolution,
|
||||
// not roll back the result or claim another task's order number.
|
||||
if e := models.CheckPurchaseOrderNumber(tx, t.ID, req.PDDOrderNo); e != nil {
|
||||
var conflict *models.PurchaseOrderNumberUsedError
|
||||
if !errors.As(e, &conflict) {
|
||||
return TaskPayload{}, conflictOrInternal(e)
|
||||
}
|
||||
next = models.PurchaseTaskStatusOrderResultUnknown
|
||||
t.PDDOrderNo = nil
|
||||
failureCode := CodeOrderNumberUsed
|
||||
message := fmt.Sprintf("读到订单号 %s,但该号已属于任务 %s", req.PDDOrderNo, taskNumber(conflict.TaskID))
|
||||
t.ErrorCode, t.ErrorMessage = &failureCode, &message
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
a.ErrorCode, a.ErrorMessage = &failureCode, &message
|
||||
}
|
||||
case "order_result_unknown":
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
|
||||
@@ -525,7 +542,10 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec
|
||||
}
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = s.Now()
|
||||
return tx.Save(&t).Error
|
||||
if e := tx.Save(&t).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return t, replayed, err
|
||||
}
|
||||
@@ -728,6 +748,10 @@ func purchaseNotFound(err error) error {
|
||||
return internal(err)
|
||||
}
|
||||
func conflictOrInternal(err error) error {
|
||||
var conflict *models.PurchaseOrderNumberUsedError
|
||||
if errors.As(err, &conflict) {
|
||||
return fail(CodeOrderNumberUsed, conflict.Error())
|
||||
}
|
||||
if isDuplicate(err) {
|
||||
return fail(CodeDeviceBusy, "设备或拼多多账号已有运行任务")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ
|
||||
}
|
||||
out.WritebackStatus = models.PurchaseWritebackStatusPending
|
||||
out.WritebackSelectRequestID = &req.RequestID
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
@@ -166,7 +169,10 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
}
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxOrderBackfillItems = 50
|
||||
CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID"
|
||||
CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH"
|
||||
CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT"
|
||||
CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT"
|
||||
CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED"
|
||||
)
|
||||
|
||||
type OrderBackfillRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Items []OrderBackfillItem `json:"items"`
|
||||
}
|
||||
|
||||
type OrderBackfillItem struct {
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo string `json:"pddOrderNo"`
|
||||
// A string keeps an invalid page timestamp local to this item.
|
||||
OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"`
|
||||
}
|
||||
|
||||
type OrderBackfillResult struct {
|
||||
Index int `json:"index"`
|
||||
TaskID uint64 `json:"taskId,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion,omitempty"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
TimeSource string `json:"timeSource,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type OrderBackfillResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Items []OrderBackfillResult `json:"items"`
|
||||
}
|
||||
|
||||
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
|
||||
out := OrderBackfillResponse{RequestID: req.RequestID}
|
||||
d, err := device.NewService(s.DB).Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
|
||||
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
|
||||
}
|
||||
ids := make([]uint64, len(req.Items))
|
||||
orders := make(map[uint64]string)
|
||||
conflicts := make(map[uint64]bool)
|
||||
for i, item := range req.Items {
|
||||
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids[i] = id
|
||||
if previous, ok := orders[id]; ok && previous != item.PDDOrderNo {
|
||||
conflicts[id] = true
|
||||
}
|
||||
orders[id] = item.PDDOrderNo
|
||||
}
|
||||
out.Items = make([]OrderBackfillResult, len(req.Items))
|
||||
for i, item := range req.Items {
|
||||
r := OrderBackfillResult{Index: i, TaskID: ids[i], Result: "failed"}
|
||||
if ids[i] == 0 {
|
||||
r.Code = CodeBackfillSuffix
|
||||
} else {
|
||||
r = s.backfillOrder(ctx, d.ID, ids[i], req.RequestID, item, conflicts[ids[i]])
|
||||
r.Index = i
|
||||
}
|
||||
out.Items[i] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
|
||||
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
|
||||
var task models.PurchaseTask
|
||||
// SQL errors must not print bound order numbers or the task's address snapshot.
|
||||
db := s.DB.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).WithContext(ctx)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
|
||||
return purchaseNotFound(err)
|
||||
}
|
||||
if task.DeviceID == nil || *task.DeviceID != deviceID {
|
||||
return fail(CodeBackfillDevice, "任务不属于当前设备")
|
||||
}
|
||||
if batchConflict {
|
||||
return fail(CodeBackfillBatchConflict, "同批任务有不同订单号")
|
||||
}
|
||||
if task.ExecutionMode != models.PurchaseExecutionModeLive || (task.Status != models.PurchaseTaskStatusOrderResultUnknown && task.Status != models.PurchaseTaskStatusOrderCreated) {
|
||||
return fail(CodeStateConflict, "当前任务不允许回填")
|
||||
}
|
||||
if item.PDDOrderNo == "" || strings.TrimSpace(item.PDDOrderNo) != item.PDDOrderNo || utf8.RuneCountInString(item.PDDOrderNo) > 100 || strings.ContainsAny(item.PDDOrderNo, "\r\n\t") {
|
||||
return fail(CodeInvalidRequest, "订单号无效")
|
||||
}
|
||||
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" && *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeBackfillOrderConflict, "已有不同订单号")
|
||||
}
|
||||
// The shared model guard also protects manual resolution and late results.
|
||||
if err := models.CheckPurchaseOrderNumber(tx, taskID, item.PDDOrderNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
|
||||
}
|
||||
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
|
||||
return nil
|
||||
}
|
||||
var submitted time.Time
|
||||
source := "page"
|
||||
if item.OrderSubmittedAt != nil {
|
||||
var err error
|
||||
submitted, err = time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
|
||||
if err != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
|
||||
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
|
||||
}
|
||||
} else {
|
||||
if task.IrreversibleAt == nil || task.IrreversibleAt.IsZero() {
|
||||
return fail(CodeOrderTimeMissing, "下单时间和不可逆时间均缺失")
|
||||
}
|
||||
submitted, source = *task.IrreversibleAt, "irreversible_at"
|
||||
}
|
||||
submitted = submitted.UTC()
|
||||
task.PDDOrderNo, task.OrderSubmittedAt = &item.PDDOrderNo, &submitted
|
||||
if err := task.SetStatus(models.PurchaseTaskStatusOrderCreated); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
task.StatusVersion++
|
||||
task.StatusChangedAt = s.Now()
|
||||
task.ErrorCode, task.ErrorMessage = nil, nil
|
||||
task.LeaseExpiresAt = nil
|
||||
// Reuse the existing resolution request slot. Scope a batch UUID to a
|
||||
// task, and retain provenance without a schema change or replay cache.
|
||||
marker := "backfill:" + source + ":" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
|
||||
task.UnknownResolveRequestID = &marker
|
||||
if err := tx.Save(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Result, r.Code = "backfilled", "BACKFILLED"
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
r.Result, r.Code = "failed", CodeInternal
|
||||
r.Retryable = true
|
||||
var se *ServiceError
|
||||
if errors.As(err, &se) {
|
||||
r.Code, r.Retryable = se.Code, se.Retryable
|
||||
}
|
||||
if errors.Is(err, models.ErrPurchaseOrderNumberUsed) {
|
||||
r.Code, r.Retryable = CodeBackfillOrderUsed, false
|
||||
}
|
||||
if r.Code == CodeBackfillBatchConflict || r.Code == CodeBackfillOrderConflict || r.Code == CodeBackfillOrderUsed {
|
||||
r.Result = "conflict"
|
||||
}
|
||||
}
|
||||
// Return only this device's committed facts, including on a rejected item.
|
||||
// Never return in-memory changes from a rolled back transaction.
|
||||
saved := task
|
||||
readable := err == nil
|
||||
if !readable {
|
||||
saved = models.PurchaseTask{}
|
||||
readable = db.Where("id = ? AND device_id = ?", taskID, deviceID).First(&saved).Error == nil
|
||||
}
|
||||
if readable {
|
||||
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
|
||||
r.PDDOrderNo, r.OrderSubmittedAt = saved.PDDOrderNo, saved.OrderSubmittedAt
|
||||
if saved.OrderSubmittedAt != nil {
|
||||
r.TimeSource = "existing_unknown"
|
||||
if saved.UnknownResolveRequestID != nil {
|
||||
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:page:") {
|
||||
r.TimeSource = "page"
|
||||
}
|
||||
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:irreversible_at:") {
|
||||
r.TimeSource = "irreversible_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h Handler) BackfillOrders(c *gin.Context) {
|
||||
var req OrderBackfillRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.BackfillOrders(c.Request.Context(), req, bearer(c.GetHeader("Authorization")))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func backfillTask(t *testing.T, db *gorm.DB, f fixture, status string) models.PurchaseTask {
|
||||
t.Helper()
|
||||
now := testService(db).Now()
|
||||
task := models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
Status: status, DeviceID: &f.device.ID, PDDProductID: f.pdd.ID, Quantity: 1, Currency: "CNY",
|
||||
CreateRequestID: uuid.NewString(), RuleSnapshot: string(purchasecontract.DefaultLiveRule()),
|
||||
SpecDecisionSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`, IrreversibleAt: &now,
|
||||
ErrorCode: strptr("ORIGINAL_ERROR"), ErrorMessage: strptr("original failure")}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func backfillItem(id uint64, order string) OrderBackfillItem {
|
||||
return OrderBackfillItem{AddressSuffix: purchasecontract.AddressSuffix(id), PDDOrderNo: order}
|
||||
}
|
||||
|
||||
func runBackfill(t *testing.T, s *Service, token, requestID string, items ...OrderBackfillItem) []OrderBackfillResult {
|
||||
t.Helper()
|
||||
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: requestID, Items: items}, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Items) != len(items) || out.RequestID != requestID {
|
||||
t.Fatalf("bad envelope: %+v", out)
|
||||
}
|
||||
return out.Items
|
||||
}
|
||||
|
||||
func loadBackfillTask(t *testing.T, db *gorm.DB, id uint64) models.PurchaseTask {
|
||||
t.Helper()
|
||||
var task models.PurchaseTask
|
||||
if err := db.First(&task, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
a.TaskType, a.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
c := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if err := db.Model(&c).Update("irreversible_at", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := backfillItem(b.ID, "ORDER-B")
|
||||
page.OrderSubmittedAt = strptr("2026-09-08T20:30:00+08:00")
|
||||
rid := uuid.NewString()
|
||||
items := []OrderBackfillItem{backfillItem(a.ID, "ORDER-A"), {AddressSuffix: "_cg0", PDDOrderNo: "bad"}, page, backfillItem(c.ID, "ORDER-C"), backfillItem(99999, "missing")}
|
||||
results := runBackfill(t, s, f.token, rid, items...)
|
||||
want := []string{"BACKFILLED", CodeBackfillSuffix, "BACKFILLED", CodeOrderTimeMissing, CodeTaskNotFound}
|
||||
for i, r := range results {
|
||||
if r.Code != want[i] || r.Index != i {
|
||||
t.Fatalf("item %d: %+v", i, r)
|
||||
}
|
||||
}
|
||||
if results[0].TimeSource != "irreversible_at" || !results[0].OrderSubmittedAt.Equal(*a.IrreversibleAt) {
|
||||
t.Fatalf("fallback: %+v", results[0])
|
||||
}
|
||||
if results[2].TimeSource != "page" || results[2].OrderSubmittedAt.Format(time.RFC3339) != "2026-09-08T12:30:00Z" {
|
||||
t.Fatalf("page: %+v", results[2])
|
||||
}
|
||||
saved := loadBackfillTask(t, db, a.ID)
|
||||
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("state metadata: %+v", saved)
|
||||
}
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
t.Fatal("unrelated business facts changed")
|
||||
}
|
||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||
item := items[0]
|
||||
item.OrderSubmittedAt = strptr("2026-09-09T00:00:00Z")
|
||||
r := runBackfill(t, s, f.token, replayID, item)[0]
|
||||
if r.Result != "already_backfilled" || r.TimeSource != "irreversible_at" {
|
||||
t.Fatalf("replay: %+v", r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, a.ID); !reflect.DeepEqual(saved, got) {
|
||||
t.Fatal("replay changed persisted task")
|
||||
}
|
||||
}
|
||||
if got := loadBackfillTask(t, db, c.ID); got.PDDOrderNo != nil || got.StatusVersion != c.StatusVersion {
|
||||
t.Fatal("missing time wrote data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillRejectsOwnershipStatesAndInvalidTime(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
for _, status := range []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted} {
|
||||
task := backfillTask(t, db, f, status)
|
||||
before := loadBackfillTask(t, db, task.ID)
|
||||
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
|
||||
if r.Code != CodeStateConflict {
|
||||
t.Fatalf("%s: %+v", status, r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, task.ID); !reflect.DeepEqual(got, before) {
|
||||
t.Fatal("rejection wrote data")
|
||||
}
|
||||
// Release the fixture's device slot before testing the next running state.
|
||||
if err := task.SetStatus(models.PurchaseTaskStatusCancelled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
|
||||
if r.Code != CodeBackfillDevice || r.Status != "" || r.PDDOrderNo != nil {
|
||||
t.Fatalf("ownership leaked: %+v", r)
|
||||
}
|
||||
other, err := device.NewService(db).Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "Other", Manufacturer: "Test", Model: "Test", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps()}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&task).Update("device_id", other.DeviceID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeBackfillDevice {
|
||||
t.Fatalf("cross device: %+v", r)
|
||||
}
|
||||
if err := db.Model(&task).Updates(map[string]any{"device_id": f.device.ID, "execution_mode": models.PurchaseExecutionModeRehearsal}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeStateConflict {
|
||||
t.Fatalf("rehearsal: %+v", r)
|
||||
}
|
||||
if err := db.Model(&task).Update("execution_mode", models.PurchaseExecutionModeLive).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []string{"", "2026-09-08 12:00:00", "0001-01-01T00:00:00Z", "garbage"} {
|
||||
item := backfillItem(task.ID, "ORDER")
|
||||
item.OrderSubmittedAt = &raw
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), item)[0]; r.Code != CodeOrderTimeInvalid {
|
||||
t.Fatalf("invalid time: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConflictsNeverOverwrite(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
rid := uuid.NewString()
|
||||
r := runBackfill(t, s, f.token, rid, backfillItem(a.ID, "A"), backfillItem(a.ID, "B"), backfillItem(b.ID, "B"))
|
||||
if r[0].Code != CodeBackfillBatchConflict || r[1].Code != CodeBackfillBatchConflict || r[2].Code != "BACKFILLED" {
|
||||
t.Fatalf("batch: %+v", r)
|
||||
}
|
||||
r = runBackfill(t, s, f.token, rid, backfillItem(a.ID, "B"), backfillItem(b.ID, "C"))
|
||||
if r[0].Code != CodeBackfillOrderUsed || r[1].Code != CodeBackfillOrderConflict {
|
||||
t.Fatalf("changed requestId payload bypassed checks: %+v", r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, b.ID); *got.PDDOrderNo != "B" || got.StatusVersion != b.StatusVersion+1 {
|
||||
t.Fatal("conflict overwrote")
|
||||
}
|
||||
if got := loadBackfillTask(t, db, a.ID); got.PDDOrderNo != nil {
|
||||
t.Fatal("conflict wrote data")
|
||||
}
|
||||
// Even an unknown task with an existing conflicting value must preserve it.
|
||||
a.PDDOrderNo = strptr("OLD")
|
||||
if err := db.Save(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "NEW"))[0]; r.Code != CodeBackfillOrderConflict {
|
||||
t.Fatalf("unknown existing: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentResolveUnknown(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
// SQLite serializes transactions through one connection. These concurrent
|
||||
// service calls verify both winner orders; they do not certify MySQL locks.
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
for i := 0; i < 12; i++ {
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var out OrderBackfillResponse
|
||||
var backErr, manualErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK-"+purchasecontract.AddressSuffix(task.ID))}}, f.token)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL-" + purchasecontract.AddressSuffix(task.ID), OrderSubmittedAt: task.IrreversibleAt})
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if backErr != nil {
|
||||
t.Fatal(backErr)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.StatusVersion != task.StatusVersion+1 || got.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatal("competing writes changed version twice")
|
||||
}
|
||||
if manualErr == nil {
|
||||
if out.Items[0].Code != CodeBackfillOrderConflict || !strings.HasPrefix(*got.PDDOrderNo, "MANUAL-") {
|
||||
t.Fatalf("manual winner: %+v", out)
|
||||
}
|
||||
} else if code(manualErr) != CodeStateConflict || out.Items[0].Code != "BACKFILLED" || !strings.HasPrefix(*got.PDDOrderNo, "BACK-") {
|
||||
t.Fatalf("backfill winner: %+v %v", out, manualErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentLateResultAndOtherTask(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: a.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusFailed, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(a.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&attempt, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var out OrderBackfillResponse
|
||||
var backErr, lateErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(a.ID, "BACK")}}, f.token)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, lateErr = s.SubmitResult(context.Background(), a.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "LATE", OrderSubmittedAt: a.IrreversibleAt}, f.token)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if backErr != nil || out.Items[0].Code != "BACKFILLED" || code(lateErr) != CodeStateConflict {
|
||||
t.Fatalf("late race: %+v %v %v", out, backErr, lateErr)
|
||||
}
|
||||
var savedAttempt models.PurchaseTaskAttempt
|
||||
if err := db.First(&savedAttempt, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(savedAttempt, attempt) {
|
||||
t.Fatal("backfill rewrote attempt")
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
_, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "BACK", OrderSubmittedAt: b.IrreversibleAt})
|
||||
if err == nil {
|
||||
t.Fatal("manual path assigned another task's order")
|
||||
}
|
||||
if got := loadBackfillTask(t, db, b.ID); got.PDDOrderNo != nil || got.StatusVersion != b.StatusVersion {
|
||||
t.Fatal("other task changed on conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillHTTPBoundary(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/order-backfill", device.RequireAgentHTTPS(false, false), (Handler{DB: db}).BackfillOrders)
|
||||
body, _ := json.Marshal(OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "HTTP")}})
|
||||
for _, test := range []struct {
|
||||
body, token string
|
||||
status int
|
||||
}{
|
||||
{string(body), "", http.StatusUnauthorized},
|
||||
{`{"requestId":"bad","items":[]}`, f.token, http.StatusUnprocessableEntity},
|
||||
{`{"requestId":"x","address":"forbidden"}`, f.token, http.StatusUnprocessableEntity},
|
||||
{string(body), f.token, http.StatusOK},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(test.body))
|
||||
req.Header.Set("Authorization", "Bearer "+test.token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != test.status {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
_, err := testService(db).BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: make([]OrderBackfillItem, 51)}, f.token)
|
||||
if code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("batch limit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
results := make(chan OrderBackfillResponse, 2)
|
||||
errors := make(chan error, 2)
|
||||
for _, id := range []uint64{a.ID, b.ID} {
|
||||
go func(id uint64) {
|
||||
<-start
|
||||
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(id, "SAME")}}, f.token)
|
||||
results <- out
|
||||
errors <- err
|
||||
}(id)
|
||||
}
|
||||
close(start)
|
||||
codes := make(map[string]int)
|
||||
for i := 0; i < 2; i++ {
|
||||
out := <-results
|
||||
if err := <-errors; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes[out.Items[0].Code]++
|
||||
}
|
||||
if codes["BACKFILLED"] != 1 || codes[CodeBackfillOrderUsed] != 1 {
|
||||
t.Fatalf("concurrent assignments: %+v", codes)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SAME").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("duplicate order: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillRejectsLateAssignmentOfSameOrder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
|
||||
t.Fatal(r)
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderSubmitStarted)
|
||||
lease := s.Now().Add(time.Minute)
|
||||
b.LeaseExpiresAt = &lease
|
||||
if err := db.Save(&b).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: b.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(b.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt}
|
||||
out, err := s.SubmitResult(context.Background(), b.ID, req, f.token)
|
||||
if err != nil || out.Status != models.PurchaseTaskStatusOrderResultUnknown {
|
||||
t.Fatalf("conflicting result must commit as unknown: %+v %v", out, err)
|
||||
}
|
||||
got := loadBackfillTask(t, db, b.ID)
|
||||
if got.Status != models.PurchaseTaskStatusOrderResultUnknown || got.StatusVersion != b.StatusVersion+1 || got.PDDOrderNo != nil {
|
||||
t.Fatalf("duplicate assignment was not safely downgraded: %+v", got)
|
||||
}
|
||||
wantMessage := "读到订单号 SHARED,但该号已属于任务 " + taskNumber(a.ID)
|
||||
if got.ErrorCode == nil || *got.ErrorCode != CodeOrderNumberUsed || got.ErrorMessage == nil || *got.ErrorMessage != wantMessage {
|
||||
t.Fatalf("conflict evidence missing: %+v", got)
|
||||
}
|
||||
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || got.IrreversibleAt == nil || got.LeaseExpiresAt != nil || got.DeviceRunSlot != nil || got.AccountRunSlot != nil {
|
||||
t.Fatalf("boundary evidence or released lease missing: %+v", got)
|
||||
}
|
||||
var saved models.PurchaseTaskAttempt
|
||||
if err := db.First(&saved, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != models.PurchaseAttemptStatusFailed || saved.ResultRequestID == nil || *saved.ResultRequestID != req.RequestID || saved.ResultHash == nil || saved.ResultType == nil || *saved.ResultType != "order_created" || saved.FinishedAt == nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderNumberUsed || saved.ErrorMessage == nil || *saved.ErrorMessage != wantMessage {
|
||||
t.Fatalf("attempt result and conflict evidence missing: %+v", saved)
|
||||
}
|
||||
out, err = s.SubmitResult(context.Background(), b.ID, req, f.token)
|
||||
if err != nil || !out.Replayed || out.Status != models.PurchaseTaskStatusOrderResultUnknown || loadBackfillTask(t, db, b.ID).StatusVersion != got.StatusVersion {
|
||||
t.Fatalf("unknown result replay failed: %+v %v", out, err)
|
||||
}
|
||||
owner := loadBackfillTask(t, db, a.ID)
|
||||
if owner.PDDOrderNo == nil || *owner.PDDOrderNo != "SHARED" || owner.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("existing owner changed: %+v", owner)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SHARED").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("duplicate order: %d %v", count, err)
|
||||
}
|
||||
resolved, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "CORRECTED", OrderSubmittedAt: req.OrderSubmittedAt})
|
||||
if err != nil || resolved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("manual resolution unavailable: %+v %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillHTTPTransportPolicy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, allow := range []string{"false", "true"} {
|
||||
t.Setenv("GOAUTO_ALLOW_INSECURE_AGENT_HTTP", allow)
|
||||
r := gin.New()
|
||||
r.POST("/order-backfill", device.RequireAgentHTTPS(true, false), (Handler{}).BackfillOrders)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(`{}`)))
|
||||
if allow == "false" && w.Code != http.StatusUpgradeRequired {
|
||||
t.Fatalf("HTTPS bypass: %d", w.Code)
|
||||
}
|
||||
if allow == "true" && w.Code == http.StatusUpgradeRequired {
|
||||
t.Fatal("HTTP compatibility broken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillMultiConnectionResolveRace(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(4)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var back OrderBackfillResponse
|
||||
var backErr, manualErr error
|
||||
req := OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK")}}
|
||||
go func() { defer wg.Done(); <-start; back, backErr = s.BackfillOrders(context.Background(), req, f.token) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL", OrderSubmittedAt: task.IrreversibleAt})
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
// SQLite returns table-lock errors rather than waiting on FOR UPDATE.
|
||||
// Only that documented DB contention or a domain conflict is acceptable;
|
||||
// after the competing calls finish, replay must converge without overwrite.
|
||||
if backErr != nil && !strings.Contains(backErr.Error(), "locked") {
|
||||
t.Fatal(backErr)
|
||||
}
|
||||
if manualErr != nil && code(manualErr) != CodeStateConflict && !strings.Contains(manualErr.Error(), "locked") {
|
||||
t.Fatal(manualErr)
|
||||
}
|
||||
if backErr == nil && back.Items[0].Code != "BACKFILLED" && back.Items[0].Code != CodeBackfillOrderConflict && !(back.Items[0].Code == CodeInternal && back.Items[0].Retryable) {
|
||||
t.Fatalf("unexpected race result: %+v", back)
|
||||
}
|
||||
before := loadBackfillTask(t, db, task.ID)
|
||||
replay := runBackfill(t, s, f.token, req.RequestID, req.Items...)[0]
|
||||
after := loadBackfillTask(t, db, task.ID)
|
||||
if before.PDDOrderNo != nil && !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("replay overwrote the concurrent winner")
|
||||
}
|
||||
if after.StatusVersion != task.StatusVersion+1 || after.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatal("race did not converge to a single transition")
|
||||
}
|
||||
if manualErr == nil {
|
||||
if *after.PDDOrderNo != "MANUAL" || replay.Code != CodeBackfillOrderConflict {
|
||||
t.Fatal("manual winner overwritten")
|
||||
}
|
||||
} else if *after.PDDOrderNo != "BACK" || (replay.Code != "BACKFILLED" && replay.Code != "ALREADY_BACKFILLED") {
|
||||
t.Fatalf("backfill did not converge: %+v", replay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderNumberConflictBusinessErrors(t *testing.T) {
|
||||
for _, path := range []string{"resolve_unknown", "cancel", "lifecycle"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
owner := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(owner.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
|
||||
t.Fatal(r)
|
||||
}
|
||||
status := models.PurchaseTaskStatusOrderResultUnknown
|
||||
if path == "lifecycle" {
|
||||
status = models.PurchaseTaskStatusRunning
|
||||
}
|
||||
task := backfillTask(t, db, f, status)
|
||||
if path != "resolve_unknown" {
|
||||
// Model legacy duplicate data predating the global save guard.
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"pdd_order_no": "SHARED", "lease_expires_at": s.Now().Add(time.Minute)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "SHARED", OrderSubmittedAt: task.IrreversibleAt, Reason: "人工取消"}
|
||||
var err error
|
||||
switch path {
|
||||
case "resolve_unknown":
|
||||
_, _, err = s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
case "cancel":
|
||||
_, _, err = s.Cancel(context.Background(), task.ID, req)
|
||||
case "lifecycle":
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(task.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if e := db.Omit("Task").Create(&attempt).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: req.RequestID}, f.token)
|
||||
}
|
||||
want := "订单号已属于任务 " + taskNumber(owner.ID)
|
||||
if code(err) != CodeOrderNumberUsed || err.Error() != want {
|
||||
t.Fatalf("unmapped conflict: %v", err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
writeError(c, err)
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
if e := json.Unmarshal(w.Body.Bytes(), &body); e != nil || w.Code != http.StatusConflict || body.Code != CodeOrderNumberUsed || body.Message != want || body.Retryable {
|
||||
t.Fatalf("unexpected HTTP error: %d %s (%v)", w.Code, w.Body.String(), e)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.Status != status || got.StatusVersion != task.StatusVersion || got.UnknownResolveRequestID != nil || got.CancelRequestID != nil || got.OrderSubmitRequestID != nil {
|
||||
t.Fatalf("rejected mutation persisted: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
agent := engine.Group("/api/agent/v1/purchase-tasks").Use(device.RequireAgentHTTPS(config.ApplicationConfig.Mode == "prod", trust))
|
||||
agent.GET("", h.AgentHistory)
|
||||
agent.GET("/next", h.Next)
|
||||
agent.POST("/order-backfill", h.BackfillOrders)
|
||||
agent.GET("/:taskId", h.AgentHistoryDetail)
|
||||
agent.POST("/:taskId/retry", h.AgentRetry)
|
||||
agent.POST("/:taskId/reset", h.AgentReset)
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
CodeRetryStale = "PURCHASE_RETRY_STALE"
|
||||
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
|
||||
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
|
||||
CodeOrderNumberUsed = "PURCHASE_ORDER_NUMBER_ALREADY_USED"
|
||||
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
|
||||
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
|
||||
CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package purchasecontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddressSuffix(t *testing.T) {
|
||||
for _, id := range []uint64{7, 72, ^uint64(0)} {
|
||||
got, err := ParseAddressSuffix(AddressSuffix(id))
|
||||
if err != nil || got != id {
|
||||
t.Fatalf("id=%d got=%d err=%v", id, got, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "_cg", "_cg0", "_cg00", "_cg07", "_cg+7", "_cg-7", "_cg18446744073709551616", "_CG7", "_cg7x", "_cg7_cg72", "address_cg7", " _cg7", "_cg7 ", "_cg7", "_cg7\n"} {
|
||||
if id, err := ParseAddressSuffix(raw); err == nil || id != 0 {
|
||||
t.Errorf("accepted %q: %d", raw, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -360,6 +361,18 @@ func RequiredCapabilities(rule RuleSnapshot) []string {
|
||||
|
||||
func AddressSuffix(taskID uint64) string { return fmt.Sprintf("_cg%d", taskID) }
|
||||
|
||||
// ParseAddressSuffix accepts only the exact canonical suffix, never an address.
|
||||
func ParseAddressSuffix(suffix string) (uint64, error) {
|
||||
if !strings.HasPrefix(suffix, "_cg") {
|
||||
return 0, errors.New("invalid address suffix")
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(suffix, "_cg"), 10, 64)
|
||||
if err != nil || id == 0 || AddressSuffix(id) != suffix {
|
||||
return 0, errors.New("invalid address suffix")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func ensureEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
|
||||
@@ -3,6 +3,8 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-admin/common/clientprincipal"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -113,8 +115,10 @@ func isImportAlreadyRunning(err error) bool {
|
||||
// `[必须]` This remains the authenticated manual entry point. Both it and the
|
||||
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
|
||||
func (handler Handler) Import(c *gin.Context) {
|
||||
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
|
||||
role := claimString(jwt.ExtractClaims(c)["rolekey"])
|
||||
client, clientOK := clientprincipal.Get(c)
|
||||
if role != "admin" && role != "purchaser" && !clientOK {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
||||
return
|
||||
}
|
||||
var request ImportRequest
|
||||
@@ -127,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
|
||||
actor := ImportActor{
|
||||
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
|
||||
}, false)
|
||||
}
|
||||
if clientOK {
|
||||
actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)}
|
||||
}
|
||||
result, err := StartImport(c.Request.Context(), service.DB, request, actor, false)
|
||||
if err != nil {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
@@ -163,6 +171,9 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
status := SyncRunSucceeded
|
||||
if err != nil {
|
||||
status = SyncRunFailed
|
||||
if report.Created+report.Updated > 0 {
|
||||
status = SyncRunPartial
|
||||
}
|
||||
}
|
||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer finishCancel()
|
||||
|
||||
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRequiresAdminRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
|
||||
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
func TestImportRoleBoundary(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
// Authorized roles reach JSON validation; no DB or external SYB call is made.
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
want := http.StatusForbidden
|
||||
if role == "admin" || role == "purchaser" {
|
||||
want = http.StatusUnprocessableEntity
|
||||
}
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,44 +4,9 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freezePaginationToday(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = previous })
|
||||
}
|
||||
|
||||
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
|
||||
f.pageIDs = func(_ string, start, call int) []int64 {
|
||||
if call == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
if call > 2 {
|
||||
return []int64{int64(2000 + start), int64(2001 + start)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
|
||||
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
|
||||
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
|
||||
t.Fatalf("did not restart at first page: %s", got)
|
||||
}
|
||||
for index, row := range rows {
|
||||
if row.ID != int64(2000+index) {
|
||||
t.Fatal("rows leaked from abandoned attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
|
||||
f.pageIDs = func(date string, start, _ int) []int64 {
|
||||
if date == "2026-08-29" && start == 2 {
|
||||
@@ -54,7 +19,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
@@ -62,7 +27,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
@@ -72,7 +37,6 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
@@ -92,7 +56,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
|
||||
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
|
||||
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/reparse-batch", handler.ReparseBatch)
|
||||
admin.POST("/import", handler.Import)
|
||||
// Run history is intentionally authenticated but not Casbin-gated: #50
|
||||
// makes it read-only for every role, while Import enforces admin itself.
|
||||
// makes it readable for every role; Import also requires admin/purchaser.
|
||||
readonly := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc())
|
||||
readonly.GET("/sync-runs", handler.ListSyncRuns)
|
||||
readonly.GET("/sync-runs/:runId", handler.SyncRunDetail)
|
||||
|
||||
@@ -41,8 +41,7 @@ const (
|
||||
// maxSyncDays bounds one request's window. It is a guard against a typo in
|
||||
// the date range turning into tens of thousands of remote reads before
|
||||
// MaxMatches trips.
|
||||
maxSyncDays = 31
|
||||
maxTodaySnapshotAttempts = 3
|
||||
maxSyncDays = 31
|
||||
)
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
@@ -82,8 +81,6 @@ type SyncProgress struct {
|
||||
DaysProcessed int
|
||||
}
|
||||
|
||||
var syncNow = time.Now
|
||||
|
||||
type ProgressFunc func(SyncProgress) error
|
||||
|
||||
type snapshotDriftError struct {
|
||||
@@ -94,14 +91,6 @@ type snapshotDriftError struct {
|
||||
|
||||
func (err *snapshotDriftError) Error() string { return err.message }
|
||||
|
||||
func shanghaiToday() string {
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
return syncNow().In(location).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
|
||||
// line into the SYB/Shopee archive through ApplyDetail.
|
||||
//
|
||||
@@ -109,16 +98,10 @@ func shanghaiToday() string {
|
||||
// a separate concern (see Connect); a session that dies mid-run surfaces as
|
||||
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
|
||||
//
|
||||
// `[必须]` Failure stops the run immediately. Rows already written are NOT
|
||||
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
|
||||
// re-run overwrites them rather than duplicating. What must not happen is
|
||||
// reporting a partial run as a complete one, which would let the missing
|
||||
// orders go unnoticed forever.
|
||||
//
|
||||
// `[必须]` Every day is verified for completeness before anything is written:
|
||||
// the per-day total is re-read after paging and must not have drifted. SYB's
|
||||
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
|
||||
// live data), so the paging loop is driven by listTotal, never by list.total.
|
||||
// Each validated page is committed independently. Date-local read failures
|
||||
// preserve committed pages and allow later dates to run. Database, cancellation
|
||||
// and progress persistence failures stop the run. Completeness is verified at
|
||||
// the end of each date; partial results must never be reported as full success.
|
||||
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
|
||||
return SyncWithProgress(ctx, db, client, cfg, dateFrom, dateTo, nil)
|
||||
}
|
||||
@@ -190,106 +173,38 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
plans = append(plans, dayPlan{date: date, total: total})
|
||||
}
|
||||
|
||||
for dayIndex, plan := range plans {
|
||||
if plan.total == 0 {
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
return report, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// `[必须]` Completeness first, filtering second. loadDailyList proves the
|
||||
// day's snapshot is whole; filtering before that would let drift among
|
||||
// other shops' orders hide a hole in the ones we do want
|
||||
// (docs/12-syb-erp-interface.md §8).
|
||||
rows, listErr := loadDailyListWithRecovery(ctx, client, plan.date, pageSize, plan.total, maxMatches)
|
||||
if listErr != nil && len(rows) == 0 {
|
||||
return report, listErr
|
||||
}
|
||||
report.OrderCount += len(rows)
|
||||
|
||||
byID := make(map[int64]sybclient.StockRow, len(rows))
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
label := name
|
||||
if sybshop.IsBlank(label) {
|
||||
label = "(无店铺名)"
|
||||
}
|
||||
display, ok := allowed[sybshop.Normalize(name)]
|
||||
if ok {
|
||||
label = display
|
||||
}
|
||||
if !ok {
|
||||
entry := report.ShopBreakdown[label]
|
||||
entry.Skipped++
|
||||
report.ShopBreakdown[label] = entry
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
entry := report.ShopBreakdown[label]
|
||||
entry.Accepted++
|
||||
report.ShopBreakdown[label] = entry
|
||||
report.AcceptedCount++
|
||||
byID[row.ID] = row
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
return report, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[start:end]
|
||||
details, err := client.DetailListByStock(ctx, batch)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
|
||||
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
|
||||
}
|
||||
if err := validateDetailBatch(batch, details); err != nil {
|
||||
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
|
||||
}
|
||||
for _, detail := range details {
|
||||
// `[必须]` Re-check the shop on the detail response. The list said
|
||||
// this order belongs to an enabled shop; if the detail disagrees,
|
||||
// the two views are inconsistent and importing it would write a
|
||||
// row for a shop nobody enabled.
|
||||
name := stringField(detail.Raw, "shopName")
|
||||
if sybshop.IsBlank(name) {
|
||||
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), "(无店铺名)", allowed)
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
|
||||
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
if err := emit(dayIndex); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
if listErr != nil {
|
||||
return report, listErr
|
||||
}
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
daysCompleted := 0
|
||||
var failures []error
|
||||
for _, plan := range plans {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
|
||||
report.OrderCount += len(rows)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
|
||||
}
|
||||
if err := emit(daysCompleted); err != nil {
|
||||
return &syncFatalError{err}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if dayErr != nil {
|
||||
failures = append(failures, dayErr)
|
||||
var fatal *syncFatalError
|
||||
if ctx.Err() != nil || errors.As(dayErr, &fatal) || errors.Is(dayErr, sybclient.ErrSessionInvalid) {
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
daysCompleted++
|
||||
if err := emit(daysCompleted); err != nil {
|
||||
return report, errors.Join(append(failures, err)...)
|
||||
}
|
||||
}
|
||||
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report, nil
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
|
||||
func shopSnapshotNames(allowed map[string]string) []string {
|
||||
@@ -336,39 +251,7 @@ func moveAcceptedToSkipped(report *SyncReport, listName, detailName string, allo
|
||||
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
|
||||
// list.total is the current page's row count, not the filtered total (§4.3).
|
||||
// Driving the loop with the response's own total would stop after page one.
|
||||
func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal, maxMatches int) ([]sybclient.StockRow, error) {
|
||||
if date != shanghaiToday() {
|
||||
return loadDailyList(ctx, client, date, pageSize, expectedTotal)
|
||||
}
|
||||
var last *snapshotDriftError
|
||||
for attempt := 1; attempt <= maxTodaySnapshotAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
total, err := client.ListTotal(ctx, date, date, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("重新查询今天 %s 货运单总数失败: %w", date, err)
|
||||
}
|
||||
if total > maxMatches {
|
||||
return nil, fmt.Errorf("今天 %s 的货运单总数 %d 超过单次同步上限 %d", date, total, maxMatches)
|
||||
}
|
||||
expectedTotal = total
|
||||
}
|
||||
rows, err := loadDailyList(ctx, client, date, pageSize, expectedTotal)
|
||||
if err == nil {
|
||||
return rows, nil
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
if last != nil && last.valid && len(last.rows) > 0 {
|
||||
return last.rows, fmt.Errorf("今天持续变化,已保存本次取得的完整明细,但未形成稳定快照;下次同步继续覆盖: %w", last)
|
||||
}
|
||||
return nil, fmt.Errorf("今天持续变化,连续 %d 次未形成稳定快照,本次同步停止: %w", maxTodaySnapshotAttempts, last)
|
||||
}
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
@@ -413,6 +296,11 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if len(page) != expectedPageCount {
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表第 %d 页不完整且相对初始总数发生变化:预期 %d 行,实际 %d 行", date, pageIndex, expectedPageCount, len(page)), rows: rows, valid: len(page) > expectedPageCount}
|
||||
}
|
||||
if len(consume) > 0 {
|
||||
if err := consume[0](page, pageIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read the total: if it moved while we paged, some order was inserted or
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Database/progress errors stop the entire run, not merely the current date.
|
||||
type syncFatalError struct{ error }
|
||||
|
||||
func (e *syncFatalError) Unwrap() error { return e.error }
|
||||
|
||||
// Read all remote details before opening a page transaction. Publish counters
|
||||
// only after commit, so a rollback never reports records as saved.
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport) error {
|
||||
next := *report
|
||||
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
|
||||
for k, v := range report.ShopBreakdown {
|
||||
next.ShopBreakdown[k] = v
|
||||
}
|
||||
byID := make(map[int64]sybclient.StockRow)
|
||||
var ids []int64
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
label := name
|
||||
if sybshop.IsBlank(label) {
|
||||
label = "(无店铺名)"
|
||||
}
|
||||
display, ok := allowed[sybshop.Normalize(name)]
|
||||
if ok {
|
||||
label = display
|
||||
}
|
||||
entry := next.ShopBreakdown[label]
|
||||
if ok {
|
||||
entry.Accepted++
|
||||
next.AcceptedCount++
|
||||
ids = append(ids, row.ID)
|
||||
byID[row.ID] = row
|
||||
} else {
|
||||
entry.Skipped++
|
||||
next.ShopSkipped++
|
||||
}
|
||||
next.ShopBreakdown[label] = entry
|
||||
}
|
||||
var details []sybclient.StockDetail
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch, err := client.DetailListByStock(ctx, ids[start:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDetailBatch(ids[start:end], batch); err != nil {
|
||||
return err
|
||||
}
|
||||
details = append(details, batch...)
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, detail := range details {
|
||||
name := stringField(detail.Raw, "shopName")
|
||||
_, ok := allowed[sybshop.Normalize(name)]
|
||||
if sybshop.IsBlank(name) || !ok {
|
||||
moveAcceptedToSkipped(&next, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
|
||||
next.AcceptedCount--
|
||||
next.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return &syncFatalError{err}
|
||||
}
|
||||
*report = next
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go-admin/app/goauto/models"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageFailurePreservesEarlierPagesContinuesDatesAndRerunsIdempotently(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, shortPageAtIndex: 2}
|
||||
var last SyncProgress
|
||||
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error { last = p; return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "第 2 页") || report.Created != 2 || report.Updated != 1 || last.DaysProcessed != 1 {
|
||||
t.Fatalf("report=%+v progress=%+v err=%v", report, last, err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
f.shortPageAtIndex = 0
|
||||
report, err = Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
if err != nil || report.Created != 1 || report.Updated != 3 {
|
||||
t.Fatalf("rerun=%+v err=%v", report, err)
|
||||
}
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 3 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTransactionRollbackDoesNotPublishCountersAndStopsDates(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Exec("CREATE TRIGGER reject_second BEFORE INSERT ON syb_product WHEN NEW.detail_id = 10011 BEGIN SELECT RAISE(ABORT, 'test database failure'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
var fatal *syncFatalError
|
||||
if !errors.As(err, &fatal) || report.Created != 0 || report.DetailCount != 0 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v calls=%v err=%v", report, f.listCalls, err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageProgressFailureStopsAfterCommittedPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
calls := 0
|
||||
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(SyncProgress) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("progress unavailable")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil || report.Created != 2 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMissingDetailPreservesPriorPageAndContinuesNextDate(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, detailDropID: 1002}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
if err == nil || report.Created != 2 || report.Updated != 1 || report.OrderCount != 4 || len(f.listCalls) != 3 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCancellationPreservesCommitAndStopsNextPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
report, err := SyncWithProgress(ctx, db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error {
|
||||
if p.Report.Created > 0 {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || report.Created != 2 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFinishPreservesProgressReleasesSlotAndIsFilterable(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
s := NewSyncRunService(db)
|
||||
run, err := s.Create(ctx, CreateSyncRunInput{DateFrom: "2026-08-01", DateTo: "2026-08-02"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := SyncReport{Created: 2, DetailCount: 2, ShopBreakdown: map[string]ShopBreakdown{}}
|
||||
if err := s.UpdateProgress(ctx, run.ID, SyncProgress{Report: report, DaysTotal: 2, DaysProcessed: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Finish(ctx, run.ID, SyncRunPartial, report, errors.New("第 2 页失败")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := s.Detail(ctx, run.ID)
|
||||
if err != nil || row.Status != SyncRunPartial || row.ActiveSlot != nil || row.ProgressPercent != 50 || row.Created != 2 {
|
||||
t.Fatalf("row=%+v err=%v", row, err)
|
||||
}
|
||||
result, err := s.List(ctx, SyncRunListRequest{Page: 1, PageSize: 20, Status: SyncRunPartial})
|
||||
if err != nil || result.Total != 1 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
const (
|
||||
SyncRunRunning = "running"
|
||||
SyncRunSucceeded = "succeeded"
|
||||
SyncRunPartial = "partial_success"
|
||||
SyncRunFailed = "failed"
|
||||
SyncRunInterrupted = "interrupted"
|
||||
)
|
||||
@@ -75,7 +76,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
}
|
||||
|
||||
func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, report SyncReport, runErr error) error {
|
||||
if status != SyncRunSucceeded && status != SyncRunFailed {
|
||||
if status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed {
|
||||
return fmt.Errorf("invalid terminal sync status %q", status)
|
||||
}
|
||||
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
|
||||
|
||||
@@ -20,7 +20,7 @@ func (handler Handler) ListSyncRuns(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
writeError(c, invalidRequest("status 无效"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,4 +36,12 @@ func TestSyncRunListAndDetailHandlers(t *testing.T) {
|
||||
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"operatorName":"管理员"`) {
|
||||
t.Fatalf("详情响应不正确: %d %s", detail.Code, detail.Body.String())
|
||||
}
|
||||
if err := NewSyncRunService(db).Finish(context.Background(), run.ID, SyncRunPartial, SyncReport{Created: 1}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial := httptest.NewRecorder()
|
||||
engine.ServeHTTP(partial, httptest.NewRequest(http.MethodGet, "/sync-runs?status=partial_success", nil))
|
||||
if partial.Code != http.StatusOK || !strings.Contains(partial.Body.String(), `"total":1`) {
|
||||
t.Fatalf("partial filter: %d %s", partial.Code, partial.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -276,39 +275,6 @@ func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodaySnapshotDriftRetriesFromFirstPage(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 10}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
|
||||
if err != nil || len(rows) != 10 || f.listTotalCalls != 3 {
|
||||
t.Fatalf("rows=%d totalCalls=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayThirdDriftReturnsValidLastListForDegradedSave(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 11, 4: 10, 5: 11}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
|
||||
if err == nil || len(rows) != 10 || !strings.Contains(err.Error(), "已保存本次取得的完整明细") {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalSnapshotDriftDoesNotRetry(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 10}, totalOverride: map[int]int{1: 11}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-28", 10, 10, 1000)
|
||||
if err == nil || rows != nil || f.listTotalCalls != 1 {
|
||||
t.Fatalf("rows=%v totalCalls=%d err=%v", rows, f.listTotalCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 某页行数少于预期同样是不完整快照。
|
||||
func TestSyncStopsOnShortPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
|
||||
@@ -3,6 +3,7 @@ package sybinnercode
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go-admin/common/clientprincipal"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -81,13 +82,19 @@ func (h Handler) Import(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId"))
|
||||
result, err := service.Import(c.Request.Context(), src, file.Filename, clientOperator(c), c.PostForm("requestId"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
func clientOperator(c *gin.Context) uint64 {
|
||||
if p, ok := clientprincipal.Get(c); ok {
|
||||
return p.AuthorizedBy
|
||||
}
|
||||
return uint64(user.GetUserId(c))
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
var request DeleteRequest
|
||||
if err := decode(c, &request); err != nil {
|
||||
@@ -98,7 +105,7 @@ func (h Handler) Delete(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request)
|
||||
result, err := service.Delete(c.Request.Context(), clientOperator(c), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -150,7 +157,7 @@ func (h Handler) Apply(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request)
|
||||
result, err := service.QueueApply(c.Request.Context(), clientOperator(c), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -38,11 +38,11 @@ func TestEnsurePurchaserRoleAndPolicies(t *testing.T) {
|
||||
if count != int64(len(access.PurchaserAPIs())) {
|
||||
t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs()))
|
||||
}
|
||||
var forbidden int64
|
||||
var allowed int64
|
||||
db.Model(&purchaserCasbinRule{}).
|
||||
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST").
|
||||
Count(&forbidden)
|
||||
if forbidden != 0 {
|
||||
t.Fatal("manual SYB import must remain administrator-only")
|
||||
Count(&allowed)
|
||||
if allowed != 1 {
|
||||
t.Fatal("manual SYB import must follow the current purchaser permission matrix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateClientAPIKey)
|
||||
}
|
||||
func migrateClientAPIKey(db *gorm.DB, version string) error {
|
||||
if err := db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var parent migrationmodels.SysMenu
|
||||
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{MenuName: "GoAutoClientKeys", Title: "客户端密钥", Icon: "lock", Path: "/client-keys/index", MenuType: "C", Action: "无", ParentId: parent.MenuId, Component: "/goauto/client-keys/index", Sort: 6, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var admin migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", "admin").First(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&admin).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/clientkey"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientKeyMigrationOnlyAdminMenu(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
defer sql.Close()
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := migrationmodels.SysRole{RoleKey: "admin"}
|
||||
purchaser := migrationmodels.SysRole{RoleKey: "purchaser"}
|
||||
parent := migrationmodels.SysMenu{MenuName: "GoAutoCollectionManagement"}
|
||||
for _, v := range []any{&admin, &purchaser, &parent} {
|
||||
if err = db.Create(v).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateClientAPIKey(db, "client-key-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&clientkey.Key{}) || !db.Migrator().HasTable(&clientkey.Audit{}) {
|
||||
t.Fatal("missing tables")
|
||||
}
|
||||
var child migrationmodels.SysMenu
|
||||
if err = db.Where("menu_name = ?", "GoAutoClientKeys").First(&child).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if child.ParentId != parent.MenuId {
|
||||
t.Fatal("wrong menu group")
|
||||
}
|
||||
assertRoleHasMenu(t, db, admin.RoleId, child.MenuId, true)
|
||||
assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, false)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package clientprincipal carries an authenticated client identity, never an
|
||||
// administrator or purchaser JWT. Only the client gateway sets it.
|
||||
package clientprincipal
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const contextKey = "goauto.authenticatedClient"
|
||||
|
||||
type Identity struct {
|
||||
KeyID uint64
|
||||
RequestID string
|
||||
AuthorizedBy uint64
|
||||
}
|
||||
|
||||
func Set(c *gin.Context, id Identity) { c.Set(contextKey, id) }
|
||||
func Get(c *gin.Context) (Identity, bool) {
|
||||
v, ok := c.Get(contextKey)
|
||||
id, valid := v.(Identity)
|
||||
return id, ok && valid && id.KeyID > 0
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import (
|
||||
// LoggerToFile 日志记录到文件
|
||||
func LoggerToFile() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// #237: client request/response bodies and one-time credentials must never
|
||||
// enter the legacy operation logger. The client gateway keeps metadata-only audit.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
log := api.GetRequestLogger(c)
|
||||
// 开始时间
|
||||
startTime := time.Now()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
const base = '/api/admin/v1/client-keys'
|
||||
export const listClientKeys = page => request({ url: base, method: 'get', params: { page }, suppressErrorMessage: true })
|
||||
export const clientKeyModules = () => request({ url: `${base}/modules`, method: 'get', suppressErrorMessage: true })
|
||||
export const createClientKey = data => request({ url: base, method: 'post', data, suppressErrorMessage: true })
|
||||
export const editClientKey = (id, data) => request({ url: `${base}/${id}/grants`, method: 'patch', data, suppressErrorMessage: true })
|
||||
export const disableClientKey = (id, version) => request({ url: `${base}/${id}/disable`, method: 'post', data: { version }, suppressErrorMessage: true })
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card shadow="never">
|
||||
<el-alert v-if="!isAdmin" title="只有管理员可以管理客户端密钥" type="warning" :closable="false" />
|
||||
<template v-else>
|
||||
<div class="heading"><div><h2>客户端密钥</h2><p>按菜单模块开放客户端访问。完整密钥仅在创建成功后显示一次。</p></div><el-button type="primary" :disabled="loading || !!loadError" @click="openEditor()">创建密钥</el-button></div>
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false"><el-button @click="load">重新加载</el-button></el-alert>
|
||||
<el-table v-loading="loading" :data="items" row-key="id" border empty-text="还没有客户端密钥,点击右上角创建">
|
||||
<el-table-column label="名称 / 标识" min-width="210"><template #default="{ row }"><strong>{{ row.name }}</strong><p class="muted">{{ row.prefix }}••••</p></template></el-table-column>
|
||||
<el-table-column label="授权模块" min-width="250"><template #default="{ row }"><div v-for="g in row.grants" :key="g.module">{{ moduleName(g.module) }} · {{ g.write ? '读写' : '只读' }}<span v-if="g.actions.length"> / {{ g.actions.map(actionName).join('、') }}</span></div></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用中' : '已停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最后使用" min-width="170"><template #default="{ row }">{{ row.lastUsedAt ? formatTime(row.lastUsedAt) : '尚未使用' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="255"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row, true)">查看授权</el-button><el-button link type="primary" :disabled="!row.enabled || saving" @click="openEditor(row)">编辑授权</el-button><el-button link type="danger" :disabled="!row.enabled || saving" @click="disable(row)">停用</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="total" v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next, total" @current-change="load" />
|
||||
<p class="muted">客户端地址:/api/client/v1。支持 HTTP / HTTPS;HTTP 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。</p>
|
||||
</template>
|
||||
</el-card>
|
||||
<el-dialog v-model="editorOpen" :title="readonly ? '查看授权' : editing ? '编辑授权' : '创建客户端密钥'" width="min(1080px, 95vw)" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" :before-close="closeEditor">
|
||||
<el-alert v-if="editError" :title="editError" type="error" :closable="false" class="notice" />
|
||||
<el-form label-position="top" @submit.prevent="save">
|
||||
<el-form-item label="名称" required><el-input v-model.trim="name" maxlength="80" :disabled="readonly || editing || saving" placeholder="例如:商品同步工具" /></el-form-item>
|
||||
<p v-if="editing" class="muted">标识:{{ selected.prefix }}•••• · 保存不会更换 API Key,完整密钥不可再次查看。</p>
|
||||
<el-alert title="新选模块默认只读;同步、采集、采购、回写、删除等动作需单独勾选。新增菜单不会自动授权。" type="info" :closable="false" class="notice" />
|
||||
<div class="groups">
|
||||
<section v-for="group in groups" :key="group.title" class="module-group">
|
||||
<el-checkbox :model-value="group.modules.every(m => !!grants[m.key])" :indeterminate="group.modules.some(m => !!grants[m.key]) && !group.modules.every(m => !!grants[m.key])" :disabled="readonly || saving" @change="value => selectGroup(group, value)">{{ group.title }}</el-checkbox>
|
||||
<div v-for="m in group.modules" :key="m.key" class="module-row">
|
||||
<div class="module-main"><el-checkbox :model-value="!!grants[m.key]" :disabled="readonly || saving" @change="value => selectModule(m.key, value)">{{ m.title }}</el-checkbox><el-radio-group v-if="grants[m.key]" v-model="grants[m.key].write" :disabled="readonly || saving" size="small" :aria-label="`${m.title}访问方式`"><el-radio-button :value="false">只读</el-radio-button><el-radio-button :value="true" :disabled="!m.writable">读写</el-radio-button></el-radio-group></div>
|
||||
<div v-if="grants[m.key] && m.actions.length" class="actions"><span class="muted">独立动作:</span><el-checkbox-group v-model="grants[m.key].actions" :disabled="readonly || saving"><el-checkbox v-for="action in m.actions" :key="action" :value="action">{{ actionName(action) }}</el-checkbox></el-checkbox-group></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-if="editing" class="muted">{{ selected.enabled ? '保存成功后,后续请求按新授权校验;已执行操作不回滚。' : '密钥已停用,只允许查看授权。' }} 最近修改:管理员 #{{ selected.updatedBy }} · {{ formatTime(selected.updatedAt) }}</p>
|
||||
<p v-if="!Object.keys(grants).length" class="validation" role="alert">至少选择一个模块;要禁止全部访问,请使用停用。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="saving" @click="closeEditor()">{{ readonly ? '关闭' : '取消' }}</el-button><el-button v-if="!readonly" type="primary" :loading="saving" :disabled="!valid || !changed || conflicted" @click="save">{{ editing ? '保存授权' : '创建密钥' }}</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="secretOpen" title="密钥已创建,请立即保存" width="min(650px, 95vw)" :close-on-click-modal="false" @closed="clearSecret">
|
||||
<el-alert title="完整密钥只显示这一次;关闭后不可再次查看。请勿放入 URL、日志或前端代码。" type="warning" :closable="false" />
|
||||
<pre class="secret">{{ secret }}</pre><el-button type="primary" @click="copySecret">复制密钥</el-button>
|
||||
<p>请求头:Authorization: Bearer <客户端密钥></p>
|
||||
<template #footer><el-button type="primary" @click="secretOpen = false">已保存,返回列表</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listClientKeys, clientKeyModules, createClientKey, editClientKey, disableClientKey } from '@/api/goauto/client-keys'
|
||||
|
||||
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则' }
|
||||
const snapshot = grants => JSON.stringify(Object.values(grants).map(g => ({ ...g, actions: [...g.actions].sort() })).sort((a, b) => a.module.localeCompare(b.module)))
|
||||
const errorText = error => error.response?.data?.message || '请求失败,请检查网络后重试'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoClientKeys',
|
||||
data() { return { active: true, items: [], modules: [], total: 0, page: 1, loading: false, loadError: '', saving: false, editorOpen: false, editing: false, readonly: false, selected: null, name: '', grants: {}, initial: '', editError: '', conflicted: false, secret: '', secretOpen: false } },
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
groups() { return [...new Set(this.modules.map(m => m.group))].map(title => ({ title, modules: this.modules.filter(m => m.group === title) })) },
|
||||
valid() { return !!this.name.trim() && Object.keys(this.grants).length > 0 },
|
||||
changed() { return !this.editing || snapshot(this.grants) !== this.initial }
|
||||
},
|
||||
mounted() { this.load() },
|
||||
activated() { this.active = true },
|
||||
deactivated() { this.active = false; this.clearSecret(); this.secretOpen = false },
|
||||
beforeUnmount() { this.active = false; this.clearSecret() },
|
||||
methods: {
|
||||
moduleName(key) { return this.modules.find(m => m.key === key)?.title || key },
|
||||
actionName(action) { return actionLabels[action] || action },
|
||||
formatTime(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||
async load() {
|
||||
if (!this.isAdmin || this.loading) return
|
||||
this.loading = true; this.loadError = ''
|
||||
try { const [rows, catalog] = await Promise.all([listClientKeys(this.page), clientKeyModules()]); this.items = rows.data.items; this.total = rows.data.total; this.modules = catalog.data } catch (error) { this.loadError = errorText(error) } finally { this.loading = false }
|
||||
},
|
||||
openEditor(row = null, readonly = false) {
|
||||
if (!this.isAdmin || (row && !row.enabled && !readonly)) return
|
||||
this.selected = row; this.editing = !!row; this.readonly = readonly; this.name = row?.name || ''; this.grants = {}
|
||||
for (const g of row?.grants || []) this.grants[g.module] = { ...g, actions: [...g.actions] }
|
||||
this.initial = snapshot(this.grants); this.editError = ''; this.conflicted = false; this.editorOpen = true
|
||||
},
|
||||
selectModule(key, value) { if (value) { if (!this.grants[key]) this.grants[key] = { module: key, write: false, actions: [] } } else delete this.grants[key] },
|
||||
selectGroup(group, value) { for (const m of group.modules) this.selectModule(m.key, value) },
|
||||
closeEditor(done) { if (this.saving) return; this.editorOpen = false; this.grants = {}; if (typeof done === 'function') done() },
|
||||
async save() {
|
||||
if (!this.valid || !this.changed || this.saving || this.readonly || this.conflicted) return
|
||||
this.saving = true; this.editError = ''
|
||||
try {
|
||||
if (this.editing) { await editClientKey(this.selected.id, { version: this.selected.version, grants: Object.values(this.grants) }); ElMessage.success('授权已更新,API Key 保持不变') } else { const result = await createClientKey({ name: this.name, grants: Object.values(this.grants) }); if (this.active) { this.secret = result.data.secret; this.secretOpen = true } }
|
||||
this.editorOpen = false; await this.load()
|
||||
} catch (error) { this.editError = errorText(error); if (!this.editing) this.editError += '。响应丢失时可能已创建,请先检查列表;丢失密钥需停用后重建。'; this.conflicted = error.response?.status === 409; if (this.conflicted) { this.editError += '。请取消并刷新列表后重新打开。'; await this.load() } } finally { this.saving = false }
|
||||
},
|
||||
async disable(row) {
|
||||
try { await ElMessageBox.confirm(`停用“${row.name}”后,新请求将被拒绝,已执行操作不回滚。首版不支持恢复。`, '停用客户端密钥', { confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning' }) } catch { return }
|
||||
this.saving = true
|
||||
try { await disableClientKey(row.id, row.version); ElMessage.success('密钥已停用'); await this.load() } catch (error) { ElMessage.error(errorText(error)); await this.load() } finally { this.saving = false }
|
||||
},
|
||||
async copySecret() { try { await navigator.clipboard.writeText(this.secret); ElMessage.success('已复制,请妥善保存') } catch { ElMessage.warning('复制失败,请手动选择并复制密钥') } },
|
||||
clearSecret() { this.secret = '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.muted, .heading p { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.module-group { padding: 16px; border: 1px solid var(--el-border-color); border-radius: 6px; min-width: 0; }
|
||||
.module-row { padding: 10px 0; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.module-main { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.actions { padding: 4px 0 0 22px; }
|
||||
.notice, .el-pagination { margin: 16px 0; }
|
||||
.validation { color: var(--el-color-danger); }
|
||||
.secret { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; background: var(--el-fill-color-light); }
|
||||
@media (max-width: 760px) { .groups { grid-template-columns: 1fr; } .heading { align-items: flex-start; } }
|
||||
</style>
|
||||
@@ -3,9 +3,9 @@
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员可以立即同步今天和昨天的数据。</p></div>
|
||||
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员和采购员可以立即同步今天和昨天的数据。</p></div>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
v-if="canStartSync"
|
||||
type="primary"
|
||||
:loading="manualSyncStarting"
|
||||
:disabled="manualSyncStarting || hasRunningSync"
|
||||
@@ -36,8 +36,9 @@
|
||||
<el-drawer v-model="detail.open" title="同步记录详情" size="760px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.item">
|
||||
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
|
||||
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted' || detail.item.status === 'partial_success'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
|
||||
<el-alert v-else-if="detail.item.status === 'running'" title="任务正在后台运行,关闭本页不会中断导入。" type="info" show-icon :closable="false" class="notice" />
|
||||
<el-alert v-if="detail.item.status === 'partial_success'" title="已保存的数据会保留。请重新同步该日期范围补齐缺失数据;重跑不会重复新增相同明细。" type="warning" show-icon :closable="false" class="notice" />
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="记录编号">{{ detail.item.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态"><el-tag :type="statusMeta(detail.item.status).type">{{ statusMeta(detail.item.status).label }}</el-tag></el-descriptions-item>
|
||||
@@ -81,18 +82,18 @@ export default {
|
||||
detail: { open: false, loading: false, item: null },
|
||||
statusOptions: [
|
||||
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
|
||||
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||
{ label: '部分成功', value: 'partial_success' }, { label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||
beforeUnmount() { this.stopPolling() },
|
||||
methods: {
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
@@ -109,7 +110,7 @@ export default {
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
|
||||
},
|
||||
async startManualSync() {
|
||||
if (this.manualSyncStarting || this.hasRunningSync) return
|
||||
if (!this.canStartSync || this.manualSyncStarting || this.hasRunningSync) return
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
|
||||
this.manualSyncStarting = true
|
||||
|
||||
@@ -4,6 +4,36 @@ async function authenticate(context: any) {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
|
||||
}
|
||||
|
||||
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
|
||||
|
||||
test('部分成功可筛选并查看已保存数量和补齐提示', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
const item = { id: 239, status: 'partial_success', dateFrom: '2026-08-01', dateTo: '2026-08-02', daysProcessed: 1, daysTotal: 2, progressPercent: 50, created: 2, updated: 1, orderCount: 3, detailCount: 3, acceptedCount: 3, shopSkipped: 0, errorMessage: '2026-08-01 第 2 页读取失败', shopBreakdown: [] };
|
||||
let selected = '';
|
||||
await page.route('**/api/**', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue();
|
||||
if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试用户', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/sync-runs/239')) return route.fulfill({ json: { code: 200, data: { item } } });
|
||||
if (url.pathname.endsWith('/sync-runs')) { selected = url.searchParams.get('status') || ''; return route.fulfill({ json: { code: 200, data: { items: [item], total: 1 } } }); }
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
await expect(page.locator('.el-table').getByText('部分成功')).toBeVisible();
|
||||
await page.locator('.search-form .el-select').click();
|
||||
await page.getByRole('option', { name: '部分成功' }).click();
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click();
|
||||
await expect.poll(() => selected).toBe('partial_success');
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click();
|
||||
await expect(page.getByText(item.errorMessage)).toBeVisible();
|
||||
await expect(page.getByText('已保存的数据会保留。', { exact: false })).toBeVisible();
|
||||
await expect(page.locator('.el-drawer').getByText('2 / 1', { exact: true })).toBeVisible();
|
||||
await expect.poll(async () => {
|
||||
const box = await page.locator('.el-drawer').boundingBox();
|
||||
return box ? Math.round(box.x + box.width) : 0;
|
||||
}).toBe(page.viewportSize()!.width);
|
||||
});
|
||||
|
||||
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
await page.route('**/api/**', async route => {
|
||||
@@ -12,12 +42,12 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs/51')) return route.fulfill({ json: { code: 200, data: { item: { id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, progressPercent: 66, orderCount: 12, detailCount: 8, acceptedCount: 10, shopSkipped: 2, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z', finishedAt: '2026-08-20T01:05:00Z', errorMessage: '第 3 天读取失败,等待下个调度周期重试', shopFilterHash: 'abc123', shopBreakdown: [{ shopName: '大碼臻選', accepted: 10, skipped: 0 }, { shopName: '未知店铺', accepted: 0, skipped: 2 }] } } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, orderCount: 12, detailCount: 8, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z' }], total: 1, page: 1, pageSize: 20 } } });
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '立即同步' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '立即同步' })).toBeVisible();
|
||||
await expect(page.getByText('2026-08-18 至 2026-08-20')).toBeVisible();
|
||||
await page.getByRole('button', { name: '详情' }).click();
|
||||
await expect(page.getByText('第 3 天读取失败,等待下个调度周期重试')).toBeVisible();
|
||||
@@ -26,14 +56,15 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
|
||||
await expect(page.getByText('系统定时同步', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员可立即同步今天和昨天,受理后按钮进入运行中状态', async ({ page, context }) => {
|
||||
for (const role of ['admin', 'purchaser']) {
|
||||
test(`${role} 可立即同步今天和昨天,受理后按钮进入运行中状态`, async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
let importBody: any = null;
|
||||
let importAccepted = false;
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue();
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/import') && route.request().method() === 'POST') {
|
||||
importBody = route.request().postDataJSON();
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
@@ -44,10 +75,10 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
|
||||
const items = importAccepted ? [{ id: 88, dateFrom: importBody.dateFrom, dateTo: importBody.dateTo, status: 'running', progressPercent: 0, daysProcessed: 0, daysTotal: 2, orderCount: 0, detailCount: 0, created: 0, updated: 0, operatorName: '管理员', startedAt: '2026-08-24T08:00:00Z' }] : [];
|
||||
return route.fulfill({ json: { code: 200, data: { items, total: items.length, page: 1, pageSize: 20 } } });
|
||||
}
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
const button = page.getByRole('button', { name: '立即同步' });
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
@@ -60,6 +91,37 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
|
||||
const formatDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
expect(importBody).toEqual({ dateFrom: formatDate(yesterday), dateTo: formatDate(now) });
|
||||
});
|
||||
}
|
||||
|
||||
for (const role of ['purchaser', 'viewer']) {
|
||||
test(`${role} 同步权限及失败恢复`, async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
let requests = 0;
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue();
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
|
||||
requests++;
|
||||
return route.fulfill({ status: 422, json: { code: 'INVALID_REQUEST', message: '已有同步正在执行,请稍后再试' } });
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 } } });
|
||||
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
|
||||
});
|
||||
await page.goto('/#/syb-sync-runs/index');
|
||||
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
|
||||
const button = page.getByRole('button', { name: '立即同步' });
|
||||
if (role === 'viewer') {
|
||||
await expect(button).toHaveCount(0);
|
||||
expect(requests).toBe(0);
|
||||
} else {
|
||||
await button.click();
|
||||
await expect(page.getByText('已有同步正在执行,请稍后再试')).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
expect(requests).toBe(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('SYB 定时任务可进入执行记录,其他任务不显示该入口', async ({ page, context }) => {
|
||||
await authenticate(context);
|
||||
@@ -73,7 +135,7 @@ test('SYB 定时任务可进入执行记录,其他任务不显示该入口', a
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/schedule/index');
|
||||
await page.goto('/#/schedule/index');
|
||||
await expect(page.getByText('SYB 每小时自动同步', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '执行记录', exact: true })).toHaveCount(1);
|
||||
await page.getByRole('button', { name: '执行记录', exact: true }).click();
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>#237 客户端密钥隔离测试</title></head><body><p>仅本地组件测试:全部请求由内存适配器处理,不连接业务服务,不创建真实密钥。</p><div id="app"></div><script type="module" src="./client-keys.js"></script></body></html>
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
// Manual browser fixture for the production component, not an offline prototype.
|
||||
import { createApp, h } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import Page from '../../src/views/goauto/client-keys/index.vue'
|
||||
import request from '../../src/utils/request'
|
||||
|
||||
const definitions = [
|
||||
['syb_products', 'SYB 商品', true, ['reparse']], ['syb_sync_runs', 'SYB 同步记录', false, ['sync']],
|
||||
['syb_inner_codes', '档口入库码', false, ['import', 'match', 'writeback', 'delete']], ['shopee_products', '虾皮商品', true, ['match', 'delete']],
|
||||
['pdd_products', 'PDD 商品', true, []], ['collection_tasks', '采集任务', false, ['collect', 'delete']], ['purchase_tasks', '采购管理', false, ['purchase']],
|
||||
['syb_shops', 'SYB 店铺', true, ['delete']], ['collection_rules', '采集规则', true, ['delete']], ['purchase_rules', '采购规则', true, ['delete', 'activate']], ['devices', '设备列表', false, []], ['ai_matching', 'AI 规格匹配', false, ['match']]
|
||||
]
|
||||
const modules = definitions.map(([key, title, writable, actions], index) => ({ key, title, writable, actions, group: index < 7 ? '采集采购' : '采采管理' }))
|
||||
let items = [{ id: 1, name: '商品同步工具(测试)', prefix: 'gak_DEMO', version: 1, enabled: true, updatedBy: 1, updatedAt: '2026-09-07T00:00:00Z', grants: [{ module: 'pdd_products', write: true, actions: [] }] }]
|
||||
request.defaults.adapter = async config => {
|
||||
let data
|
||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
|
||||
if (config.url.endsWith('/modules')) data = modules
|
||||
else if (config.method === 'get') data = { items: structuredClone(items), total: items.length }
|
||||
else if (config.url.endsWith('/grants')) { items[0].grants = body.grants; items[0].version++; data = items[0] } else if (config.url.endsWith('/disable')) { items[0].enabled = false; items[0].version++; data = items[0] } else if (config.method === 'post' && config.url === '/api/admin/v1/client-keys') {
|
||||
const key = { id: 2, name: body.name, prefix: 'gak_DEMO_2', enabled: true, version: 1, grants: body.grants }
|
||||
items = [key, ...items]; data = { key, secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }
|
||||
} else throw new Error('Unexpected fixture request')
|
||||
return { status: 200, statusText: 'OK', headers: {}, config, data: { code: 200, data }}
|
||||
}
|
||||
const app = createApp(Page)
|
||||
app.use(ElementPlus)
|
||||
app.config.globalProperties.$store = { getters: { roles: ['admin'] }}
|
||||
app.component('BasicLayout', { setup(_, { slots }) { return () => h('main', { style: 'padding:24px;background:#f3f6fa;min-height:90vh;font-family:Arial,sans-serif' }, slots.wrapper?.()) } })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,61 @@
|
||||
import Page from '@/views/goauto/client-keys/index.vue'
|
||||
import { createClientKey, editClientKey, listClientKeys, clientKeyModules } from '@/api/goauto/client-keys'
|
||||
|
||||
jest.mock('@/api/goauto/client-keys', () => ({ createClientKey: jest.fn(), editClientKey: jest.fn(), disableClientKey: jest.fn(), listClientKeys: jest.fn(), clientKeyModules: jest.fn() }))
|
||||
jest.mock('element-plus', () => ({ ElMessage: { success: jest.fn(), error: jest.fn(), warning: jest.fn() }, ElMessageBox: { confirm: jest.fn() }}))
|
||||
|
||||
function vm() {
|
||||
const value = { ...Page.data(), $store: { getters: { roles: ['admin'] }}}
|
||||
for (const [name, fn] of Object.entries(Page.methods)) value[name] = fn.bind(value)
|
||||
for (const [name, fn] of Object.entries(Page.computed)) Object.defineProperty(value, name, { get: fn.bind(value) })
|
||||
return value
|
||||
}
|
||||
const row = () => ({ id: 12, version: 2, name: 'Tool', prefix: 'masked', enabled: true, grants: [{ module: 'pdd_products', write: false, actions: [] }] })
|
||||
beforeEach(() => { jest.clearAllMocks(); listClientKeys.mockResolvedValue({ data: { items: [], total: 0 }}); clientKeyModules.mockResolvedValue({ data: [] }) })
|
||||
|
||||
test('edit prefill and cancel never change the existing row or create a key', () => {
|
||||
const p = vm(); const original = row(); p.openEditor(original)
|
||||
expect(p.changed).toBe(false)
|
||||
p.grants.pdd_products.write = true; expect(p.changed).toBe(true)
|
||||
p.closeEditor()
|
||||
expect(original.grants[0].write).toBe(false)
|
||||
expect(editClientKey).not.toHaveBeenCalled(); expect(createClientKey).not.toHaveBeenCalled()
|
||||
})
|
||||
test('module group selection defaults read-only and removing a module removes actions', () => {
|
||||
const p = vm(); p.selectGroup({ modules: [{ key: 'one' }, { key: 'two' }] }, true)
|
||||
expect(p.grants.one).toEqual({ module: 'one', write: false, actions: [] })
|
||||
p.grants.one.actions.push('delete'); p.selectModule('one', false); p.selectModule('one', true)
|
||||
expect(p.grants.one.actions).toEqual([])
|
||||
})
|
||||
test('edit saves same ID with version, does not issue a new key', async() => {
|
||||
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
|
||||
editClientKey.mockResolvedValue({ data: {}}); await p.save()
|
||||
expect(editClientKey).toHaveBeenCalledWith(12, { version: 2, grants: [{ module: 'pdd_products', write: true, actions: [] }] })
|
||||
expect(createClientKey).not.toHaveBeenCalled(); expect(p.secret).toBe(''); expect(p.editorOpen).toBe(false)
|
||||
})
|
||||
test('failure preserves changes and conflict prevents blind retry', async() => {
|
||||
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
|
||||
editClientKey.mockRejectedValue({ response: { status: 409, data: { message: 'conflict' }}})
|
||||
await p.save(); expect(p.editorOpen).toBe(true); expect(p.grants.pdd_products.write).toBe(true); expect(p.conflicted).toBe(true)
|
||||
await p.save(); expect(editClientKey).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
test('disabled keys are read-only and no modules cannot be saved', async() => {
|
||||
const p = vm(); p.openEditor({ ...row(), enabled: false }); expect(p.editorOpen).toBe(false)
|
||||
p.openEditor({ ...row(), enabled: false }, true); expect(p.readonly).toBe(true)
|
||||
p.openEditor(row()); p.selectModule('pdd_products', false); await p.save(); expect(editClientKey).not.toHaveBeenCalled()
|
||||
})
|
||||
test('secret is cleared when dialog or cached view closes', () => {
|
||||
const p = vm(); p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.clearSecret(); expect(p.secret).toBe('')
|
||||
p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.secretOpen = true; Page.deactivated.call(p); expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
|
||||
})
|
||||
test('ordinary users do not load or open management', async() => {
|
||||
const p = vm(); p.$store.getters.roles = ['purchaser']; await p.load(); p.openEditor(); expect(listClientKeys).not.toHaveBeenCalled(); expect(p.editorOpen).toBe(false)
|
||||
})
|
||||
test('late creation response after navigation does not retain a secret', async() => {
|
||||
const p = vm(); p.name = 'test'; p.selectModule('pdd_products', true)
|
||||
let resolve
|
||||
createClientKey.mockImplementation(() => new Promise(done => { resolve = done }))
|
||||
const pending = p.save(); Page.deactivated.call(p)
|
||||
resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending
|
||||
expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
|
||||
})
|
||||
Reference in New Issue
Block a user