fix(#72): wait for API and retry SYB load

This commit is contained in:
QiuSW
2026-08-24 11:02:47 +08:00
parent ca0c81b9f0
commit 476ca64616
7 changed files with 96 additions and 17 deletions
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: fbd334cc639f9053388af09319f34faf1dc4a5ab
synchronized_at: 2026-08-24T02:35:18Z
wiki_revision: 2075bb8a28fa01dcdbbcf105f12a29afc9b2e6f3
synchronized_at: 2026-08-24T02:59:16Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -143,7 +143,7 @@ Remove-Item Env:GOAUTO_IMPORT_MYSQL_TEST_DSN
本机安装的 Go Supervisor 位于 `D:\supervisor`。GoAuto 的版本化配置源为 `scripts/supervisor/goauto.conf`,运行副本为 `D:\supervisor\programs\goauto.conf`。两个实例都读取仓库根目录已忽略的 `config.yaml`,Supervisor 配置不得保存数据库密码:
- `goauto-admin-api`:调用 `scripts/start-server.ps1`,执行迁移后启动 Admin API。
- `goauto-admin-ui`:调用 `scripts/start-web.ps1`,启动 Admin UI;脚本会解析 Node.js 并直接运行项目的 Vite CLI,避免 Supervisor 子进程缺少 Node PATH。
- `goauto-admin-ui`:调用 `scripts/start-web.ps1`,先等待 `ports.server` 对应的 `/api/v1/health` 返回 200,再启动 Admin UI;脚本会解析 Node.js 并直接运行项目的 Vite CLI,避免 Supervisor 子进程缺少 Node PATH。API 在 60 秒内未就绪时,脚本明确失败并由 Supervisor 按重启策略处理。
- 日志:`D:\supervisor\logs\goauto-admin-api.log`、`D:\supervisor\logs\goauto-admin-ui.log`。
- Supervisor 管理界面:`http://127.0.0.1:9009`。
@@ -162,4 +162,4 @@ D:\supervisor\supervisord.exe /c D:\supervisor\supervisord.conf ctl restart goau
D:\supervisor\supervisord.exe /c D:\supervisor\supervisord.conf ctl restart goauto-admin-ui
```
Supervisor 托管期间不要再运行 `start-all.bat` 或重复启动对应单端脚本,否则会因 8010/9527 被占用而失败。
Supervisor 托管期间不要再运行 `start-all.bat` 或重复启动对应单端脚本,否则会因 8010/9527 被占用而失败。两个 GoAuto 实例同时重启时,Admin UI 会等待 API HTTP 就绪后再监听 Web 端口;SYB 商品页对一次短暂网络断开做单次有界重试,不对认证、权限或业务错误重试。
+28
View File
@@ -77,6 +77,32 @@ function Read-PortConfig {
return @{ Server = [int]$values.server; Web = [int]$values.web }
}
function Wait-ApiReady {
param(
[int]$Port,
[int]$TimeoutSeconds = 60
)
$healthUrl = "http://127.0.0.1:${Port}/api/v1/health"
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
Write-Host "Waiting for GoAuto API at $healthUrl ..." -ForegroundColor Cyan
while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
Write-Host "GoAuto API is ready." -ForegroundColor Green
return
}
}
catch {
# The API process may still be migrating or compiling.
}
Start-Sleep -Milliseconds 1000
}
throw "GoAuto API did not become ready within ${TimeoutSeconds}s: $healthUrl"
}
if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
$ConfigPath = Join-Path $workspaceRoot "config.yaml"
}
@@ -94,6 +120,8 @@ if ($ValidateConfigOnly) {
return
}
Wait-ApiReady -Port $ports.Server
$node = Get-Command node.exe -ErrorAction SilentlyContinue
if ($node) {
$nodePath = $node.Source
+1 -1
View File
@@ -2,7 +2,7 @@ import request from '@/utils/request'
export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data }) }
export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data }) }
export function authorizeRepurchase(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/authorize-repurchase`, method: 'post', data }) }
+2 -2
View File
@@ -1,7 +1,7 @@
import request from '@/utils/request'
export function listSybProducts(params) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params })
export function listSybProducts(params, options = {}) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, ...options })
}
export function getSybProduct(productId) {
+7 -5
View File
@@ -99,11 +99,13 @@ service.interceptors.response.use(
},
error => {
if (error.message === 'Network Error') {
ElMessage({
message: '服务器连接异常,请检查服务器!',
type: 'error',
duration: 5 * 1000
})
if (!error.config?.suppressNetworkError) {
ElMessage({
message: '服务器连接异常,请检查服务器!',
type: 'error',
duration: 5 * 1000
})
}
return Promise.reject(error)
}
console.log('err' + error) // for debug
+12 -5
View File
@@ -184,15 +184,22 @@ export default {
created() { this.load() },
beforeUnmount() { this.stopSyncPolling() },
methods: {
async load() {
async load(allowNetworkRetry = true) {
this.loading = true
this.selectedProducts = []
this.$refs.productTable?.clearSelection()
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const r = await listSybProducts(this.query)
const r = await listSybProducts(this.query, requestOptions)
this.products = r.data.items
this.total = r.data.total
await this.loadPurchaseReadiness()
await this.loadPurchaseReadiness(requestOptions)
} catch (error) {
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
await new Promise(resolve => setTimeout(resolve, 1000))
return this.load(false)
}
throw error
} finally {
this.loading = false
}
@@ -204,12 +211,12 @@ export default {
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
async loadPurchaseReadiness() {
async loadPurchaseReadiness(requestOptions = {}) {
this.purchaseReadiness = {}
if (!this.canPurchase || !this.products.length) return
this.purchaseReadinessLoading = true
try {
const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) })
const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) }, requestOptions)
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
} finally { this.purchaseReadinessLoading = false }
},
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'
test('SYB 商品首次网络失败时静默重试一次并恢复列表', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let listCalls = 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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) {
listCalls += 1
if (listCalls === 1) return route.abort('connectionrefused')
return route.fulfill({ json: { code: 200, data: { items: [{ id: 72, orderCode: 'RETRY-72', shopeeItemId: '10072', productTitle: '网络恢复后的商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 20 }}})
}
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 72, eligible: false, reason: '测试数据未关联商品' }], eligibleCount: 0, skippedCount: 1 }}})
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('http://localhost:9527/#/syb-products/index')
await expect(page.getByText('网络恢复后的商品')).toBeVisible()
expect(listCalls).toBe(2)
await expect(page.getByText('服务器连接异常,请检查服务器!')).toHaveCount(0)
})
test('SYB 商品业务错误不重试', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let listCalls = 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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) {
listCalls += 1
return route.fulfill({ json: { code: 500, msg: '测试业务错误' }})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('http://localhost:9527/#/syb-products/index')
await expect(page.getByText('测试业务错误')).toBeVisible()
expect(listCalls).toBe(1)
})