feat(#75): 增加 SYB 每小时自动同步
This commit is contained in:
@@ -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: 5f88de723d9ca9124383cd4abcfa5c27ddcd6d48
|
||||
synchronized_at: 2026-08-24T07:03:25Z
|
||||
wiki_revision: ce37cbe86bad24c38c801955e301ff8e90ec86b5
|
||||
synchronized_at: 2026-08-24T07:56:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -120,6 +120,7 @@ Android Portal/Agent
|
||||
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
|
||||
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
|
||||
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
|
||||
| SYB 每小时自动同步 | `server/app/goauto/sybimport/import_handler.go` 提供手动/定时共用的 `StartImport` 服务;`scheduled_job.go` 通过 go-admin 定时任务调度,固定按 Asia/Shanghai 取今天和昨天;任务注册迁移为 `server/cmd/migrate/migration/version-local/1786701600000_syb_hourly_sync_job.go` |
|
||||
| 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` |
|
||||
| 采购任务单条/批量预检与创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;`batch-preview` 通过批量预加载 SYB、蝦皮、PDD 与最新任务执行快速只读预检,不访问 AI;`batch-create` 仍按最新数据逐项完整复核并可在必要时调用 AI;既有追加迁移为 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` |
|
||||
| 采购规格标准化匹配与 AI Provider 设置 | `server/app/goauto/aimatching/`;服务端先做繁简、空白/全半角/大小写和公斤/斤的唯一确定性匹配,再按需调用单一 OpenAI-compatible Provider;`1786701300000_ai_matching_setting.go` 创建设置表,`1786701400000_ai_matching_setting_plain_api_key.go` 将原加密列迁移为内部明文 `api_key`,仅管理员读取 |
|
||||
|
||||
@@ -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: 5adb7b0f05c4562e23fe92fe7ff89625d697c2d4
|
||||
synchronized_at: 2026-08-24T07:03:44Z
|
||||
wiki_revision: bb1dea3110c827ee6516f74eb440417a0551f9b8
|
||||
synchronized_at: 2026-08-24T07:56:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -184,6 +184,15 @@ synchronized_at: 2026-08-24T07:03:44Z
|
||||
| spec_source | 采购任务的规格来源标记:`manual_mapping` / `exact_match` / `ai_match`,用于事后批量追溯 |
|
||||
|
||||
|
||||
## SYB 自动同步
|
||||
|
||||
- 服务端内置 go-admin 定时任务“SYB 每小时自动同步”,默认每小时第 5 分钟执行;同步日期按 Asia/Shanghai 计算,覆盖当天和前一天。
|
||||
- 定时同步与保留的手动导入 API 共用同一个导入服务和 `syb_sync_run` 记录;定时任务的操作人显示为“系统定时同步”。
|
||||
- 同一时刻只允许一个 SYB 同步任务运行。上一次仍在运行时,本次定时触发直接跳过,不排队、不并发,也不立即重试;等待下一小时再次触发。
|
||||
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
|
||||
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮;页面自动显示最近一次同步状态,运行中轮询进度,完成后刷新商品列表。同步记录页面和手动导入 API 继续保留。
|
||||
|
||||
|
||||
## cmautobuy 商品导入
|
||||
|
||||
- 这是人工触发的单向离线导入,不是持续双写或双库同步;不随服务启动执行。
|
||||
|
||||
@@ -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: 2075bb8a28fa01dcdbbcf105f12a29afc9b2e6f3
|
||||
synchronized_at: 2026-08-24T02:59:16Z
|
||||
wiki_revision: 465390216d69d28a642854fd946d2d818a834f9c
|
||||
synchronized_at: 2026-08-24T07:56:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -77,6 +77,22 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
SQLite 只用于测试;正式运行和最终迁移目标仍为 MySQL 8.4。真实 MySQL 连接串继续通过 `GOAUTO_DB_DSN` 注入。
|
||||
|
||||
|
||||
## SYB 定时同步
|
||||
|
||||
迁移 `1786701600000_syb_hourly_sync_job.go` 会幂等写入 go-admin 的 `sys_job`,调用目标为 `GoAutoSYBHourlySync`,默认 Cron 为 `0 5 * * * *`、状态为启用。已有同调用目标的任务不会被迁移覆盖;管理员可在 go-admin“定时任务”中调整 Cron、启停状态和参数。
|
||||
|
||||
默认参数为 `{"lookbackDays":2,"timezone":"Asia/Shanghai"}`。当前实现允许回看 1~7 天;默认 2 天即当天和前一天。修改后需要让调度器重新加载任务(通常重启 Admin API)。
|
||||
|
||||
本地验证不要为了检查迁移而启动 Admin API:迁移本身不会访问 SYB,但启用状态的定时任务会在服务启动并到达下一次调度时间后访问已配置的 SYB。可只执行迁移并再次执行确认幂等:
|
||||
|
||||
```powershell
|
||||
Set-Location server
|
||||
go run . migrate -c config/settings.yml
|
||||
go run . migrate -c config/settings.yml
|
||||
```
|
||||
|
||||
|
||||
## Web 验证
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -36,11 +37,80 @@ type ImportRequest struct {
|
||||
DateTo string `json:"dateTo"`
|
||||
}
|
||||
|
||||
type ImportActor struct {
|
||||
ID uint64
|
||||
Name string
|
||||
}
|
||||
|
||||
type StartImportResult struct {
|
||||
RunID uint64
|
||||
Status string
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// StartImport is the single entry point shared by the authenticated Admin
|
||||
// handler and the built-in hourly job. It deliberately owns every preflight
|
||||
// and both single-flight guards so a scheduled run can never bypass the same
|
||||
// safety boundary as a manual run.
|
||||
func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor ImportActor, skipIfRunning bool) (StartImportResult, error) {
|
||||
if request.DateFrom == "" || request.DateTo == "" {
|
||||
return StartImportResult{}, invalidRequest("dateFrom 和 dateTo 不能为空,格式为 YYYY-MM-DD")
|
||||
}
|
||||
if _, err := splitDateRange(request.DateFrom, request.DateTo); err != nil {
|
||||
return StartImportResult{}, invalidRequest(err.Error())
|
||||
}
|
||||
|
||||
// This check must stay before credentials and Connect: an invalid sync must
|
||||
// not consume a login attempt or send a captcha to OCR.
|
||||
enabled, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return StartImportResult{}, &ServiceError{Code: CodeSyncShopPreflightFailed, Message: "服务端处理失败", Cause: err}
|
||||
}
|
||||
if len(enabled) == 0 {
|
||||
return StartImportResult{}, invalidRequest(ErrNoEnabledShop.Error())
|
||||
}
|
||||
|
||||
settings := config.ExtConfig.SYB.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
return StartImportResult{}, invalidRequest(credentialHint())
|
||||
}
|
||||
|
||||
importGate.Lock()
|
||||
if importGate.running {
|
||||
importGate.Unlock()
|
||||
if skipIfRunning {
|
||||
return StartImportResult{Skipped: true}, nil
|
||||
}
|
||||
return StartImportResult{}, invalidRequest("已有一个导入任务正在执行,请等它结束后再试")
|
||||
}
|
||||
run, err := NewSyncRunService(db).Create(ctx, CreateSyncRunInput{
|
||||
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
|
||||
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
|
||||
})
|
||||
if err != nil {
|
||||
importGate.Unlock()
|
||||
if skipIfRunning && isImportAlreadyRunning(err) {
|
||||
return StartImportResult{Skipped: true}, nil
|
||||
}
|
||||
return StartImportResult{}, err
|
||||
}
|
||||
importGate.running = true
|
||||
importGate.Unlock()
|
||||
|
||||
go runImport(db, run.ID, request, settings)
|
||||
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
|
||||
}
|
||||
|
||||
func isImportAlreadyRunning(err error) bool {
|
||||
var serviceErr *ServiceError
|
||||
return errors.As(err, &serviceErr) && serviceErr.Code == CodeInvalidRequest && strings.Contains(serviceErr.Message, "已有")
|
||||
}
|
||||
|
||||
// Import pulls shipment orders from SYB for a date range and folds them into
|
||||
// the archive.
|
||||
//
|
||||
// `[必须]` This is the only endpoint that reaches out to SYB. It performs reads
|
||||
// only — no SYB write endpoint is called from anywhere in GoAuto.
|
||||
// `[必须]` 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": "只有管理员可以开始导入"})
|
||||
@@ -51,69 +121,28 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
writeError(c, invalidRequest("请求体必须是合法 JSON,且包含 dateFrom 和 dateTo"))
|
||||
return
|
||||
}
|
||||
if request.DateFrom == "" || request.DateTo == "" {
|
||||
writeError(c, invalidRequest("dateFrom 和 dateTo 不能为空,格式为 YYYY-MM-DD"))
|
||||
return
|
||||
}
|
||||
if _, err := splitDateRange(request.DateFrom, request.DateTo); err != nil {
|
||||
writeError(c, invalidRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// `[必须]` Refuse before Connect: Connect may log in and send a captcha to
|
||||
// the configured OCR service. With no enabled shop there is no valid sync
|
||||
// to run, so consuming either remote service would be wasteful and would
|
||||
// violate #49's pre-flight boundary. Sync checks again to cover a shop being
|
||||
// disabled between this pre-flight and the actual run.
|
||||
enabled, err := sybshop.EnabledNames(c.Request.Context(), service.DB)
|
||||
if err != nil {
|
||||
handler.logInternalFailure(c, "shop_preflight", err)
|
||||
writeError(c, &ServiceError{Code: CodeSyncShopPreflightFailed, Message: "服务端处理失败", Cause: err})
|
||||
return
|
||||
}
|
||||
if len(enabled) == 0 {
|
||||
writeError(c, invalidRequest(ErrNoEnabledShop.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
settings := config.ExtConfig.SYB.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
// Name the file that was actually consulted. The previous wording only
|
||||
// mentioned environment variables, which sent operators looking in the
|
||||
// wrong place once config.yaml became the normal way to configure this.
|
||||
writeError(c, invalidRequest(credentialHint()))
|
||||
return
|
||||
}
|
||||
|
||||
importGate.Lock()
|
||||
if importGate.running {
|
||||
importGate.Unlock()
|
||||
writeError(c, &ServiceError{Code: CodeInvalidRequest, Message: "已有一个导入任务正在执行,请等它结束后再试"})
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
run, err := NewSyncRunService(service.DB).Create(c.Request.Context(), CreateSyncRunInput{
|
||||
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
|
||||
OperatorID: claimUint64(claims["identity"]), OperatorName: claimString(claims["nice"]),
|
||||
})
|
||||
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
|
||||
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
|
||||
}, false)
|
||||
if err != nil {
|
||||
importGate.Unlock()
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) && serviceErr.Code == CodeSyncRunCreateFailed {
|
||||
handler.logInternalFailure(c, "sync_run_create", serviceErr.Cause)
|
||||
if errors.As(err, &serviceErr) {
|
||||
switch serviceErr.Code {
|
||||
case CodeSyncShopPreflightFailed:
|
||||
handler.logInternalFailure(c, "shop_preflight", serviceErr.Cause)
|
||||
case CodeSyncRunCreateFailed:
|
||||
handler.logInternalFailure(c, "sync_run_create", serviceErr.Cause)
|
||||
}
|
||||
}
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
importGate.running = true
|
||||
importGate.Unlock()
|
||||
|
||||
go runImport(service.DB, run.ID, request, settings)
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": run.ID, "status": run.Status}})
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "status": result.Status}})
|
||||
}
|
||||
|
||||
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
HourlySyncInvokeTarget = "GoAutoSYBHourlySync"
|
||||
defaultHourlyTimezone = "Asia/Shanghai"
|
||||
defaultLookbackDays = 2
|
||||
)
|
||||
|
||||
type hourlySyncArgs struct {
|
||||
LookbackDays int `json:"lookbackDays"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// HourlySyncJob is registered in go-admin's ExecJob map. ExecWithDB is the
|
||||
// production path; Exec exists only to satisfy legacy jobs.JobExec and fails
|
||||
// closed if an older caller forgets to provide the current database.
|
||||
type HourlySyncJob struct{}
|
||||
|
||||
func (HourlySyncJob) Exec(_ interface{}) error {
|
||||
return errors.New("SYB 定时同步缺少数据库连接")
|
||||
}
|
||||
|
||||
func (HourlySyncJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
args, err := parseHourlySyncArgs(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
location, err := time.LoadLocation(args.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SYB 定时同步时区无效: %w", err)
|
||||
}
|
||||
dateFrom, dateTo := hourlySyncDateRange(time.Now().In(location), args.LookbackDays)
|
||||
_, err = StartImport(context.Background(), db, ImportRequest{DateFrom: dateFrom, DateTo: dateTo}, ImportActor{Name: "系统定时同步"}, true)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseHourlySyncArgs(arg interface{}) (hourlySyncArgs, error) {
|
||||
result := hourlySyncArgs{LookbackDays: defaultLookbackDays, Timezone: defaultHourlyTimezone}
|
||||
raw, _ := arg.(string)
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return hourlySyncArgs{}, fmt.Errorf("SYB 定时同步参数不是合法 JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if result.LookbackDays < 1 || result.LookbackDays > 7 {
|
||||
return hourlySyncArgs{}, errors.New("SYB 定时同步 lookbackDays 必须在 1 到 7 之间")
|
||||
}
|
||||
if strings.TrimSpace(result.Timezone) == "" {
|
||||
result.Timezone = defaultHourlyTimezone
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hourlySyncDateRange(now time.Time, lookbackDays int) (string, string) {
|
||||
to := now.Format("2006-01-02")
|
||||
from := now.AddDate(0, 0, -(lookbackDays - 1)).Format("2006-01-02")
|
||||
return from, to
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/config"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestHourlySyncDateRangeUsesConfiguredLookback(t *testing.T) {
|
||||
from, to := hourlySyncDateRange(time.Date(2026, 8, 24, 0, 5, 0, 0, time.FixedZone("CST", 8*60*60)), 2)
|
||||
if from != "2026-08-23" || to != "2026-08-24" {
|
||||
t.Fatalf("range = %s..%s", from, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlySyncArgsDefaultsAndBounds(t *testing.T) {
|
||||
args, err := parseHourlySyncArgs("")
|
||||
if err != nil || args.LookbackDays != 2 || args.Timezone != "Asia/Shanghai" {
|
||||
t.Fatalf("defaults = %+v, err = %v", args, err)
|
||||
}
|
||||
if _, err := parseHourlySyncArgs(`{"lookbackDays":8,"timezone":"Asia/Shanghai"}`); err == nil {
|
||||
t.Fatal("lookbackDays above safety bound should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledImportSkipsExistingRunWithoutStartingNetworkWork(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:scheduled-import-busy?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SYBShop{}, &models.SYBSyncRun{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.SYBShop{DisplayName: "测试店铺", NormalizedName: "测试店铺", Enabled: true}).Error; err != nil {
|
||||
t.Fatalf("create shop: %v", err)
|
||||
}
|
||||
if _, err := NewSyncRunService(db).Create(context.Background(), CreateSyncRunInput{DateFrom: "2026-08-23", DateTo: "2026-08-24", ShopFilterHash: "existing"}); err != nil {
|
||||
t.Fatalf("create active run: %v", err)
|
||||
}
|
||||
|
||||
originalSYB := config.ExtConfig.SYB
|
||||
config.ExtConfig.SYB.Username = "test-user"
|
||||
config.ExtConfig.SYB.Password = "test-password"
|
||||
t.Cleanup(func() { config.ExtConfig.SYB = originalSYB })
|
||||
|
||||
result, err := StartImport(context.Background(), db, ImportRequest{DateFrom: "2026-08-23", DateTo: "2026-08-24"}, ImportActor{Name: "系统定时同步"}, true)
|
||||
if err != nil || !result.Skipped {
|
||||
t.Fatalf("result = %+v, err = %v", result, err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.SYBSyncRun{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("sync run count = %d, err = %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package jobs
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
// InitJob
|
||||
@@ -10,7 +12,8 @@ import (
|
||||
// 字典 key 可以配置到 自动任务 调用目标 中;
|
||||
func InitJob() {
|
||||
jobList = map[string]JobExec{
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type HttpJob struct {
|
||||
|
||||
type ExecJob struct {
|
||||
JobCore
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (e *ExecJob) Run() {
|
||||
@@ -46,10 +47,10 @@ func (e *ExecJob) Run() {
|
||||
log.Warn("[Job] ExecJob Run job nil")
|
||||
return
|
||||
}
|
||||
err := CallExec(obj.(JobExec), e.Args)
|
||||
err := CallExecWithDB(obj.(JobExec), e.DB, e.Args)
|
||||
if err != nil {
|
||||
// 如果失败暂停一段时间重试
|
||||
fmt.Println(time.Now().Format(timeFormat), " [ERROR] mission failed! ", err)
|
||||
log.Errorf("[Job] JobCore %s failed: %v", e.Name, err)
|
||||
return
|
||||
}
|
||||
// 结束时间
|
||||
endTime := time.Now()
|
||||
@@ -134,6 +135,7 @@ func setup(key string, db *gorm.DB) {
|
||||
sysJob.EntryId, err = AddJob(crontab, j)
|
||||
} else if jobList[i].JobType == 2 {
|
||||
j := &ExecJob{}
|
||||
j.DB = db
|
||||
j.InvokeTarget = jobList[i].InvokeTarget
|
||||
j.CronExpression = jobList[i].CronExpression
|
||||
j.JobId = jobList[i].JobId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -71,6 +72,9 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error {
|
||||
}
|
||||
} else {
|
||||
var j = &jobs.ExecJob{}
|
||||
// A manually restarted job outlives the HTTP request that created this
|
||||
// service. Detach it from any request context before storing it in cron.
|
||||
j.DB = e.Orm.WithContext(context.Background())
|
||||
j.InvokeTarget = data.InvokeTarget
|
||||
j.CronExpression = data.CronExpression
|
||||
j.JobId = data.JobId
|
||||
|
||||
+15
-1
@@ -1,6 +1,9 @@
|
||||
package jobs
|
||||
|
||||
import "github.com/robfig/cron/v3"
|
||||
import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Run()
|
||||
@@ -11,6 +14,17 @@ type JobExec interface {
|
||||
Exec(arg interface{}) error
|
||||
}
|
||||
|
||||
type JobExecWithDB interface {
|
||||
ExecWithDB(db *gorm.DB, arg interface{}) error
|
||||
}
|
||||
|
||||
func CallExec(e JobExec, arg interface{}) error {
|
||||
return e.Exec(arg)
|
||||
}
|
||||
|
||||
func CallExecWithDB(e JobExec, db *gorm.DB, arg interface{}) error {
|
||||
if withDB, ok := e.(JobExecWithDB); ok {
|
||||
return withDB.ExecWithDB(db, arg)
|
||||
}
|
||||
return e.Exec(arg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type dbAwareExec struct {
|
||||
gotDB *gorm.DB
|
||||
gotArg interface{}
|
||||
}
|
||||
|
||||
func (*dbAwareExec) Exec(interface{}) error { return nil }
|
||||
func (job *dbAwareExec) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
job.gotDB, job.gotArg = db, arg
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCallExecWithDBUsesDatabaseAwarePath(t *testing.T) {
|
||||
db := &gorm.DB{}
|
||||
job := &dbAwareExec{}
|
||||
if err := CallExecWithDB(job, db, "args"); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if job.gotDB != db || job.gotArg != "args" {
|
||||
t.Fatalf("db-aware call = db:%p arg:%v", job.gotDB, job.gotArg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBHourlySyncJob)
|
||||
}
|
||||
|
||||
func migrateSYBHourlySyncJob(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensureSYBHourlySyncJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureSYBHourlySyncJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", sybimport.HourlySyncInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "SYB 每小时自动同步", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.HourlySyncInvokeTarget,
|
||||
Args: `{"lookbackDays":2,"timezone":"Asia/Shanghai"}`,
|
||||
MisfirePolicy: 1, Concurrent: 1, Status: 2,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureSYBHourlySyncJobIsIdempotentAndPreservesAdminChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:syb-hourly-job-migration?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := ensureSYBHourlySyncJob(db); err != nil {
|
||||
t.Fatalf("first ensure: %v", err)
|
||||
}
|
||||
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.HourlySyncInvokeTarget).
|
||||
Updates(map[string]any{"cron_expression": "0 15 * * * *", "status": 1}).Error; err != nil {
|
||||
t.Fatalf("customize: %v", err)
|
||||
}
|
||||
if err := ensureSYBHourlySyncJob(db); err != nil {
|
||||
t.Fatalf("second ensure: %v", err)
|
||||
}
|
||||
var rows []jobsmodels.SysJob
|
||||
if err := db.Where("invoke_target = ?", sybimport.HourlySyncInvokeTarget).Find(&rows).Error; err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].CronExpression != "0 15 * * * *" || rows[0].Status != 1 {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export function importSybProducts(data) {
|
||||
return request({ url: '/api/admin/v1/syb-products/import', method: 'post', data })
|
||||
}
|
||||
|
||||
export function listSybSyncRuns(params) {
|
||||
return request({ url: '/api/admin/v1/syb-products/sync-runs', method: 'get', params })
|
||||
export function listSybSyncRuns(params, options = {}) {
|
||||
return request({ url: '/api/admin/v1/syb-products/sync-runs', method: 'get', params, ...options })
|
||||
}
|
||||
|
||||
export function getSybSyncRun(runId) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-tooltip :disabled="isAdmin" content="只有管理员可以开始导入" placement="bottom"><span><el-button type="primary" :icon="Download" :disabled="!isAdmin" @click="openImport">导入</el-button></span></el-tooltip></el-form-item>
|
||||
<el-form-item><el-button @click="$router.push('/syb-sync-runs/index')">同步记录</el-button></el-form-item>
|
||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||
@@ -41,22 +39,6 @@
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<!-- 从 SYB 导入 -->
|
||||
<el-dialog v-model="importDialog.open" title="从 SYB 导入" width="560px" :close-on-click-modal="false">
|
||||
<el-alert title="按货运单创建日期拉取,只读取 SYB 数据,不会修改 SYB 上的任何内容。重复导入相同明细执行覆盖,不产生重复记录。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-form label-width="96px" class="dialog-form">
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker v-model="importDialog.range" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="起始日期" end-placeholder="结束日期" :disabled-date="disableFutureDate" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="importDialog.spanDays > 7" :title="`已选择 ${importDialog.spanDays} 天。实测一周可达 7000 张以上货运单,范围过大会触发单次同步上限而失败,建议分批导入。`" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="importDialog.saving" title="正在创建后台任务,请稍候。创建后可以关闭弹窗或离开本页。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<template #footer>
|
||||
<el-button :disabled="importDialog.saving" @click="importDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importDialog.saving" :disabled="!importDialog.range || importDialog.range.length !== 2" @click="submitImport">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建采购任务确认 -->
|
||||
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
|
||||
<div v-loading="purchaseDialog.loading">
|
||||
@@ -143,16 +125,16 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, RefreshLeft, Download } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct, importSybProducts, getSybSyncRun } from '@/api/goauto/syb-products'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct, listSybSyncRuns, getSybSyncRun } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
setup() { return { Search, RefreshLeft, Download } },
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
@@ -162,14 +144,12 @@ export default {
|
||||
purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' },
|
||||
importDialog: { open: false, saving: false, range: null, spanDays: 0 },
|
||||
activeRun: null, syncPollTimer: null,
|
||||
detail: { open: false, loading: false, item: null },
|
||||
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
|
||||
formattedRaw() {
|
||||
@@ -177,15 +157,7 @@ export default {
|
||||
try { return JSON.stringify(JSON.parse(this.detail.item.rawJson), null, 2) } catch { return this.detail.item.rawJson }
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 天数必须跟着日期选择实时更新,否则超范围的提示只在打开弹窗那一刻算过一次。
|
||||
'importDialog.range'(range) {
|
||||
if (!range || range.length !== 2) { this.importDialog.spanDays = 0; return }
|
||||
const [from, to] = range.map((d) => new Date(`${d}T00:00:00`))
|
||||
this.importDialog.spanDays = Math.round((to - from) / 86400000) + 1
|
||||
}
|
||||
},
|
||||
created() { this.load() },
|
||||
created() { this.load(); this.loadLatestSyncRun() },
|
||||
beforeUnmount() { this.stopSyncPolling() },
|
||||
methods: {
|
||||
async load(allowNetworkRetry = true) {
|
||||
@@ -262,7 +234,7 @@ export default {
|
||||
},
|
||||
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
syncStatus(status) { return { running: { label: '正在后台导入' }, succeeded: { label: '导入完成' }, failed: { label: '导入失败' }, interrupted: { label: '导入已中断' }}[status] || { label: status || '未知状态' } },
|
||||
syncStatus(status) { return { running: { label: '正在同步' }, succeeded: { label: '同步完成' }, failed: { label: '同步失败' }, interrupted: { label: '同步已中断' }}[status] || { label: status || '未知状态' } },
|
||||
openShopeeDetail(shopeeProductId, targetColor = '') {
|
||||
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
|
||||
const query = { productId: shopeeProductId }
|
||||
@@ -314,33 +286,16 @@ export default {
|
||||
} finally { this.purchaseDialog.saving = false }
|
||||
},
|
||||
|
||||
// ---------------- 从 SYB 导入 ----------------
|
||||
disableFutureDate(date) { return date.getTime() > Date.now() },
|
||||
async openImport() {
|
||||
if (!this.isAdmin) return
|
||||
const shops = await listSybShops({ page: 1, pageSize: 1, enabledOnly: true })
|
||||
if (!shops.data.enabledCount) {
|
||||
await ElMessageBox.confirm('还没有启用任何店铺,导入不会登录 SYB,也不会消耗验证码。请先到店铺管理启用至少一家店铺。', '无法导入', { confirmButtonText: '去店铺管理', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(() => this.$router.push('/syb-shops/index'))
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
const today = new Date()
|
||||
const start = new Date(today.getTime() - 2 * 86400000)
|
||||
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
this.importDialog = { open: true, saving: false, range: [fmt(start), fmt(today)], spanDays: 3 }
|
||||
},
|
||||
async submitImport() {
|
||||
const [dateFrom, dateTo] = this.importDialog.range
|
||||
this.importDialog.saving = true
|
||||
// ---------------- SYB 自动同步状态 ----------------
|
||||
async loadLatestSyncRun() {
|
||||
try {
|
||||
const r = await importSybProducts({ dateFrom, dateTo })
|
||||
this.importDialog.open = false
|
||||
this.activeRun = { id: r.data.runId, status: r.data.status, dateFrom, dateTo, daysProcessed: 0, daysTotal: this.importDialog.spanDays, detailCount: 0 }
|
||||
ElMessage.success('已开始后台导入,可以继续使用本页面')
|
||||
this.startSyncPolling(r.data.runId)
|
||||
} finally {
|
||||
this.importDialog.saving = false
|
||||
const response = await listSybSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
|
||||
const latest = Array.isArray(response.data?.items) ? response.data.items[0] : null
|
||||
if (!latest) return
|
||||
this.activeRun = latest
|
||||
if (latest.status === 'running') this.startSyncPolling(latest.id)
|
||||
} catch {
|
||||
// 同步状态是辅助信息,失败不能阻断商品列表。
|
||||
}
|
||||
},
|
||||
startSyncPolling(runId) {
|
||||
@@ -404,7 +359,7 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.page-card { min-height: calc(100vh - 124px); }
|
||||
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
||||
.search-form{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
||||
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
||||
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
||||
@@ -422,7 +377,6 @@ export default {
|
||||
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.notice{margin-bottom:16px}
|
||||
.compact-notice{margin-bottom:12px}
|
||||
.dialog-form{margin-bottom:8px}
|
||||
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.purchase-note{margin:0 0 16px}
|
||||
.drawer-body{padding:0 4px 24px}
|
||||
@@ -431,5 +385,6 @@ export default {
|
||||
.section-title{margin:0 0 12px}
|
||||
.drawer-actions{display:flex;gap:12px;margin-top:16px}
|
||||
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,8 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-products/index')
|
||||
await expect(page.getByRole('button', { name: '店铺管理' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '导入', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '同步记录', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '重新解析', exact: true })).toHaveCount(0)
|
||||
@@ -28,12 +29,12 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
|
||||
await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域')
|
||||
await expect(shopeeTitle).toHaveCSS('text-overflow', 'ellipsis')
|
||||
|
||||
const importButton = await page.getByRole('button', { name: '导入', exact: true }).boundingBox()
|
||||
const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox()
|
||||
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
|
||||
const reset = await page.getByRole('button', { name: '重置' }).boundingBox()
|
||||
const purchase = await page.getByRole('button', { name: '创建采购', exact: true }).boundingBox()
|
||||
expect(importButton).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
|
||||
expect(Math.abs((importButton?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
|
||||
expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
|
||||
expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
|
||||
expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2)
|
||||
expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0))
|
||||
await expect(page.getByRole('button', { name: '创建采购', exact: true })).toBeDisabled()
|
||||
@@ -87,3 +88,25 @@ test('店铺选项加载失败时仍可输入历史店铺查询', async({ page,
|
||||
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
|
||||
expect(listURLs.at(-1)?.searchParams.get('shopName')).toBe('已删除店铺')
|
||||
})
|
||||
|
||||
test('页面自动发现运行中的 SYB 同步并在完成后刷新商品', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let productCalls = 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/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 91, status: 'running', dateFrom: '2026-08-23', dateTo: '2026-08-24', daysProcessed: 1, daysTotal: 2, detailCount: 20 }], total: 1, page: 1, pageSize: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs/91')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
return route.fulfill({ json: { code: 200, data: { item: { id: 91, status: 'succeeded', dateFrom: '2026-08-23', dateTo: '2026-08-24', daysProcessed: 2, daysTotal: 2, detailCount: 40 }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { productCalls += 1; return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}}) }
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-products/index')
|
||||
await expect(page.getByText('正在同步', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('strong').filter({ hasText: '同步完成' })).toBeVisible()
|
||||
await expect.poll(() => productCalls).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user