fix(web): support request IDs over HTTP (#182)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
function fillWithPseudoRandomBytes(bytes) {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function formatUuidV4(bytes) {
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
|
||||
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
function browserCrypto() {
|
||||
return typeof window === 'undefined' ? undefined : window.crypto
|
||||
}
|
||||
|
||||
export function createRequestId(cryptoApi = browserCrypto()) {
|
||||
if (typeof cryptoApi?.randomUUID === 'function') {
|
||||
return cryptoApi.randomUUID()
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
if (typeof cryptoApi?.getRandomValues === 'function') {
|
||||
cryptoApi.getRandomValues(bytes)
|
||||
} else {
|
||||
// requestId is an idempotency key, not a secret. Public HTTP contexts can
|
||||
// expose neither Web Crypto method, so retain a UUID-shaped local fallback.
|
||||
fillWithPseudoRandomBytes(bytes)
|
||||
}
|
||||
return formatUuidV4(bytes)
|
||||
}
|
||||
@@ -99,6 +99,7 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createCollectionRule, deleteCollectionRule, getCollectionRuleTemplate, listCollectionRules, updateCollectionRule } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
const defaultConstraints = {
|
||||
browserPackages: ['com.heytap.browser', 'com.android.chrome', 'com.android.browser'], hookDirections: ['up', 'down', 'left', 'right'],
|
||||
@@ -162,8 +163,8 @@ export default {
|
||||
summary(content) { if (content.schemaVersion !== 2) { const text = JSON.stringify(content); return text.length > 100 ? `${text.slice(0, 100)}…` : text } const aliases = content.collector?.dimensionAliases; const hookCount = content.hooks?.afterSpecPanelOpen?.[0]?.count || 0; const recovery = content.pageRecovery?.transientSoldOut?.enabled ? '假售罄恢复已启用' : '假售罄恢复关闭'; const navigationRecovery = content.navigationRecovery?.reopenBrowser?.enabled ? '入口恢复已启用' : '入口恢复关闭'; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次 · ${recovery} · ${navigationRecovery}` },
|
||||
async openCreate() { this.dialog = { open: true, loading: true, saving: false, ruleId: null }; try { await this.ensureTemplate(); this.fillV2(this.serverTemplate, 'PDD 商品详情采集') } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
|
||||
async openEdit(row) { this.dialog = { open: true, loading: true, saving: false, ruleId: row.id }; try { if (row.content.schemaVersion === 2) { await this.ensureTemplate(); this.fillV2(row.content, row.name) } else { this.contentBase = null; this.form = { ...this.emptyForm(), mode: 'v1', name: row.name, contentText: JSON.stringify(row.content, null, 2) } } } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
|
||||
async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: window.crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } },
|
||||
async remove(row) { await ElMessageBox.confirm(`删除“${row.name}”后不能再用于创建新任务,已有任务不受影响。`, '确认删除规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deleteCollectionRule(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success('规则已删除'); await this.getList() }
|
||||
async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: createRequestId(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } },
|
||||
async remove(row) { await ElMessageBox.confirm(`删除“${row.name}”后不能再用于创建新任务,已有任务不受影响。`, '确认删除规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deleteCollectionRule(row.id, { requestId: createRequestId() }); ElMessage.success('规则已删除'); await this.getList() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -76,6 +76,7 @@ import { createCollectionTask, deleteCollectionTask, getCollectionTask, listColl
|
||||
import { listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoCollectionTasks', setup() { return { Plus, RefreshLeft, Search } },
|
||||
@@ -88,10 +89,10 @@ export default {
|
||||
isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') },
|
||||
formatRuleSnapshot(value) { try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2) } catch (_) { return String(value || '') } },
|
||||
async openCreate() { this.form = { pddProductId: null, ruleId: null, deviceId: null }; this.createDialog.open = true; const [p, r, d] = await Promise.all([listPddProducts({ page: 1, pageSize: 100 }), listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.options = { products: p.data.items, rules: r.data.items, devices: d.data.items }; this.$nextTick(() => this.$refs.taskForm?.clearValidate()) },
|
||||
async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: window.crypto.randomUUID(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } },
|
||||
async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: createRequestId(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } },
|
||||
async showDetail(row) { this.detail = { open: true, loading: true, data: null }; try { const r = await getCollectionTask(row.id); this.detail.data = r.data } finally { this.detail.loading = false } },
|
||||
async confirmReset(row) { await ElMessageBox.confirm('将复用原任务并按最新有效规则重新采集;当前结果会归档到历史执行记录,不会创建新任务。是否继续?', '重新采集', { type: 'warning' }); const response = await resetCollectionTask(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success(`任务已进入第 ${response.data.attemptNumber || 1} 次采集`); await this.getList() },
|
||||
async confirmDelete(row) { await ElMessageBox.confirm('仅删除这条失败任务,是否继续?', '删除失败任务', { type: 'warning' }); await deleteCollectionTask(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success('失败任务已删除'); await this.getList() }
|
||||
async confirmReset(row) { await ElMessageBox.confirm('将复用原任务并按最新有效规则重新采集;当前结果会归档到历史执行记录,不会创建新任务。是否继续?', '重新采集', { type: 'warning' }); const response = await resetCollectionTask(row.id, { requestId: createRequestId() }); ElMessage.success(`任务已进入第 ${response.data.attemptNumber || 1} 次采集`); await this.getList() },
|
||||
async confirmDelete(row) { await ElMessageBox.confirm('仅删除这条失败任务,是否继续?', '删除失败任务', { type: 'warning' }); await deleteCollectionTask(row.id, { requestId: createRequestId() }); ElMessage.success('失败任务已删除'); await this.getList() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,6 +118,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoDeviceList',
|
||||
@@ -168,7 +169,7 @@ export default {
|
||||
async loadReleases() { this.releaseDrawer.loading = true; try { const response = await listAgentAppReleases({ page: 1, pageSize: 100 }); this.releaseDrawer.items = response.data.items } finally { this.releaseDrawer.loading = false } },
|
||||
onAPKChange(file) { this.releaseDrawer.file = file.raw }, onAPKRemove() { this.releaseDrawer.file = null },
|
||||
async uploadRelease() { if (!this.releaseDrawer.file) return; const form = new FormData(); form.append('file', this.releaseDrawer.file); form.append('releaseNotes', this.releaseDrawer.notes.trim()); this.releaseDrawer.uploading = true; this.releaseDrawer.progress = 0; try { await uploadAgentAppRelease(form, event => { if (event.total) this.releaseDrawer.progress = Math.round(event.loaded * 100 / event.total) }); ElMessage.success('Agent APK 已上传,请核对后设为当前版本'); this.releaseDrawer.file = null; this.releaseDrawer.notes = ''; await this.loadReleases() } finally { this.releaseDrawer.uploading = false } },
|
||||
async activateRelease(row) { await ElMessageBox.confirm(`设为当前版本 ${row.versionName}(${row.versionCode})?Agent 检查更新后将看到此版本。`, '发布 Agent 版本', { type: 'warning', confirmButtonText: '设为当前', cancelButtonText: '取消' }); await setCurrentAgentAppRelease({ requestId: crypto.randomUUID(), releaseId: row.id }); ElMessage.success('当前 Agent 版本已更新'); await this.loadReleases() },
|
||||
async activateRelease(row) { await ElMessageBox.confirm(`设为当前版本 ${row.versionName}(${row.versionCode})?Agent 检查更新后将看到此版本。`, '发布 Agent 版本', { type: 'warning', confirmButtonText: '设为当前', cancelButtonText: '取消' }); await setCurrentAgentAppRelease({ requestId: createRequestId(), releaseId: row.id }); ElMessage.success('当前 Agent 版本已更新'); await this.loadReleases() },
|
||||
async downloadRelease(row) { const response = await downloadAgentAppRelease(row.id); const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `agent-${row.versionCode}.apk`; link.click(); URL.revokeObjectURL(url) },
|
||||
formatBytes(bytes) { return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB` },
|
||||
async confirmDisable(row) {
|
||||
|
||||
@@ -134,6 +134,7 @@ import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, createStockPurchaseTask, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
let uid = 0
|
||||
export default {
|
||||
@@ -232,7 +233,7 @@ export default {
|
||||
if (!await this.$refs.batchForm.validate().catch(() => false)) return
|
||||
this.batch.saving = true
|
||||
try {
|
||||
const response = await batchCreateCollectionTasks({ requestId: crypto.randomUUID(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null })
|
||||
const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null })
|
||||
const byID = new Map(this.batch.products.map(item => [item.id, item]))
|
||||
this.batch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item }))
|
||||
this.batch.successCount = response.data.successCount
|
||||
@@ -249,11 +250,11 @@ export default {
|
||||
}
|
||||
},
|
||||
openCreate() { this.createDialog = { open: true, saving: false }; this.createData = { url: '' }; this.$nextTick(() => this.$refs.createForm?.clearValidate()) },
|
||||
async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: crypto.randomUUID(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
|
||||
async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: createRequestId(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, product: null, relatedShopee: [] }; this.related = this.emptyRelated(); try { const r = await getPddProduct(id); this.detail.product = r.data.product; this.detail.relatedShopee = r.data.relatedShopeeProducts || []; await this.loadRelated() } finally { this.detail.loading = false } },
|
||||
async openRelatedPurchase() { const candidates = this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)); if (!candidates.length) return; const ids = candidates.map(row => row.sybProductId); this.relatedPurchase = { ...this.emptyRelatedPurchase(), open: true, loading: true, ids }; try { const [preview, devices] = await Promise.all([previewPurchaseTasks({ sybProductIds: ids }), listDevices({ page: 1, pageSize: 100, status: 'online' })]); this.relatedPurchase.items = preview.data.items; this.relatedPurchase.eligibleCount = preview.data.eligibleCount; this.relatedPurchase.devices = devices.data.items.filter(item => item.selectable) } finally { this.relatedPurchase.loading = false } },
|
||||
async refreshRelatedPurchase() { this.relatedPurchase.loading = true; try { const r = await previewPurchaseTasks({ sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); this.relatedPurchase.items = r.data.items; this.relatedPurchase.eligibleCount = r.data.eligibleCount } finally { this.relatedPurchase.loading = false } },
|
||||
async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: crypto.randomUUID(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; await this.loadRelated() } finally { this.relatedPurchase.saving = false } },
|
||||
async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; await this.loadRelated() } finally { this.relatedPurchase.saving = false } },
|
||||
openStockPurchase() { if (!this.canCreateStockPurchase) return; const color = this.stockColorOptions[0]; const price = color.priceCent / 100; this.stock = { open: true, saving: false, error: '' }; this.stockData = { color: color.name, size: this.stockSizeOptions[0]?.name || '', quantity: 1, minPriceYuan: price, maxPriceYuan: price, executionMode: 'live' }; this.$nextTick(() => this.$refs.stockForm?.clearValidate()) },
|
||||
resetStockPurchase() { this.stock = this.emptyStock(); this.stockData = this.emptyStockData() },
|
||||
onStockColorChange(name) { const color = this.stockColorOptions.find(item => item.name === name); if (!color) return; const price = color.priceCent / 100; this.stockData.minPriceYuan = price; this.stockData.maxPriceYuan = price; this.stock.error = '' },
|
||||
@@ -263,7 +264,7 @@ export default {
|
||||
if (minUnitPriceCent < 0 || maxUnitPriceCent < minUnitPriceCent) { this.stock.error = '允许单价上限不能低于下限'; return }
|
||||
this.stock.saving = true; this.stock.error = ''
|
||||
try {
|
||||
const response = await createStockPurchaseTask({ requestId: crypto.randomUUID(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true })
|
||||
const response = await createStockPurchaseTask({ requestId: createRequestId(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true })
|
||||
ElMessage.success('备货采购任务已创建'); this.stock.open = false; this.detail.open = false
|
||||
await this.$router.push({ path: '/purchase-tasks/index', query: { taskId: response.data.id }})
|
||||
} catch (error) { this.stock.error = error?.response?.data?.message || error?.response?.data?.msg || error?.message || '创建失败,请稍后重试' } finally { this.stock.saving = false }
|
||||
@@ -272,7 +273,7 @@ export default {
|
||||
addDimension() { this.editData.specs.push({ localId: ++uid, name: '', role: 'other', values: [] }) }, addValue(d) { d.values.push({ localId: ++uid, name: '', selectable: true, priceYuan: null }) },
|
||||
move(items, index, offset) { const target = index + offset; if (target < 0 || target >= items.length) return; const [item] = items.splice(index, 1); items.splice(target, 0, item) },
|
||||
specError() { const dimensions = new Set(); for (const d of this.editData.specs) { const name = d.name.trim(); if (!name) return '请填写规格维度名称'; if (dimensions.has(name)) return `规格维度“${name}”重复`; dimensions.add(name); if (!d.values.length) return `请为“${name}”添加规格值`; const values = new Set(); for (const v of d.values) { const n = v.name.trim(); if (!n) return `请填写“${name}”中的规格值名称`; if (values.has(n)) return `“${name}”中存在重复规格值“${n}”`; values.add(n) } } return '' },
|
||||
async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: crypto.randomUUID(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } }
|
||||
async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: createRequestId(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { enabled: true, minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] }
|
||||
const defaultForm = () => ({ name: '', priceGuardEnabled: true, minRatio: 0.2, maxRatio: 1.5, absoluteMaxUnitPriceYuan: 50, contentText: '' })
|
||||
@@ -82,14 +83,14 @@ export default {
|
||||
this.dialog.saving = true
|
||||
const content = JSON.parse(this.form.contentText)
|
||||
content.priceGuard = this.form.priceGuardEnabled ? { enabled: true, minRatio: this.form.minRatio, maxRatio: this.form.maxRatio } : { enabled: false, absoluteMaxUnitPriceCent: absoluteCent }
|
||||
const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content }
|
||||
const payload = { requestId: createRequestId(), name: this.form.name.trim(), content }
|
||||
try {
|
||||
if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload)
|
||||
ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load()
|
||||
} finally { this.dialog.saving = false }
|
||||
},
|
||||
async setCurrent(row) { const guard = row.content.priceGuard; const risk = guard?.enabled === false ? `该规则已关闭参考价倍率保护,单件绝对最高价为 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}。` : ''; await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。${risk}`, '切换当前采购规则', { type: guard?.enabled === false ? 'error' : 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } },
|
||||
async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() }
|
||||
async setCurrent(row) { const guard = row.content.priceGuard; const risk = guard?.enabled === false ? `该规则已关闭参考价倍率保护,单件绝对最高价为 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}。` : ''; await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。${risk}`, '切换当前采购规则', { type: guard?.enabled === false ? 'error' : 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: createRequestId(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } },
|
||||
async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: createRequestId() }); ElMessage.success('采购规则已删除'); await this.load() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
const statusOptions = [
|
||||
['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'],
|
||||
@@ -211,7 +212,7 @@ export default {
|
||||
specText(color, size) { return [color, size].filter(Boolean).join(' / ') || '未记录' },
|
||||
priceText(cents, currency) { if (cents === null || cents === undefined) return '—'; return `${currency || ''} ${(cents / 100).toFixed(2)}` },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
requestId() { return window.crypto.randomUUID() },
|
||||
requestId() { return createRequestId() },
|
||||
isRetrySelectable(row) { return row.taskType !== 'stock' && row.status === 'failed' && row.retryable === true },
|
||||
retryRowClass({ row }) { return row.status === 'failed' && !row.retryable ? 'retry-disabled-row' : '' },
|
||||
handleRetrySelection(rows) { this.retrySelection = rows },
|
||||
|
||||
@@ -155,6 +155,7 @@ import {
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings, batchDeleteShopeeProducts
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoShopeeProducts',
|
||||
@@ -242,7 +243,7 @@ export default {
|
||||
if (this.colorValues.length) specs.push({ name: '颜色', role: 'color', values: this.colorValues.map(name => ({ name, source: 'manual' })) })
|
||||
if (this.sizeValues.length) specs.push({ name: '尺码', role: 'size', values: this.sizeValues.map(name => ({ name, source: 'manual' })) })
|
||||
const r = await createShopeeProduct({
|
||||
requestId: crypto.randomUUID(), shopeeItemId: this.createData.shopeeItemId.trim(),
|
||||
requestId: createRequestId(), shopeeItemId: this.createData.shopeeItemId.trim(),
|
||||
title: this.createData.title.trim(), shopName: this.createData.shopName.trim(),
|
||||
pddProductId: this.createData.pddProductId || undefined, specs
|
||||
})
|
||||
@@ -275,7 +276,7 @@ export default {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await linkShopeeProductPdd(this.detail.product.id, { requestId: crypto.randomUUID(), pddProductId: row.id })
|
||||
const r = await linkShopeeProductPdd(this.detail.product.id, { requestId: createRequestId(), pddProductId: row.id })
|
||||
this.detail.product = this.withDraftMappings(r.data.product)
|
||||
await this.loadLinkedPdd(row.id)
|
||||
ElMessage.success('已关联 PDD 商品')
|
||||
@@ -399,7 +400,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async confirmPendingMapping(row) {
|
||||
await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name })
|
||||
await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
await this.refreshDetail()
|
||||
},
|
||||
async saveAllMappings() {
|
||||
@@ -411,11 +412,11 @@ export default {
|
||||
try {
|
||||
for (const row of changes) {
|
||||
if (!row.pddValueDraft) {
|
||||
if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name })
|
||||
if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
continue
|
||||
}
|
||||
const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'
|
||||
const payload = { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }
|
||||
const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }
|
||||
if (source === 'ai_match') {
|
||||
if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence
|
||||
if (row.previewReason) payload.reason = row.previewReason
|
||||
@@ -424,7 +425,7 @@ export default {
|
||||
// AI-sourced mappings are always written as pending by the server
|
||||
// (#40, #46) and must stay that way until an operator explicitly
|
||||
// confirms them, separately from the act of saving the draft.
|
||||
if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name })
|
||||
if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
}
|
||||
ElMessage.success('规格匹配已保存')
|
||||
await this.refreshDetail()
|
||||
@@ -441,12 +442,12 @@ export default {
|
||||
const dimension = this.addValueForm.dimension.trim()
|
||||
const name = this.addValueForm.name.trim()
|
||||
if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return }
|
||||
await addShopeeSpecValue(this.detail.product.id, { requestId: crypto.randomUUID(), dimension, role: this.addValueForm.role, name })
|
||||
await addShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name })
|
||||
this.addValueForm = { dimension: '', role: 'other', name: '' }
|
||||
await this.refreshDetail()
|
||||
},
|
||||
async removeValue(dimension, row) {
|
||||
await removeShopeeSpecValue(this.detail.product.id, { requestId: crypto.randomUUID(), dimension, name: row.name })
|
||||
await removeShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, name: row.name })
|
||||
await this.refreshDetail()
|
||||
},
|
||||
// ---------------- 编辑档案 ----------------
|
||||
@@ -458,7 +459,7 @@ export default {
|
||||
async saveEdit() {
|
||||
this.edit.saving = true
|
||||
try {
|
||||
await updateShopeeProduct(this.edit.productId, { requestId: crypto.randomUUID(), ...this.editData })
|
||||
await updateShopeeProduct(this.edit.productId, { requestId: createRequestId(), ...this.editData })
|
||||
ElMessage.success('虾皮商品资料已保存')
|
||||
this.edit.open = false
|
||||
await this.refreshDetail()
|
||||
@@ -472,7 +473,7 @@ export default {
|
||||
async submitBatchDelete() {
|
||||
this.batchDelete.saving = true
|
||||
try {
|
||||
const r = await batchDeleteShopeeProducts({ requestId: crypto.randomUUID(), ids: this.batchDelete.products.map(item => item.id) })
|
||||
const r = await batchDeleteShopeeProducts({ requestId: createRequestId(), ids: this.batchDelete.products.map(item => item.id) })
|
||||
this.batchDelete.results = r.data.results
|
||||
this.batchDelete.deletedCount = r.data.results.filter(x => x.status === 'deleted').length
|
||||
this.batchDelete.skippedCount = r.data.results.filter(x => x.status === 'skipped').length
|
||||
@@ -482,7 +483,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async restore(row) {
|
||||
await restoreShopeeProduct(row.id, { requestId: crypto.randomUUID() })
|
||||
await restoreShopeeProduct(row.id, { requestId: createRequestId() })
|
||||
ElMessage.success('已恢复')
|
||||
await this.load()
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
import { FolderOpened, RefreshLeft, Search, Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { applySybInnerCodes, deleteSybInnerCodes, getSybInnerCode, getSybInnerCodeApplyBatch, getSybInnerCodeMatchJob, importSybInnerCodes, listSybInnerCodes, previewSybInnerCodeApply, recheckSybInnerCode, rematchSybInnerCodes } from '@/api/goauto/syb-inner-codes'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
@@ -83,12 +84,12 @@ export default {
|
||||
selectFile(file) { this.selectedFile = file.raw }, replaceFile(files) { this.$refs.upload.clearFiles(); this.$refs.upload.handleStart(files[0]) }, selectionChanged(rows) { this.selected = rows },
|
||||
async load() { this.loading = true; this.loadError = ''; try { const r = await listSybInnerCodes(this.query); this.items = r.data.items; this.total = r.data.total } catch (error) { this.loadError = error?.response?.data?.message || error?.message || '列表加载失败' } finally { this.loading = false } },
|
||||
search() { [this.query.dateFrom, this.query.dateTo] = this.dateRange || ['', '']; this.query.page = 1; this.load() }, clearFilters() { this.dateRange = null; Object.assign(this.query, { page: 1, dateFrom: '', dateTo: '', keyword: '' }); this.load() },
|
||||
async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, crypto.randomUUID()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } },
|
||||
async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, createRequestId()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } },
|
||||
async openApply() { this.applyDialog = { open: true, loading: true, saving: false, preview: {}}; try { const r = await previewSybInnerCodeApply(this.selected.map(item => item.id)); this.applyDialog.preview = r.data } finally { this.applyDialog.loading = false } },
|
||||
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: crypto.randomUUID(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
|
||||
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: crypto.randomUUID(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
|
||||
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
|
||||
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
|
||||
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: crypto.randomUUID(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
|
||||
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
|
||||
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
|
||||
|
||||
@@ -151,6 +151,7 @@ import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
@@ -322,7 +323,7 @@ export default {
|
||||
async submitPurchaseBatch() {
|
||||
this.purchaseDialog.saving = true
|
||||
try {
|
||||
const r = await createPurchaseTasksBatch({ requestId: crypto.randomUUID(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined })
|
||||
const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined })
|
||||
this.purchaseResult = { open: true, items: r.data.items, createdCount: r.data.createdCount, failedCount: r.data.failedCount }
|
||||
this.purchaseDialog.open = false
|
||||
await this.load()
|
||||
@@ -359,7 +360,7 @@ export default {
|
||||
if (!await this.$refs.collectionBatchForm.validate().catch(() => false)) return
|
||||
this.collectionBatch.saving = true
|
||||
try {
|
||||
const response = await batchCreateCollectionTasks({ requestId: crypto.randomUUID(), pddProductIds: this.collectionBatch.products.map(item => item.pddProductId), ruleId: this.collectionBatchData.ruleId, deviceId: this.collectionBatchData.deviceId || null })
|
||||
const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.collectionBatch.products.map(item => item.pddProductId), ruleId: this.collectionBatchData.ruleId, deviceId: this.collectionBatchData.deviceId || null })
|
||||
const byID = new Map(this.collectionBatch.products.map(item => [item.pddProductId, item]))
|
||||
this.collectionBatch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item }))
|
||||
this.collectionBatch.successCount = response.data.successCount
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
describe('request ID generation', () => {
|
||||
it('uses the browser native UUID when available', () => {
|
||||
const randomUUID = jest.fn(() => 'native-request-id')
|
||||
|
||||
expect(createRequestId({ randomUUID })).toBe('native-request-id')
|
||||
expect(randomUUID).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('formats secure random bytes as an RFC 4122 version 4 UUID', () => {
|
||||
const getRandomValues = jest.fn(bytes => {
|
||||
bytes.fill(0xff)
|
||||
return bytes
|
||||
})
|
||||
|
||||
expect(createRequestId({ getRandomValues })).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff')
|
||||
expect(getRandomValues).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('supports public HTTP contexts without Web Crypto', () => {
|
||||
jest.spyOn(Math, 'random').mockReturnValue(0)
|
||||
|
||||
expect(createRequestId(null)).toBe('00000000-0000-4000-8000-000000000000')
|
||||
|
||||
Math.random.mockRestore()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user