fix(#72): wait for API and retry SYB load
This commit is contained in:
@@ -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 商品页对一次短暂网络断开做单次有界重试,不对认证、权限或业务错误重试。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user