feat(#48): add SYB sync orchestration, import endpoint and page entry
Stage 2. Completes the path from the SYB API to the archive. sybimport.Sync pages a date range day by day and folds each detail line through the existing idempotent ApplyDetail. The paging loop is driven by listTotal, never by the list response's own total, which live data confirmed is the page's row count rather than the filtered total. A day whose completeness cannot be proven — a short page, a total that drifted while paging, a detail response missing an order — stops the run instead of reporting a partial import as a whole one. Rows already written stay: they are idempotent on (order_code, detail_id), so a re-run overwrites. sybimport.Connect reuses the cached session and only discards it when SYB explicitly says it is invalid. Caching the numeric user id alongside the cookies is required, not incidental: session validation calls /am/user/get?id=, and a wrong id comes back as a business error rather than a logout, so cookies alone can never be revalidated. The import endpoint is single-flight and detached from the request context, so closing the tab cannot abandon a half-finished range. It reads only; no SYB write endpoint is reachable from GoAuto. The page swaps its "no import entry" warning for a date-range dialog that warns when the span is wide — a single week held over 7000 orders — and reports partial progress when a run fails midway. Verified: go build, go vet, go test ./... all pass. The drift check was mutation-tested. Not verified: the MySQL migration for syb_session, and any browser walkthrough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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: 945d51155a72ea5b41f600262590cf928de3f5ab
|
||||
synchronized_at: 2026-08-19T08:15:27Z
|
||||
wiki_revision: a94218dc2c7cbf1c5d56af808a02bf65aaa19964
|
||||
synchronized_at: 2026-08-19T08:53:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -81,7 +81,8 @@ Android Portal/Agent
|
||||
| PDD 商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700500000_pdd_product_archive.go` |
|
||||
| 虾皮商品档案与规格映射 | `server/app/goauto/shopeeproduct/`(服务端 API 已实现;Admin 页面待实现) |
|
||||
| 虾皮商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700600000_shopee_product_archive.go` |
|
||||
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、管理端 API 已实现;SYB 接口拉取客户端未实现,无凭据;Admin 页面已实现) |
|
||||
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
|
||||
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
|
||||
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
|
||||
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
|
||||
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
|
||||
|
||||
@@ -303,6 +303,12 @@ type SYBSession struct {
|
||||
// Username is the SYB account this session belongs to. It is the business
|
||||
// key: re-logging in as the same account overwrites the same row.
|
||||
Username string `json:"username" gorm:"size:128;not null;uniqueIndex:ux_syb_session_username"`
|
||||
// UserID is SYB's own numeric account id, returned by login. It must be
|
||||
// cached alongside the cookies: session validation calls
|
||||
// /am/user/get?id=<userID>, and that endpoint rejects a wrong id with a
|
||||
// business error rather than a "not logged in" one — so without the real
|
||||
// id a restored session can never be validated and the cache is useless.
|
||||
UserID int64 `json:"userId" gorm:"not null;default:0"`
|
||||
// CookiesJSON is the cookie jar serialised by sybclient.Client.ExportCookiesJSON.
|
||||
// It is a credential-equivalent secret: never log it, never return it over HTTP.
|
||||
CookiesJSON string `json:"-" gorm:"type:text;not null"`
|
||||
|
||||
@@ -30,25 +30,41 @@ type SessionStore struct{ db *gorm.DB }
|
||||
|
||||
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// Session is one cached SYB login.
|
||||
type Session struct {
|
||||
Username string
|
||||
// UserID is SYB's numeric account id from the login response. CheckSession
|
||||
// needs it, so caching cookies without it would make the cache unusable.
|
||||
UserID int64
|
||||
CookiesJSON string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Save replaces the whole session row for username. Replacing rather than
|
||||
// merging is deliberate: a partial update could leave a cookie from a previous
|
||||
// session alongside the new one, and the two jars are not interchangeable.
|
||||
func (s *SessionStore) Save(ctx context.Context, username, cookiesJSON string, expiresAt time.Time) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
func (s *SessionStore) Save(ctx context.Context, session Session) error {
|
||||
session.Username = strings.TrimSpace(session.Username)
|
||||
if session.Username == "" {
|
||||
return fmt.Errorf("顺云宝账号不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cookiesJSON) == "" {
|
||||
if strings.TrimSpace(session.CookiesJSON) == "" {
|
||||
return fmt.Errorf("顺云宝会话 Cookie 不能为空")
|
||||
}
|
||||
if expiresAt.IsZero() {
|
||||
if session.ExpiresAt.IsZero() {
|
||||
return fmt.Errorf("顺云宝会话必须有过期时间")
|
||||
}
|
||||
if session.UserID <= 0 {
|
||||
return fmt.Errorf("顺云宝会话必须带上登录返回的 user id,否则恢复后无法校验")
|
||||
}
|
||||
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "username"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"cookies_json", "expires_at", "updated_at"}),
|
||||
DoUpdates: clause.AssignmentColumns([]string{"user_id", "cookies_json", "expires_at", "updated_at"}),
|
||||
}).Create(&models.SYBSession{
|
||||
Username: username, CookiesJSON: cookiesJSON, ExpiresAt: expiresAt.UTC(),
|
||||
Username: session.Username,
|
||||
UserID: session.UserID,
|
||||
CookiesJSON: session.CookiesJSON,
|
||||
ExpiresAt: session.ExpiresAt.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -60,19 +76,22 @@ func (s *SessionStore) Save(ctx context.Context, username, cookiesJSON string, e
|
||||
// expired jar would only produce a confusing "未登录" error one request later.
|
||||
// The row is not deleted here: Load must stay safe to call concurrently, and
|
||||
// the next successful login overwrites it anyway.
|
||||
func (s *SessionStore) Load(ctx context.Context, username string, now time.Time) (string, time.Time, error) {
|
||||
func (s *SessionStore) Load(ctx context.Context, username string, now time.Time) (Session, error) {
|
||||
var record models.SYBSession
|
||||
err := s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(username)).First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", time.Time{}, ErrNoSession
|
||||
return Session{}, ErrNoSession
|
||||
}
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
return Session{}, err
|
||||
}
|
||||
if !now.UTC().Before(record.ExpiresAt.UTC()) {
|
||||
return "", record.ExpiresAt, ErrNoSession
|
||||
return Session{}, ErrNoSession
|
||||
}
|
||||
return record.CookiesJSON, record.ExpiresAt, nil
|
||||
return Session{
|
||||
Username: record.Username, UserID: record.UserID,
|
||||
CookiesJSON: record.CookiesJSON, ExpiresAt: record.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete drops the cached session for username. Callers use it after the
|
||||
|
||||
@@ -36,18 +36,22 @@ func TestSaveThenLoadRoundTripsCookies(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
expires := now.Add(24 * time.Hour)
|
||||
|
||||
if err := store.Save(ctx, "operator", `[{"name":"SESSION","value":"abc"}]`, expires); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: "operator", UserID: 4242, CookiesJSON: `[{"name":"SESSION","value":"abc"}]`, ExpiresAt: expires}); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
cookies, gotExpires, err := store.Load(ctx, "operator", now)
|
||||
got, err := store.Load(ctx, "operator", now)
|
||||
if err != nil {
|
||||
t.Fatalf("读取会话失败: %v", err)
|
||||
}
|
||||
if cookies != `[{"name":"SESSION","value":"abc"}]` {
|
||||
t.Fatalf("Cookie 内容不一致: %q", cookies)
|
||||
if got.CookiesJSON != `[{"name":"SESSION","value":"abc"}]` {
|
||||
t.Fatalf("Cookie 内容不一致: %q", got.CookiesJSON)
|
||||
}
|
||||
if !gotExpires.Equal(expires) {
|
||||
t.Fatalf("过期时间不一致: 期望 %v,实际 %v", expires, gotExpires)
|
||||
if !got.ExpiresAt.Equal(expires) {
|
||||
t.Fatalf("过期时间不一致: 期望 %v,实际 %v", expires, got.ExpiresAt)
|
||||
}
|
||||
// 没有 user id 就没法调 /am/user/get 校验会话,缓存等于白存。
|
||||
if got.UserID != 4242 {
|
||||
t.Fatalf("user id 没有被缓存: %d", got.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,21 +61,21 @@ func TestSaveReplacesExistingSessionForSameAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := store.Save(ctx, "operator", `["old"]`, now.Add(time.Hour)); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: "operator", UserID: 4242, CookiesJSON: `["old"]`, ExpiresAt: now.Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("首次保存失败: %v", err)
|
||||
}
|
||||
if err := store.Save(ctx, "operator", `["new"]`, now.Add(24*time.Hour)); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: "operator", UserID: 4242, CookiesJSON: `["new"]`, ExpiresAt: now.Add(24 * time.Hour)}); err != nil {
|
||||
t.Fatalf("覆盖保存失败: %v", err)
|
||||
}
|
||||
cookies, gotExpires, err := store.Load(ctx, "operator", now)
|
||||
got, err := store.Load(ctx, "operator", now)
|
||||
if err != nil {
|
||||
t.Fatalf("读取会话失败: %v", err)
|
||||
}
|
||||
if cookies != `["new"]` {
|
||||
t.Fatalf("旧 Cookie 没有被覆盖: %q", cookies)
|
||||
if got.CookiesJSON != `["new"]` {
|
||||
t.Fatalf("旧 Cookie 没有被覆盖: %q", got.CookiesJSON)
|
||||
}
|
||||
if !gotExpires.Equal(now.Add(24 * time.Hour)) {
|
||||
t.Fatalf("过期时间没有被覆盖: %v", gotExpires)
|
||||
if !got.ExpiresAt.Equal(now.Add(24 * time.Hour)) {
|
||||
t.Fatalf("过期时间没有被覆盖: %v", got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +86,10 @@ func TestLoadTreatsExpiredSessionAsAbsent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := store.Save(ctx, "operator", `["jar"]`, now.Add(time.Hour)); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: "operator", UserID: 4242, CookiesJSON: `["jar"]`, ExpiresAt: now.Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
if _, _, err := store.Load(ctx, "operator", now.Add(2*time.Hour)); !errors.Is(err, ErrNoSession) {
|
||||
if _, err := store.Load(ctx, "operator", now.Add(2*time.Hour)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("过期会话应报 ErrNoSession,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -97,17 +101,17 @@ func TestLoadTreatsSessionExactlyAtExpiryAsAbsent(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
expires := now.Add(time.Hour)
|
||||
|
||||
if err := store.Save(ctx, "operator", `["jar"]`, expires); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: "operator", UserID: 4242, CookiesJSON: `["jar"]`, ExpiresAt: expires}); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
if _, _, err := store.Load(ctx, "operator", expires); !errors.Is(err, ErrNoSession) {
|
||||
if _, err := store.Load(ctx, "operator", expires); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("正好到期的会话应报 ErrNoSession,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsErrNoSessionWhenNeverSaved(t *testing.T) {
|
||||
store := NewSessionStore(newSessionTestDB(t))
|
||||
if _, _, err := store.Load(context.Background(), "nobody", time.Now()); !errors.Is(err, ErrNoSession) {
|
||||
if _, err := store.Load(context.Background(), "nobody", time.Now()); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("未保存过的账号应报 ErrNoSession,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -118,17 +122,17 @@ func TestDeleteRemovesOnlyTheNamedAccount(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, name := range []string{"alice", "bob"} {
|
||||
if err := store.Save(ctx, name, `["jar"]`, now.Add(time.Hour)); err != nil {
|
||||
if err := store.Save(ctx, Session{Username: name, UserID: 4242, CookiesJSON: `["jar"]`, ExpiresAt: now.Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("保存 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := store.Delete(ctx, "alice"); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
if _, _, err := store.Load(ctx, "alice", now); !errors.Is(err, ErrNoSession) {
|
||||
if _, err := store.Load(ctx, "alice", now); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("alice 应已被删除,实际: %v", err)
|
||||
}
|
||||
if _, _, err := store.Load(ctx, "bob", now); err != nil {
|
||||
if _, err := store.Load(ctx, "bob", now); err != nil {
|
||||
t.Fatalf("bob 不应受影响,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -140,14 +144,15 @@ func TestSaveRejectsIncompleteInput(t *testing.T) {
|
||||
valid := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, c := range []struct {
|
||||
name, username, cookies string
|
||||
expires time.Time
|
||||
name string
|
||||
session Session
|
||||
}{
|
||||
{"空账号", " ", `["jar"]`, valid},
|
||||
{"空Cookie", "operator", " ", valid},
|
||||
{"零过期时间", "operator", `["jar"]`, time.Time{}},
|
||||
{"空账号", Session{Username: " ", UserID: 1, CookiesJSON: `["jar"]`, ExpiresAt: valid}},
|
||||
{"空Cookie", Session{Username: "operator", UserID: 1, CookiesJSON: " ", ExpiresAt: valid}},
|
||||
{"零过期时间", Session{Username: "operator", UserID: 1, CookiesJSON: `["jar"]`}},
|
||||
{"缺userID", Session{Username: "operator", CookiesJSON: `["jar"]`, ExpiresAt: valid}},
|
||||
} {
|
||||
if err := store.Save(ctx, c.username, c.cookies, c.expires); err == nil {
|
||||
if err := store.Save(ctx, c.session); err == nil {
|
||||
t.Fatalf("%s 应该被拒绝", c.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// importTimeout bounds one import run. A month-wide range over a busy account
|
||||
// is thousands of remote reads, so the ceiling is generous — but it must exist,
|
||||
// or a stalled SYB response would pin the request forever.
|
||||
const importTimeout = 30 * time.Minute
|
||||
|
||||
// importGate makes imports mutually exclusive. Two concurrent runs over
|
||||
// overlapping dates would race on the same (order_code, detail_id) rows: the
|
||||
// writes are idempotent so the data would survive, but the two reports would
|
||||
// each undercount, and the operator would have no way to tell which is right.
|
||||
var importGate = struct {
|
||||
sync.Mutex
|
||||
running bool
|
||||
}{}
|
||||
|
||||
type ImportRequest struct {
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (handler Handler) Import(c *gin.Context) {
|
||||
var request ImportRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
settings := config.ExtConfig.SYB.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
writeError(c, invalidRequest(
|
||||
"顺云宝账号未配置:请设置环境变量 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD 后重启服务端"))
|
||||
return
|
||||
}
|
||||
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
importGate.Lock()
|
||||
if importGate.running {
|
||||
importGate.Unlock()
|
||||
writeError(c, &ServiceError{Code: CodeInvalidRequest, Message: "已有一个导入任务正在执行,请等它结束后再试"})
|
||||
return
|
||||
}
|
||||
importGate.running = true
|
||||
importGate.Unlock()
|
||||
defer func() {
|
||||
importGate.Lock()
|
||||
importGate.running = false
|
||||
importGate.Unlock()
|
||||
}()
|
||||
|
||||
// Detached from the request context on purpose: an operator closing the tab
|
||||
// must not abort a half-finished import, which would leave a partial range
|
||||
// imported with nobody holding the report that says how far it got.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
|
||||
defer cancel()
|
||||
|
||||
client, err := Connect(ctx, sybclient.NewSessionStore(service.DB), ConnectConfig{
|
||||
BaseURL: settings.BaseURL,
|
||||
Username: settings.Username,
|
||||
Password: settings.Password,
|
||||
OcrURL: settings.OcrURL,
|
||||
OcrMaxAttempts: settings.OcrMaxAttempts,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, &ServiceError{Code: CodeInvalidRequest, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
report, err := Sync(ctx, service.DB, client, SyncConfig{
|
||||
PageSize: settings.PageSize, MaxMatches: settings.MaxMatches,
|
||||
}, request.DateFrom, request.DateTo)
|
||||
if err != nil {
|
||||
// The partial report goes back with the error: rows already written are
|
||||
// kept (a re-run overwrites them), so the operator needs to see how far
|
||||
// it got, not just that it failed.
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"code": CodeInvalidRequest, "message": err.Error(), "data": report,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": report})
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// InitRouter mounts the read/reparse/manual-correct surface only. There is no
|
||||
// create/import endpoint: no SYB credentials or fetch client exist in this
|
||||
// repo yet (see package doc), so rows only appear here once something else
|
||||
// calls sybimport.ApplyDetail directly (currently only from tests). Wiring a
|
||||
// live import trigger is a separate, explicitly deferred piece of work.
|
||||
// InitRouter mounts the read/reparse/manual-correct surface plus the import
|
||||
// trigger (#48). Import is the only route that reaches out to SYB, and it only
|
||||
// reads: no SYB write endpoint is called from anywhere in GoAuto.
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
admin := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
@@ -19,5 +17,6 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.GET("/:productId", handler.Detail)
|
||||
admin.POST("/:productId/reparse", handler.Reparse)
|
||||
admin.POST("/reparse-batch", handler.ReparseBatch)
|
||||
admin.POST("/import", handler.Import)
|
||||
admin.PATCH("/:productId/correction", handler.ManualCorrect)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// gin 在注册阶段就会因为路由冲突 panic,编译期发现不了。
|
||||
// /import 和 /:productId 处在同一层,必须确认两者能共存。
|
||||
func TestRoutesRegisterWithoutConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
handler := Handler{}
|
||||
group := engine.Group("/api/admin/v1/syb-products")
|
||||
group.GET("", handler.List)
|
||||
group.GET("/:productId", handler.Detail)
|
||||
group.POST("/:productId/reparse", handler.Reparse)
|
||||
group.POST("/reparse-batch", handler.ReparseBatch)
|
||||
group.POST("/import", handler.Import)
|
||||
|
||||
want := map[string]bool{
|
||||
"GET /api/admin/v1/syb-products": false,
|
||||
"GET /api/admin/v1/syb-products/:productId": false,
|
||||
"POST /api/admin/v1/syb-products/:productId/reparse": false,
|
||||
"POST /api/admin/v1/syb-products/reparse-batch": false,
|
||||
"POST /api/admin/v1/syb-products/import": false,
|
||||
}
|
||||
for _, route := range engine.Routes() {
|
||||
want[route.Method+" "+route.Path] = true
|
||||
}
|
||||
for route, registered := range want {
|
||||
if !registered {
|
||||
t.Fatalf("路由未注册: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncConfig is the subset of the SYB settings the orchestration needs. It is
|
||||
// a plain struct rather than config.SYB so this package does not depend on the
|
||||
// server's configuration wiring, which keeps it testable without settings.yml.
|
||||
type SyncConfig struct {
|
||||
PageSize int
|
||||
// MaxMatches caps how many shipment orders one sync may touch. Real data
|
||||
// makes this matter: a single week held 7404 orders, so a careless range
|
||||
// blows past any sane budget (docs/12-syb-erp-interface.md §8).
|
||||
MaxMatches int
|
||||
}
|
||||
|
||||
const (
|
||||
// detailBatch is SYB's hard limit on listByStock (§6).
|
||||
detailBatch = 100
|
||||
// maxSyncDays bounds one request's window. It is a guard against a typo in
|
||||
// the date range turning into tens of thousands of remote reads before
|
||||
// MaxMatches trips.
|
||||
maxSyncDays = 31
|
||||
)
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
type SyncReport struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
|
||||
// line into the SYB/Shopee archive through ApplyDetail.
|
||||
//
|
||||
// `[必须]` The caller supplies an already-logged-in client. Session handling is
|
||||
// a separate concern (see Connect); a session that dies mid-run surfaces as
|
||||
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
|
||||
//
|
||||
// `[必须]` Failure stops the run immediately. Rows already written are NOT
|
||||
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
|
||||
// re-run overwrites them rather than duplicating. What must not happen is
|
||||
// reporting a partial run as a complete one, which would let the missing
|
||||
// orders go unnoticed forever.
|
||||
//
|
||||
// `[必须]` Every day is verified for completeness before anything is written:
|
||||
// the per-day total is re-read after paging and must not have drifted. SYB's
|
||||
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
|
||||
// live data), so the paging loop is driven by listTotal, never by list.total.
|
||||
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC()}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
maxMatches := cfg.MaxMatches
|
||||
if maxMatches <= 0 {
|
||||
maxMatches = 10000
|
||||
}
|
||||
|
||||
// Pre-flight: total up the whole range before fetching anything, so an
|
||||
// over-budget range fails without having written a single row.
|
||||
type dayPlan struct {
|
||||
date string
|
||||
total int
|
||||
}
|
||||
plans := make([]dayPlan, 0, len(dates))
|
||||
rangeTotal := 0
|
||||
for _, date := range dates {
|
||||
total, err := client.ListTotal(ctx, date, date, pageSize)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("查询 %s 货运单总数失败: %w", date, err)
|
||||
}
|
||||
if total < 0 {
|
||||
return report, fmt.Errorf("查询 %s 货运单总数返回负数 %d", date, total)
|
||||
}
|
||||
rangeTotal += total
|
||||
if rangeTotal > maxMatches {
|
||||
return report, fmt.Errorf(
|
||||
"日期范围 %s ~ %s 的货运单总数已超过单次同步上限 %d(截至 %s 已 %d 张),"+
|
||||
"请缩小日期范围;实测最近 7 天可达 7000 张以上",
|
||||
dateFrom, dateTo, maxMatches, date, rangeTotal)
|
||||
}
|
||||
plans = append(plans, dayPlan{date: date, total: total})
|
||||
}
|
||||
|
||||
for _, plan := range plans {
|
||||
if plan.total == 0 {
|
||||
continue
|
||||
}
|
||||
rows, err := loadDailyList(ctx, client, plan.date, pageSize, plan.total)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.OrderCount += len(rows)
|
||||
|
||||
byID := make(map[int64]sybclient.StockRow, len(rows))
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[start:end]
|
||||
details, err := client.DetailListByStock(ctx, batch)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
|
||||
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
|
||||
}
|
||||
if err := validateDetailBatch(batch, details); err != nil {
|
||||
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
|
||||
}
|
||||
for _, detail := range details {
|
||||
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// loadDailyList pages through one day and refuses to return a list it cannot
|
||||
// prove is complete.
|
||||
//
|
||||
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
|
||||
// list.total is the current page's row count, not the filtered total (§4.3).
|
||||
// Driving the loop with the response's own total would stop after page one.
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
seen := make(map[int64]struct{}, expectedTotal)
|
||||
|
||||
for start := 0; start < expectedTotal; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
page, pageCount, err := client.ListPage(ctx, date, date, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("拉取 %s 货运单列表第 %d 页失败(已获取 %d/%d 张): %w",
|
||||
date, pageIndex, len(rows), expectedTotal, err)
|
||||
}
|
||||
expectedPageCount := pageSize
|
||||
if remaining := expectedTotal - start; remaining < pageSize {
|
||||
expectedPageCount = remaining
|
||||
}
|
||||
if pageCount != expectedPageCount || len(page) != expectedPageCount {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页不完整:预期 %d 行,实际 %d 行;"+
|
||||
"分页期间数据发生变化,本次同步停止", date, pageIndex, expectedPageCount, len(page))
|
||||
}
|
||||
for _, row := range page {
|
||||
if _, duplicate := seen[row.ID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[row.ID] = struct{}{}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read the total: if it moved while we paged, some order was inserted or
|
||||
// removed underneath us and the snapshot we hold has a hole in it.
|
||||
afterTotal, err := client.ListTotal(ctx, date, date, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("分页后重新查询 %s 货运单总数失败: %w", date, err)
|
||||
}
|
||||
if afterTotal != expectedTotal {
|
||||
return nil, fmt.Errorf("%s 货运单总数在分页期间从 %d 变为 %d,本次同步停止",
|
||||
date, expectedTotal, afterTotal)
|
||||
}
|
||||
if len(rows) != expectedTotal {
|
||||
return nil, fmt.Errorf("%s 货运单列表不完整:预期 %d 张,分页后只有 %d 个唯一 ID",
|
||||
date, expectedTotal, len(rows))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// validateDetailBatch rejects a detail response that does not exactly answer
|
||||
// what was asked. Silently accepting a short response would drop shipment
|
||||
// orders from the import while still reporting success.
|
||||
func validateDetailBatch(requested []int64, details []sybclient.StockDetail) error {
|
||||
wanted := make(map[int64]struct{}, len(requested))
|
||||
for _, id := range requested {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(details))
|
||||
for _, detail := range details {
|
||||
if _, ok := wanted[detail.ID]; !ok {
|
||||
return fmt.Errorf("响应包含未请求的货运单 id=%d", detail.ID)
|
||||
}
|
||||
if _, duplicate := seen[detail.ID]; duplicate {
|
||||
return fmt.Errorf("响应重复返回货运单 id=%d", detail.ID)
|
||||
}
|
||||
seen[detail.ID] = struct{}{}
|
||||
}
|
||||
for _, id := range requested {
|
||||
if _, ok := seen[id]; !ok {
|
||||
return fmt.Errorf("响应缺少货运单 id=%d", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyStockDetail writes one shipment order's items. Each item is its own
|
||||
// ApplyDetail call and its own transaction — a several-thousand-row sync must
|
||||
// not sit in one long-held transaction.
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport) error {
|
||||
order := OrderInput{
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
ShopName: stringField(row.Raw, "shopName"),
|
||||
}
|
||||
if order.Code == "" {
|
||||
order.Code = row.Code
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("货运单 %s 明细 %d 原始数据编码失败: %w", order.Code, item.ID, err)
|
||||
}
|
||||
result, err := ApplyDetail(ctx, db, order, DetailInput{
|
||||
ID: uint64(item.ID),
|
||||
ProductID: uint64(item.ProductID),
|
||||
ProductQty: int64(item.ProductQty),
|
||||
ProductPrice: item.ProductPrice,
|
||||
ProductSpec: item.ProductSpec,
|
||||
ProductTitle: item.ProductTitle,
|
||||
ProductThumb: uint64(item.ProductThumb),
|
||||
Raw: raw,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w",
|
||||
order.Code, item.ID, err)
|
||||
}
|
||||
report.DetailCount++
|
||||
switch result.Outcome {
|
||||
case OutcomeCreated:
|
||||
report.Created++
|
||||
case OutcomeUpdated:
|
||||
report.Updated++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringField(raw map[string]any, key string) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := raw[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// splitDateRange expands an inclusive YYYY-MM-DD range into single days,
|
||||
// because SYB's totals and paging are only self-consistent within one day.
|
||||
func splitDateRange(dateFrom, dateTo string) ([]string, error) {
|
||||
from, err := time.Parse("2006-01-02", dateFrom)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("起始日期格式应为 YYYY-MM-DD: %q", dateFrom)
|
||||
}
|
||||
to, err := time.Parse("2006-01-02", dateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("结束日期格式应为 YYYY-MM-DD: %q", dateTo)
|
||||
}
|
||||
if to.Before(from) {
|
||||
return nil, fmt.Errorf("结束日期 %s 不能早于起始日期 %s", dateTo, dateFrom)
|
||||
}
|
||||
dates := make([]string, 0, 8)
|
||||
for day := from; !day.After(to); day = day.AddDate(0, 0, 1) {
|
||||
dates = append(dates, day.Format("2006-01-02"))
|
||||
if len(dates) > maxSyncDays {
|
||||
return nil, fmt.Errorf("单次同步最多 %d 天,请缩小日期范围", maxSyncDays)
|
||||
}
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
// Connect returns a logged-in client, reusing the cached session when one is
|
||||
// still valid so a routine sync does not burn a captcha round-trip.
|
||||
//
|
||||
// `[必须]` A cached session is only discarded when SYB explicitly says it is
|
||||
// invalid. A timeout or a 5xx leaves it in place (§3.5): treating a network
|
||||
// blip as a logout would trigger needless logins and could throw away a
|
||||
// perfectly good session.
|
||||
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
|
||||
}
|
||||
client, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached, err := store.Load(ctx, cfg.Username, time.Now())
|
||||
switch {
|
||||
case err == nil:
|
||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
|
||||
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
|
||||
return client, nil
|
||||
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
|
||||
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
|
||||
return nil, delErr
|
||||
}
|
||||
}
|
||||
}
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
// Nothing cached; fall through to a fresh login.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.OcrURL == "" {
|
||||
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
|
||||
}
|
||||
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||
}
|
||||
jar, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Save(ctx, sybclient.Session{
|
||||
Username: cfg.Username, UserID: result.User.ID,
|
||||
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ConnectConfig carries the login settings. The password is passed through and
|
||||
// never persisted: only cookies are cached.
|
||||
type ConnectConfig struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
OcrURL string
|
||||
OcrMaxAttempts int
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func newSyncTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on",
|
||||
strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := migrations.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// fakeSYB is a stand-in for the SYB list/detail endpoints, deliberately
|
||||
// reproducing the quirk confirmed against live data: /am/stock/list returns the
|
||||
// CURRENT PAGE's row count in `total`, while /am/stock/listTotal returns the
|
||||
// filtered total (§4.3).
|
||||
type fakeSYB struct {
|
||||
perDay map[string]int // date -> order count
|
||||
// totalAfterPaging, when non-nil, overrides listTotal from the Nth call on,
|
||||
// simulating orders appearing or disappearing mid-sync.
|
||||
listTotalCalls int
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
}
|
||||
|
||||
func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/am/stock/listTotal", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
date := f.dateOf(body)
|
||||
f.listTotalCalls++
|
||||
if override, ok := f.totalOverride[f.listTotalCalls]; ok {
|
||||
writeEnvelope(w, override)
|
||||
return
|
||||
}
|
||||
writeEnvelope(w, f.perDay[date])
|
||||
})
|
||||
mux.HandleFunc("/am/stock/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
date := f.dateOf(body)
|
||||
start := int(body["start"].(float64))
|
||||
pageSize := int(body["length"].(float64))
|
||||
total := f.perDay[date]
|
||||
|
||||
rows := []map[string]any{}
|
||||
for i := start; i < total && len(rows) < pageSize; i++ {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": float64(1000 + i),
|
||||
"code": fmt.Sprintf("ORD%s%03d", strings.ReplaceAll(date, "-", ""), i),
|
||||
"shopName": "测试店铺",
|
||||
})
|
||||
}
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
rows = rows[:len(rows)-1]
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
})
|
||||
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
list := []map[string]any{}
|
||||
for _, id := range body.IDs {
|
||||
if id == f.detailDropID {
|
||||
continue
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": id,
|
||||
"code": fmt.Sprintf("ORD-%d", id),
|
||||
"details": []any{map[string]any{
|
||||
"id": float64(id*10 + 1), "productId": float64(9001),
|
||||
"productTitle": "测试商品", "productSpec": "白色,L",
|
||||
"productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77),
|
||||
}},
|
||||
})
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": list})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func (f *fakeSYB) dateOf(body map[string]any) string {
|
||||
queries, _ := body["queries"].([]any)
|
||||
for _, q := range queries {
|
||||
m, _ := q.(map[string]any)
|
||||
if value, ok := m["dvalue"].(string); ok && strings.Contains(value, ",") {
|
||||
return strings.Split(value, ",")[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeEnvelope(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": true, "msg": "ok", "data": data})
|
||||
}
|
||||
|
||||
func newSyncClient(t *testing.T, f *fakeSYB) *sybclient.Client {
|
||||
t.Helper()
|
||||
client, err := sybclient.New(f.server(t).URL)
|
||||
if err != nil {
|
||||
t.Fatalf("创建客户端失败: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// 分页必须由 listTotal 驱动。若误用 list 响应里的 total 当筛选总数,
|
||||
// 循环会在第一页就停下——这是 §4.3 最容易踩的坑。
|
||||
func TestSyncPagesBeyondTheFirstPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 25}}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.OrderCount != 25 {
|
||||
t.Fatalf("应拉到 25 张货运单(3 页),实际 %d——分页很可能停在第一页", report.OrderCount)
|
||||
}
|
||||
if report.DetailCount != 25 {
|
||||
t.Fatalf("应写入 25 条明细,实际 %d", report.DetailCount)
|
||||
}
|
||||
if report.Created != 25 {
|
||||
t.Fatalf("首次同步应全部是新增,实际 %d", report.Created)
|
||||
}
|
||||
}
|
||||
|
||||
// 重跑同一范围必须幂等:按 (order_code, detail_id) 覆盖,不产生重复行。
|
||||
func TestSyncIsIdempotentAcrossRuns(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 5}}
|
||||
cfg := SyncConfig{PageSize: 10, MaxMatches: 1000}
|
||||
|
||||
if _, err := Sync(context.Background(), db, newSyncClient(t, f), cfg, "2026-08-01", "2026-08-01"); err != nil {
|
||||
t.Fatalf("首次同步失败: %v", err)
|
||||
}
|
||||
second, err := Sync(context.Background(), db, newSyncClient(t, f), cfg, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("重跑失败: %v", err)
|
||||
}
|
||||
if second.Created != 0 || second.Updated != 5 {
|
||||
t.Fatalf("重跑应全部是更新,实际 created=%d updated=%d", second.Created, second.Updated)
|
||||
}
|
||||
var count int64
|
||||
db.Table("syb_product").Count(&count)
|
||||
if count != 5 {
|
||||
t.Fatalf("重跑不应产生重复行,实际 %d 行", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 超过上限必须在**写库之前**失败,否则会留下一半数据还报错。
|
||||
func TestSyncRefusesOverBudgetRangeBeforeWritingAnything(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 40, "2026-08-02": 40}}
|
||||
_, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 50}, "2026-08-01", "2026-08-02")
|
||||
if err == nil {
|
||||
t.Fatal("超过上限应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "上限") {
|
||||
t.Fatalf("错误信息应说明超过上限: %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Table("syb_product").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("超限时不应写入任何数据,实际 %d 行", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页期间总数变化说明快照有洞,必须停下,不能报告成功。
|
||||
func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{
|
||||
perDay: map[string]int{"2026-08-01": 10},
|
||||
// 第 1 次是预检,第 2 次是分页后的复核——让它返回不同的数。
|
||||
totalOverride: map[int]int{2: 11},
|
||||
}
|
||||
_, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err == nil {
|
||||
t.Fatal("总数漂移应报错,不能当成同步成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "变为") {
|
||||
t.Fatalf("错误信息应说明总数发生变化: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 某页行数少于预期同样是不完整快照。
|
||||
func TestSyncStopsOnShortPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 25}, shortPageAtIndex: 2}
|
||||
_, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err == nil {
|
||||
t.Fatal("缺行的分页应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不完整") {
|
||||
t.Fatalf("错误信息应说明列表不完整: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 明细响应少返回一张货运单时必须报错。静默接受会让这张单永远漏掉,
|
||||
// 而同步却报告成功。
|
||||
func TestSyncStopsWhenDetailResponseIsMissingAnOrder(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3}, detailDropID: 1001}
|
||||
_, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err == nil {
|
||||
t.Fatal("明细缺货运单应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "缺少货运单") {
|
||||
t.Fatalf("错误信息应指出缺了哪张货运单: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSpansMultipleDays(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 0, "2026-08-03": 2}}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-03")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.OrderCount != 5 {
|
||||
t.Fatalf("三天合计应为 5 张,实际 %d", report.OrderCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDateRangeRejectsBadInput(t *testing.T) {
|
||||
for _, c := range []struct{ name, from, to string }{
|
||||
{"格式错误", "2026/08/01", "2026-08-02"},
|
||||
{"结束早于开始", "2026-08-05", "2026-08-01"},
|
||||
{"跨度过大", "2026-01-01", "2026-06-01"},
|
||||
} {
|
||||
if _, err := splitDateRange(c.from, c.to); err == nil {
|
||||
t.Fatalf("%s 应该被拒绝", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDateRangeIsInclusive(t *testing.T) {
|
||||
dates, err := splitDateRange("2026-08-01", "2026-08-03")
|
||||
if err != nil {
|
||||
t.Fatalf("拆分失败: %v", err)
|
||||
}
|
||||
if len(dates) != 3 || dates[0] != "2026-08-01" || dates[2] != "2026-08-03" {
|
||||
t.Fatalf("首尾日期都应包含在内: %v", dates)
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,9 @@ export function reparseSybProductsBatch(data) {
|
||||
export function correctSybProduct(productId, data) {
|
||||
return request({ url: `/api/admin/v1/syb-products/${productId}/correction`, method: 'patch', data })
|
||||
}
|
||||
|
||||
// 从 SYB 拉取指定日期范围的货运单并入库。耗时可能很长(一周可达数千张),
|
||||
// 所以单独放大超时,不用 request 的默认值。
|
||||
export function importSybProducts(data) {
|
||||
return request({ url: '/api/admin/v1/syb-products/import', method: 'post', data, timeout: 30 * 60 * 1000 })
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-alert title="本页暂无「从 SYB 导入」入口:服务端尚未接入顺云宝 ERP 的真实接口凭据,导入功能待后续工单补充。当前数据仅能通过服务端内部调用写入。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-button type="primary" :icon="Download" @click="openImport">从 SYB 导入</el-button></el-form-item>
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="货运单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
@@ -30,6 +30,36 @@
|
||||
<p class="scope-note">本页不展示采购任务、PDD 订单号、快递单号和支付状态。</p>
|
||||
</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="importResult.open" title="导入结果" width="560px">
|
||||
<el-alert v-if="importResult.failed" :title="importResult.message" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else title="导入完成" type="success" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="2" border class="dialog-form">
|
||||
<el-descriptions-item label="日期范围">{{ importResult.report.from }} ~ {{ importResult.report.to }}</el-descriptions-item>
|
||||
<el-descriptions-item label="货运单">{{ importResult.report.orderCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="商品明细">{{ importResult.report.detailCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="新增 / 覆盖">{{ importResult.report.created }} / {{ importResult.report.updated }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="importResult.failed" class="muted">中途失败时已写入的数据会保留;按同一范围重新导入会覆盖,不会产生重复记录。</p>
|
||||
<template #footer><el-button type="primary" @click="importResult.open = false">知道了</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量重新解析 -->
|
||||
<el-dialog v-model="reparseBatch.open" title="批量重新解析" width="560px" :close-on-click-modal="false">
|
||||
<el-alert title="适用于解析规则更新后批量重跑,只读取已保存的原始 JSON,不请求 SYB 接口。" type="info" :closable="false" show-icon class="notice" />
|
||||
@@ -104,16 +134,18 @@
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct } from '@/api/goauto/syb-products'
|
||||
import { Search, RefreshLeft, Download } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct, importSybProducts } from '@/api/goauto/syb-products'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
setup() { return { Search, RefreshLeft, Download } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
query: { page: 1, pageSize: 20, keyword: '', parseStatus: '' },
|
||||
importDialog: { open: false, saving: false, range: null, spanDays: 0 },
|
||||
importResult: { open: false, failed: false, message: '', report: {}},
|
||||
reparseBatch: { open: false, saving: false, ids: [], force: false },
|
||||
reparseResult: { open: false, results: [], reparsedCount: 0, skippedCount: 0, unchangedCount: 0 },
|
||||
detail: { open: false, loading: false, item: null },
|
||||
@@ -126,6 +158,14 @@ 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() },
|
||||
methods: {
|
||||
async load() {
|
||||
@@ -153,6 +193,39 @@ export default {
|
||||
window.open(route.href, '_blank', 'noopener')
|
||||
},
|
||||
|
||||
// ---------------- 从 SYB 导入 ----------------
|
||||
disableFutureDate(date) { return date.getTime() > Date.now() },
|
||||
openImport() {
|
||||
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
|
||||
try {
|
||||
const r = await importSybProducts({ dateFrom, dateTo })
|
||||
this.importDialog.open = false
|
||||
this.importResult = { open: true, failed: false, message: '', report: r.data || {}}
|
||||
ElMessage.success('导入完成')
|
||||
this.load()
|
||||
} catch (error) {
|
||||
// 中途失败时服务端仍会带回部分报告,让操作员看到导入到哪一步了。
|
||||
const payload = error?.response?.data || {}
|
||||
this.importDialog.open = false
|
||||
this.importResult = {
|
||||
open: true, failed: true,
|
||||
message: payload.message || error?.message || '导入失败',
|
||||
report: payload.data || {}
|
||||
}
|
||||
// 已经写入的部分要能立刻看到。
|
||||
this.load()
|
||||
} finally {
|
||||
this.importDialog.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- 批量重新解析 ----------------
|
||||
openReparseBatch() { this.reparseBatch = { open: true, saving: false, ids: this.selectedProducts.map(item => item.id), force: false } },
|
||||
async submitReparseBatch() {
|
||||
@@ -224,6 +297,7 @@ export default {
|
||||
.link{color:#1677ff;cursor:pointer}
|
||||
.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}
|
||||
.notice{margin-bottom:16px}
|
||||
.dialog-form{margin-bottom:8px}
|
||||
.purchase-note{margin:0 0 16px}
|
||||
.drawer-body{padding:0 4px 24px}
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
|
||||
Reference in New Issue
Block a user