diff --git a/admin/config.example.yaml b/admin/config.example.yaml index 77b36ac..fd222a9 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -25,10 +25,10 @@ syb: page_size: 20 # 整个日期范围的货运单数上限,默认 10000;超过就停止,不拉列表和明细。 - # 指定日期同步另有最多 31 天限制,自动增量不受天数限制。 + # 页面每次最多同步 31 个自然日。 max_matches: 10000 - # 首次同步的起始日期。之后按「上次同步时间」增量拉,不再用这个值。 + # 兼容旧版自动增量入口的首次起始日期;新版页面默认最近 3 天,不使用此值。 sync_from: "2026-07-01" # 验证码自动识别服务地址(工单 #47)。留空 = 只用手工输入弹窗,不报错。 diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 60b8902..da7b768 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/url" + "strconv" "strings" "time" @@ -59,6 +60,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") return } + history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page"))) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") + return + } status := msg if status == "" { @@ -88,15 +95,25 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, // 真正会登录的只有「同步」这一个按钮。这与 Client 侧 // 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。 needLogin := c.Query("login") == "1" - today := service.SybToday(time.Now()) + now := time.Now() + today := service.SybToday(now) + defaults, err := service.DefaultSybSyncRange(h.db, now) + if err != nil { + fail(c, http.StatusInternalServerError, + "计算默认同步日期失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") + return + } dateFrom := c.Query("date_from") dateTo := c.Query("date_to") - hasSyncRange := dateFrom != "" || dateTo != "" + rangeWarning := "" if dateFrom == "" { - dateFrom = today + dateFrom = defaults.From } if dateTo == "" { - dateTo = today + dateTo = defaults.To + } + if c.Query("date_from") == "" && c.Query("date_to") == "" { + rangeWarning = defaults.Warning } cfg, cfgErr := config.Load() @@ -133,13 +150,39 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "Today": today, "SyncDateFrom": dateFrom, "SyncDateTo": dateTo, - "HasSyncRange": hasSyncRange, - "NeedRangeSync": c.Query("range_sync") == "1", "RangeError": c.Query("range_error"), - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "RangeWarning": rangeWarning, + "SyncHistory": history, + "NeedSyncHistory": c.Query("history") == "1", + "HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages, + keyword, dateFrom, dateTo), + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), })) } +func sybHistoryPagination(pageNum, totalPages int, keyword, dateFrom, dateTo string) service.PaginationView { + link := func(target int) string { + values := url.Values{"history": {"1"}, "history_page": {strconv.Itoa(target)}} + if keyword != "" { + values.Set("order_no", keyword) + } + values.Set("date_from", dateFrom) + values.Set("date_to", dateTo) + return "/syb?" + values.Encode() + } + view := service.PaginationView{ + Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages, + FirstURL: link(1), LastURL: link(totalPages), + } + if view.HasPrev { + view.PrevURL = link(pageNum - 1) + } + if view.HasNext { + view.NextURL = link(pageNum + 1) + } + return view +} + // sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。 // `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。 func sybStatusLine(result *service.SybListResult) string { @@ -189,19 +232,29 @@ func (h *Handler) SybSync(c *gin.Context) { h.sybRedirectLogin(c, reason) return } - if !h.startSybSync(client, *cfg, options) { + started, startErr := h.startSybSync(client, *cfg, options, currentUser(c)) + if startErr != nil { + h.sybRedirect(c, "同步没有启动:"+startErr.Error()) + return + } + if !started { h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果") return } - h.sybRedirect(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果") + h.sybRedirectAfterStart(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果") return } - if !h.startSybSync(client, *cfg, options) { + started, startErr := h.startSybSync(client, *cfg, options, currentUser(c)) + if startErr != nil { + h.sybRedirect(c, "同步没有启动:"+startErr.Error()) + return + } + if !started { h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击") return } - h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果") + h.sybRedirectAfterStart(c, "同步已开始,请稍后刷新页面查看结果") } // attemptSybAutoLogin 尝试用配置的 OCR 服务自动识别验证码并登录。 @@ -304,11 +357,16 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) { log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err) } - if !h.startSybSync(client, *cfg, options) { + started, startErr := h.startSybSync(client, *cfg, options, currentUser(c)) + if startErr != nil { + h.sybRedirect(c, "登录成功,但同步没有启动:"+startErr.Error()) + return + } + if !started { h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果") return } - h.sybRedirect(c, "登录成功,同步已开始,请稍后刷新页面查看结果") + h.sybRedirectAfterStart(c, "登录成功,同步已开始,请稍后刷新页面查看结果") } // startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。 @@ -319,16 +377,27 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) { // // `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()—— // 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。 -func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions) bool { +func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions, actor *model.User) (bool, error) { if !service.TryStartSybSync() { - return false + return false, nil + } + runID, err := service.CreateSybSyncRun(h.db, actor, options, time.Now()) + if err != nil { + service.FinishSybSync(service.SyncReport{Err: err, FinishedAt: time.Now().UTC()}) + return false, err } db := h.db go func() { report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options) + if err := service.FinishSybSyncRun(db, runID, report); err != nil { + log.Printf("syb_sync_run_finish_failed run_id=%s err=%v", runID, err) + if report.Err == nil { + report.Err = fmt.Errorf("同步数据已处理,但保存同步记录失败,请联系维护者核对:%w", err) + } + } service.FinishSybSync(report) }() - return true + return true, nil } // sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。 @@ -346,6 +415,16 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) { c.Redirect(http.StatusSeeOther, target) } +// sybRedirectAfterStart 不保留本次日期。后台任务结束后刷新干净的列表 URL, +// 页面就会按新的覆盖游标给出下一段默认范围,避免误点后重复上一段。 +func (h *Handler) sybRedirectAfterStart(c *gin.Context, msg string) { + params := url.Values{"msg": {msg}} + if keyword := strings.TrimSpace(c.PostForm("order_no")); keyword != "" { + params.Set("order_no", keyword) + } + c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) +} + // sybRedirectLogin 同 sybRedirect,但把 reason 放进 login_reason 参数, // 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要 // 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时 @@ -364,12 +443,11 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) { c.Redirect(http.StatusSeeOther, target) } -// sybRedirectRange 把服务端日期校验错误带回指定日期弹窗。输入值一并保留, -// 操作员只需改错的那一端,不必重新填整张表单。 +// sybRedirectRange 把服务端日期校验错误带回工具栏日期表单。输入值一并保留, +// 操作员只需改错的那一端,不必重新填写。 func (h *Handler) sybRedirectRange(c *gin.Context, message string) { params := url.Values{} appendSybFormState(params, c) - params.Set("range_sync", "1") params.Set("range_error", message) c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) } diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index f14f133..585693f 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -47,7 +47,7 @@ func TestSybRedirectLogin_保留指定日期并打开登录弹窗(t *testing.T) } } -func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T) { +func TestSybRedirectRange_保留输入并显示日期错误(t *testing.T) { context, recorder := sybPostContext(t, url.Values{ "date_from": {"2026-08-09"}, "date_to": {"2026-08-01"}, @@ -59,10 +59,43 @@ func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T) t.Fatalf("解析 Location 失败: %v", err) } query := location.Query() - if query.Get("range_sync") != "1" || query.Get("range_error") == "" { - t.Fatalf("日期错误后应该重开弹窗并显示错误,Location=%s", location.String()) + if query.Get("range_error") == "" { + t.Fatalf("日期错误后应该显示错误,Location=%s", location.String()) } if query.Get("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" { t.Fatalf("日期错误后应该保留两端输入,Location=%s", location.String()) } } + +func TestSybRedirectAfterStart_清除本次日期范围(t *testing.T) { + context, recorder := sybPostContext(t, url.Values{ + "order_no": {"ORDER-1"}, "date_from": {"2026-08-01"}, "date_to": {"2026-08-09"}, + }) + (&Handler{}).sybRedirectAfterStart(context, "同步已开始") + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + query := location.Query() + if query.Get("order_no") != "ORDER-1" || query.Get("msg") != "同步已开始" { + t.Fatalf("跳转应保留搜索和提示: %s", location.String()) + } + if query.Has("date_from") || query.Has("date_to") { + t.Fatalf("启动后不应保留旧日期,否则下一段会误重复: %s", location.String()) + } +} + +func TestSybHistoryPagination_保留筛选并重开弹窗(t *testing.T) { + view := sybHistoryPagination(2, 3, "ORDER-1", "2026-08-01", "2026-08-09") + for _, raw := range []string{view.FirstURL, view.PrevURL, view.NextURL, view.LastURL} { + location, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + query := location.Query() + if query.Get("history") != "1" || query.Get("order_no") != "ORDER-1" || + query.Get("date_from") != "2026-08-01" || query.Get("date_to") != "2026-08-09" { + t.Fatalf("同步记录翻页链接丢失页面状态: %s", raw) + } + } +} diff --git a/admin/main.go b/admin/main.go index 5f69eb4..4677bfc 100644 --- a/admin/main.go +++ b/admin/main.go @@ -16,6 +16,7 @@ import ( "io/fs" "log" "net/http" + "time" "github.com/gin-gonic/gin" @@ -23,6 +24,7 @@ import ( "cmautobuy/admin/handler/api" "cmautobuy/admin/handler/web" "cmautobuy/admin/repository" + "cmautobuy/admin/service" ) // 模板和静态文件打进二进制,这样打包后只有一个 exe, @@ -59,6 +61,13 @@ func main() { if err := repository.CheckSchema(db); err != nil { log.Fatalf("%v", err) } + interrupted, err := service.InterruptRunningSybSyncRuns(db, time.Now()) + if err != nil { + log.Fatalf("恢复上次中断的顺运宝同步记录失败: %v", err) + } + if interrupted > 0 { + log.Printf("已把 %d 条上次进程遗留的顺运宝同步记录标记为中断", interrupted) + } log.Printf("数据库已就绪") // 3. Web 引擎 diff --git a/admin/main_test.go b/admin/main_test.go index 8e6061a..60028f6 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -67,13 +67,16 @@ func TestMainPagesReturnOK(t *testing.T) { sybResponse := httptest.NewRecorder() router.ServeHTTP(sybResponse, sybRequest) for _, want := range []string{ - "指定日期同步", `name="date_from"`, `name="date_to"`, + "同步记录", `name="date_from"`, `name="date_to"`, "按顺运宝货运单创建日期(UTC+8)同步", } { if !strings.Contains(sybResponse.Body.String(), want) { t.Errorf("顺运宝页面缺少 %q", want) } } + if strings.Contains(sybResponse.Body.String(), "指定日期同步") { + t.Error("顺运宝页面不应再显示旧的指定日期同步入口") + } pdd, err := repository.EnsurePddProduct( db, "737116531267", diff --git a/admin/model/model.go b/admin/model/model.go index 23b76cf..d83dee4 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -219,6 +219,36 @@ type SybOrder struct { UpdatedAt string } +// SybSyncRunStatus 是一次顺运宝同步记录的持久化状态。 +type SybSyncRunStatus string + +const ( + SybSyncRunning SybSyncRunStatus = "running" + SybSyncSucceeded SybSyncRunStatus = "succeeded" + SybSyncFailed SybSyncRunStatus = "failed" + SybSyncInterrupted SybSyncRunStatus = "interrupted" +) + +// SybSyncRun 记录一次同步的范围、操作人和最终结果。 +// 这里只保存审计所需的统计和错误摘要,不保存 Cookie、验证码或原始响应。 +type SybSyncRun struct { + RunID string + UserID string + Username string + DateFrom string + DateTo string + Status SybSyncRunStatus + StockCount int + DetailCount int + Created int + Updated int + Skipped int + ErrorMessage string + CursorAdvanced bool + StartedAt string + FinishedAt string +} + // SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。 // // # 为什么主键要带上 PddGoodsID diff --git a/admin/repository/db.go b/admin/repository/db.go index 9bbb7fa..3a7080f 100644 --- a/admin/repository/db.go +++ b/admin/repository/db.go @@ -264,7 +264,7 @@ var migrations = [][]string{ // 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器 // (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个 // 和代码对不上的库静默启动。 -const schemaVersion = 7 +const schemaVersion = 8 // migrationV4 给 PDD 商品增加店铺名。 // @@ -353,6 +353,32 @@ var migrationV7 = []string{ ON client_user_assignments(client_id, started_at DESC);`, } +// migrationV8 持久化顺运宝同步记录,见工单 #59。 +// 只新增表和索引,不修改 v1-v7 的任何已发布语句。 +var migrationV8 = []string{ + `CREATE TABLE syb_sync_runs ( + run_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + date_from TEXT NOT NULL, + date_to TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'interrupted')), + stock_count INTEGER NOT NULL DEFAULT 0, + detail_count INTEGER NOT NULL DEFAULT 0, + created_count INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + cursor_advanced INTEGER NOT NULL DEFAULT 0 CHECK (cursor_advanced IN (0, 1)), + started_at TEXT NOT NULL, + finished_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(user_id), + CHECK ((status = 'running' AND finished_at IS NULL) + OR (status <> 'running' AND finished_at IS NOT NULL)) + );`, + `CREATE INDEX idx_syb_sync_runs_started + ON syb_sync_runs(started_at DESC, run_id DESC);`, +} + // Migrate 把数据库升到最新版本。 // 已经是最新的就什么都不做,可以重复调用。 func Migrate(db *sql.DB) error { @@ -431,6 +457,14 @@ func Migrate(db *sql.DB) error { if err := runSQLMigration(db, 7, migrationV7); err != nil { return err } + reached = 7 + } + + // v8 只新增顺运宝同步记录表和倒序索引。 + if reached < 8 { + if err := runSQLMigration(db, 8, migrationV8); err != nil { + return err + } } return nil @@ -892,7 +926,7 @@ var requiredTables = []string{ "syb_orders", "sku_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", - "users", "web_sessions", "client_user_assignments", + "users", "web_sessions", "client_user_assignments", "syb_sync_runs", } // requiredColumns 只列出不能靠“表存在”发现的关键追加列。 @@ -900,8 +934,9 @@ var requiredTables = []string{ // 查询对应页面会直接失败,因此启动时就应给出明确错误, // 而不是等操作员点到页面才暴露。 var requiredColumns = map[string][]string{ - "pdd_products": {"shop_name"}, - "syb_orders": {"product_spec"}, + "pdd_products": {"shop_name"}, + "syb_orders": {"product_spec"}, + "syb_sync_runs": {"user_id", "date_from", "date_to", "status", "cursor_advanced", "started_at", "finished_at"}, } // CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。 diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go index e2e5cb0..662dd12 100644 --- a/admin/repository/migrate_test.go +++ b/admin/repository/migrate_test.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "testing" + + "cmautobuy/admin/model" ) // 本文件是工单 #20 的核心交付物:证明不管从哪种库起步,迁到最新版本后 @@ -623,11 +625,10 @@ func TestMigrate_v6用户表与v7客户端归属表均存在(t *testing.T) { } } -// #52/#54:显式覆盖每一个已发布版本起点,证明 Client 现实库 -// 不会因为 v6/v7 的新增表而卡在中间版本。v2 的两种历史结构另由上面的收敛 -// 测试持续覆盖;这里验证顺序发布的 v1-v5 路径。 -func TestMigrate_v1到v5均可升级到最新版本(t *testing.T) { - for version := 1; version <= 5; version++ { +// 显式覆盖每一个已发布版本起点,证明现实库不会因为后续新增表而卡住。 +// v2 的两种历史结构另由上面的收敛测试持续覆盖。 +func TestMigrate_v1到v7均可升级到最新版本(t *testing.T) { + for version := 1; version <= 7; version++ { t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) { db := newPublishedVersionDB(t, version) if err := Migrate(db); err != nil { @@ -647,6 +648,41 @@ func TestMigrate_v1到v5均可升级到最新版本(t *testing.T) { } } +func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) { + db := newPublishedVersionDB(t, 7) + if err := Migrate(db); err != nil { + t.Fatalf("v7 升级到 v8 失败: %v", err) + } + if !existingTableSet(t, db)["syb_sync_runs"] { + t.Fatal("v8 应该新增 syb_sync_runs 表") + } + var version int + if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != 8 { + t.Fatalf("迁移版本错误: version=%d err=%v", version, err) + } + user := model.User{ + UserID: "USR-MIGRATE", Username: "buyer", PasswordHash: "test-hash", + Role: model.RolePurchaser, Status: model.UserActive, + PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(), + } + if err := CreateInitialAdmin(db, user); err != nil { + t.Fatalf("准备同步记录外键用户失败: %v", err) + } + // 状态和 running/finished_at 对应关系必须由数据库兜底,不能只靠 Go 校验。 + if _, err := db.Exec(`INSERT INTO syb_sync_runs + (run_id, user_id, date_from, date_to, status, started_at, finished_at) + VALUES ('bad', ?, '2026-08-09', '2026-08-09', 'unknown', ?, ?)`, + user.UserID, model.NowISO(), model.NowISO()); err == nil { + t.Fatal("非法同步状态应该被数据库约束拒绝") + } + if _, err := db.Exec(`INSERT INTO syb_sync_runs + (run_id, user_id, date_from, date_to, status, started_at, finished_at) + VALUES ('bad-running', ?, '2026-08-09', '2026-08-09', 'running', ?, ?)`, + user.UserID, model.NowISO(), model.NowISO()); err == nil { + t.Fatal("running 状态不应允许填写完成时间") + } +} + func newPublishedVersionDB(t *testing.T, version int) *sql.DB { t.Helper() db, err := Open(t.TempDir()) @@ -683,6 +719,16 @@ func newPublishedVersionDB(t *testing.T, version int) *sql.DB { t.Fatalf("构造 v%d 时执行 v5 失败: %v", version, err) } } + if version >= 6 { + if err := runSQLMigration(db, 6, migrationV6); err != nil { + t.Fatalf("构造 v%d 时执行 v6 失败: %v", version, err) + } + } + if version >= 7 { + if err := runSQLMigration(db, 7, migrationV7); err != nil { + t.Fatalf("构造 v%d 时执行 v7 失败: %v", version, err) + } + } return db } @@ -1120,7 +1166,7 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) { if err := migrateV3(db); err != nil { t.Fatalf("准备 v3 数据库失败: %v", err) } - // 故意跳过 v4(不加 shop_name),但把 v5-v7 补上——否则 CheckSchema 会先 + // 故意跳过 v4(不加 shop_name),但把 v5-v8 补上——否则 CheckSchema 会先 // 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。 if err := runSQLMigration(db, 5, migrationV5); err != nil { t.Fatalf("准备 v5 数据库失败: %v", err) @@ -1131,6 +1177,9 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) { if err := runSQLMigration(db, 7, migrationV7); err != nil { t.Fatalf("准备 v7 数据库失败: %v", err) } + if err := runSQLMigration(db, 8, migrationV8); err != nil { + t.Fatalf("准备 v8 数据库失败: %v", err) + } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "shop_name") { @@ -1152,3 +1201,14 @@ func TestCheckSchema_缺表时拒绝(t *testing.T) { t.Errorf("错误信息应该指出缺的是哪张表,实际: %v", err) } } + +func TestCheckSchema_缺少同步记录表时拒绝(t *testing.T) { + db := newFreshDB(t) + if _, err := db.Exec(`DROP TABLE syb_sync_runs`); err != nil { + t.Fatalf("删表失败: %v", err) + } + err := CheckSchema(db) + if err == nil || !strings.Contains(err.Error(), "syb_sync_runs") { + t.Fatalf("缺少同步记录表时应拒绝启动并指出表名,实际 %v", err) + } +} diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 988ef99..474263c 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -112,6 +112,113 @@ func SetSybLastSyncedAt(q Execer, at string) error { return nil } +// ---------- 同步记录 ---------- + +// CreateSybSyncRun 在真正启动后台同步前写入一条 running 记录。 +func CreateSybSyncRun(q Execer, run model.SybSyncRun) error { + if run.RunID == "" || run.UserID == "" || run.DateFrom == "" || run.DateTo == "" || run.StartedAt == "" { + return fmt.Errorf("同步记录缺少编号、操作人、日期范围或开始时间") + } + _, err := q.Exec(` + INSERT INTO syb_sync_runs + (run_id, user_id, date_from, date_to, status, started_at) + VALUES (?, ?, ?, ?, 'running', ?)`, + run.RunID, run.UserID, run.DateFrom, run.DateTo, run.StartedAt) + if err != nil { + return fmt.Errorf("创建顺运宝同步记录失败: %w", err) + } + return nil +} + +// FinishSybSyncRun 把 running 记录更新为最终状态。 +func FinishSybSyncRun(q Execer, run model.SybSyncRun) error { + if run.Status != model.SybSyncSucceeded && run.Status != model.SybSyncFailed { + return fmt.Errorf("同步完成状态不合法: %s", run.Status) + } + result, err := q.Exec(` + UPDATE syb_sync_runs + SET status = ?, stock_count = ?, detail_count = ?, created_count = ?, + updated_count = ?, skipped_count = ?, error_message = ?, + cursor_advanced = ?, finished_at = ? + WHERE run_id = ? AND status = 'running'`, + run.Status, run.StockCount, run.DetailCount, run.Created, run.Updated, run.Skipped, + nullableText(run.ErrorMessage), run.CursorAdvanced, run.FinishedAt, run.RunID) + if err != nil { + return fmt.Errorf("完成顺运宝同步记录失败: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("确认顺运宝同步记录完成结果失败: %w", err) + } + if affected != 1 { + return fmt.Errorf("同步记录 %s 不存在或已经结束", run.RunID) + } + return nil +} + +// InterruptRunningSybSyncRuns 在 Admin 启动时收敛上次进程遗留的 running 记录。 +func InterruptRunningSybSyncRuns(q Execer, finishedAt string) (int, error) { + result, err := q.Exec(` + UPDATE syb_sync_runs + SET status = 'interrupted', finished_at = ?, + error_message = 'Admin 在同步完成前退出,请重新同步该日期范围' + WHERE status = 'running'`, finishedAt) + if err != nil { + return 0, fmt.Errorf("标记中断的顺运宝同步记录失败: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("统计中断的顺运宝同步记录失败: %w", err) + } + return int(affected), nil +} + +// ListSybSyncRuns 按开始时间倒序分页查询同步记录。 +func ListSybSyncRuns(q Execer, limit, offset int) ([]model.SybSyncRun, error) { + rows, err := q.Query(` + SELECT r.run_id, r.user_id, u.username, r.date_from, r.date_to, r.status, + r.stock_count, r.detail_count, r.created_count, r.updated_count, + r.skipped_count, r.error_message, r.cursor_advanced, + r.started_at, r.finished_at + FROM syb_sync_runs r + JOIN users u ON u.user_id = r.user_id + ORDER BY r.started_at DESC, r.run_id DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, fmt.Errorf("查询顺运宝同步记录失败: %w", err) + } + defer rows.Close() + + var list []model.SybSyncRun + for rows.Next() { + var run model.SybSyncRun + var errorMessage, finishedAt sql.NullString + var cursorAdvanced int + if err := rows.Scan(&run.RunID, &run.UserID, &run.Username, &run.DateFrom, &run.DateTo, + &run.Status, &run.StockCount, &run.DetailCount, &run.Created, &run.Updated, + &run.Skipped, &errorMessage, &cursorAdvanced, &run.StartedAt, &finishedAt); err != nil { + return nil, fmt.Errorf("读取顺运宝同步记录失败: %w", err) + } + run.ErrorMessage = errorMessage.String + run.CursorAdvanced = cursorAdvanced == 1 + run.FinishedAt = finishedAt.String + list = append(list, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取顺运宝同步记录失败: %w", err) + } + return list, nil +} + +// CountSybSyncRuns 返回同步记录总数,供弹窗分页。 +func CountSybSyncRuns(q Execer) (int, error) { + var count int + if err := q.QueryRow(`SELECT COUNT(*) FROM syb_sync_runs`).Scan(&count); err != nil { + return 0, fmt.Errorf("统计顺运宝同步记录失败: %w", err) + } + return count, nil +} + // ---------- 货运单明细 ---------- // UpsertSybOrder 写入或更新一条顺运宝货运单明细行。 diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index 98e301e..149a352 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -191,6 +191,82 @@ func TestSybSyncState_首次为空之后可更新(t *testing.T) { } } +func createSybSyncTestUser(t *testing.T, db *sql.DB) model.User { + t.Helper() + user := model.User{ + UserID: "USR-SYNC", Username: "buyer", PasswordHash: "test-hash", + Role: model.RolePurchaser, Status: model.UserActive, + PasswordChangedAt: "2026-08-09T00:00:00Z", CreatedAt: "2026-08-09T00:00:00Z", + UpdatedAt: "2026-08-09T00:00:00Z", + } + if err := CreateInitialAdmin(db, user); err != nil { + t.Fatalf("创建同步记录测试用户失败: %v", err) + } + return user +} + +func TestSybSyncRun_创建完成并分页读取(t *testing.T) { + db := newSybTestDB(t) + user := createSybSyncTestUser(t, db) + run := model.SybSyncRun{ + RunID: "SYB-RUN-1", UserID: user.UserID, DateFrom: "2026-08-07", + DateTo: "2026-08-09", StartedAt: "2026-08-09T01:00:00Z", + } + if err := CreateSybSyncRun(db, run); err != nil { + t.Fatalf("创建同步记录失败: %v", err) + } + if err := FinishSybSyncRun(db, model.SybSyncRun{ + RunID: "SYB-RUN-1", Status: model.SybSyncSucceeded, + StockCount: 3, DetailCount: 4, Created: 2, Updated: 2, Skipped: 1, + CursorAdvanced: true, FinishedAt: "2026-08-09T01:02:00Z", + }); err != nil { + t.Fatalf("完成同步记录失败: %v", err) + } + + rows, err := ListSybSyncRuns(db, 10, 0) + if err != nil || len(rows) != 1 { + t.Fatalf("读取同步记录失败: rows=%+v err=%v", rows, err) + } + got := rows[0] + if got.Username != "buyer" || got.Status != model.SybSyncSucceeded || + got.DetailCount != 4 || !got.CursorAdvanced || got.FinishedAt == "" { + t.Fatalf("同步记录字段不正确: %+v", got) + } + if count, err := CountSybSyncRuns(db); err != nil || count != 1 { + t.Fatalf("同步记录总数错误: count=%d err=%v", count, err) + } +} + +func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) { + db := newSybTestDB(t) + user := createSybSyncTestUser(t, db) + for _, id := range []string{"RUNNING", "DONE"} { + if err := CreateSybSyncRun(db, model.SybSyncRun{ + RunID: id, UserID: user.UserID, DateFrom: "2026-08-09", DateTo: "2026-08-09", + StartedAt: "2026-08-09T01:00:00Z", + }); err != nil { + t.Fatal(err) + } + } + if err := FinishSybSyncRun(db, model.SybSyncRun{ + RunID: "DONE", Status: model.SybSyncSucceeded, FinishedAt: "2026-08-09T01:01:00Z", + }); err != nil { + t.Fatal(err) + } + affected, err := InterruptRunningSybSyncRuns(db, "2026-08-09T02:00:00Z") + if err != nil || affected != 1 { + t.Fatalf("应该只中断一条 running 记录: affected=%d err=%v", affected, err) + } + rows, _ := ListSybSyncRuns(db, 10, 0) + statuses := map[string]model.SybSyncRunStatus{} + for _, row := range rows { + statuses[row.RunID] = row.Status + } + if statuses["RUNNING"] != model.SybSyncInterrupted || statuses["DONE"] != model.SybSyncSucceeded { + t.Fatalf("中断状态错误: %+v", statuses) + } +} + func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) { db := newSybTestDB(t) mustUpsert := func(sybID, orderNo, title string) { diff --git a/admin/service/syb.go b/admin/service/syb.go index f995d8a..d29c22d 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -36,8 +36,8 @@ const ( defaultSybMaxMatches = 10000 ) -// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量; -// 两者都有值是操作员明确发起的指定日期补同步。 +// SybSyncOptions 表示一次同步的日期选择。页面必须明确传入两端日期;两端都空 +// 仅保留给旧版内部调用兼容。 // // `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员 // 以为补拉了指定日期,实际却跑了另一段数据。 @@ -46,7 +46,7 @@ type SybSyncOptions struct { To string } -// IsSpecified 判断这是不是指定日期同步。 +// IsSpecified 判断是否明确给出了日期范围。 func (o SybSyncOptions) IsSpecified() bool { return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != "" } @@ -78,7 +78,7 @@ func NewSybSyncOptions(fromRaw, toRaw string, now time.Time) (SybSyncOptions, er } days := int(toDate.Sub(fromDate).Hours()/24) + 1 if days > maxSpecifiedSyncDays { - return SybSyncOptions{}, fmt.Errorf("指定日期同步最多选择 %d 天,当前范围为 %d 天,请分段同步", + return SybSyncOptions{}, fmt.Errorf("同步日期范围最多选择 %d 天,当前范围为 %d 天,请分段同步", maxSpecifiedSyncDays, days) } return SybSyncOptions{From: from, To: to}, nil @@ -136,6 +136,63 @@ func SybToday(now time.Time) string { return dateOf(now) } +// SybSyncDefaults 是页面首次打开时预填的同步范围和必要提示。 +type SybSyncDefaults struct { + From string + To string + Warning string +} + +// DefaultSybSyncRange 通常预填最近三天;如果覆盖游标落后,则优先从游标 +// 当天连续补齐。缺口超过 31 天时只选最早一段,成功后下一次继续。 +func DefaultSybSyncRange(db *sql.DB, now time.Time) (SybSyncDefaults, error) { + today, err := time.Parse(dateLayout, dateOf(now)) + if err != nil { + return SybSyncDefaults{}, fmt.Errorf("计算顺运宝今天日期失败: %w", err) + } + recentStart := today.AddDate(0, 0, -2) + defaults := SybSyncDefaults{From: recentStart.Format(dateLayout), To: today.Format(dateLayout)} + + lastSyncedAt, found, err := repository.GetSybLastSyncedAt(db) + if err != nil { + return SybSyncDefaults{}, err + } + if !found { + return defaults, nil + } + lastTime, ok := model.ParseISO(lastSyncedAt) + if !ok { + return SybSyncDefaults{}, fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt) + } + coveredDate, err := time.Parse(dateLayout, dateOf(lastTime)) + if err != nil || !coveredDate.Before(recentStart) { + return defaults, nil + } + + segmentEnd := coveredDate.AddDate(0, 0, maxSpecifiedSyncDays-1) + defaults.From = coveredDate.Format(dateLayout) + if segmentEnd.Before(today) { + defaults.To = segmentEnd.Format(dateLayout) + defaults.Warning = fmt.Sprintf( + "存在较长的未同步区间,已先选择最早 %d 天(%s 至 %s);本段成功后请继续同步下一段。", + maxSpecifiedSyncDays, defaults.From, defaults.To) + return defaults, nil + } + defaults.To = today.Format(dateLayout) + defaults.Warning = fmt.Sprintf("检测到上次同步停在 %s,已自动扩展开始日期以补齐缺口。", defaults.From) + return defaults, nil +} + +// cursorISOForDate 把“已连续覆盖到哪一天”保存成 UTC ISO。下一次仍从该日 +// 重拉,宁可重复当天,也不能漏掉该日稍晚创建的货运单。 +func cursorISOForDate(date string) (string, error) { + t, err := time.ParseInLocation(dateLayout, date, sybLocation) + if err != nil || t.Format(dateLayout) != date { + return "", fmt.Errorf("同步游标日期格式不正确: %q", date) + } + return t.UTC().Format(model.TimeLayout), nil +} + // syncDateRange 算出这次同步该拉哪个日期范围。 // // `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created @@ -175,17 +232,146 @@ type SkipNote struct { // SyncReport 是一次同步的结果,供状态条显示。 type SyncReport struct { - From, To string - Specified bool // true 表示操作员发起的指定日期补同步 - StockCount int // 拉到的货运单数 - DetailCount int // 落库的商品明细行数(不含跳过的) - Created int - Updated int - SkippedZero int // quantity <= 0 被跳过的条数 - Notes []SkipNote - Err error - StartedAt time.Time - FinishedAt time.Time + From, To string + Specified bool // true 表示操作员发起的指定日期补同步 + StockCount int // 拉到的货运单数 + DetailCount int // 落库的商品明细行数(不含跳过的) + Created int + Updated int + SkippedZero int // quantity <= 0 被跳过的条数 + Notes []SkipNote + Err error + StartedAt time.Time + FinishedAt time.Time + CursorAdvanced bool +} + +const sybSyncHistoryPageSize = 10 + +// SybSyncRunView 是同步记录弹窗的一行,时间和状态已经转成采购员可读文本。 +type SybSyncRunView struct { + RunID string + Username string + DateRange string + StatusText string + StatusClass string + Summary string + ErrorMessage string + CursorText string + StartedAt string + FinishedAt string +} + +// SybSyncHistoryResult 是同步记录弹窗的分页结果。 +type SybSyncHistoryResult struct { + Rows []SybSyncRunView + Page int + TotalPages int + Total int +} + +// CreateSybSyncRun 在后台任务启动前创建可审计记录。 +func CreateSybSyncRun(db *sql.DB, actor *model.User, options SybSyncOptions, now time.Time) (string, error) { + if actor == nil || actor.UserID == "" { + return "", fmt.Errorf("无法确认当前操作账号,同步没有启动") + } + if !options.IsSpecified() { + return "", fmt.Errorf("同步必须明确填写开始日期和结束日期") + } + runID, err := randomID("SYB-", 16) + if err != nil { + return "", fmt.Errorf("生成同步记录编号失败: %w", err) + } + run := model.SybSyncRun{ + RunID: runID, UserID: actor.UserID, DateFrom: options.From, DateTo: options.To, + Status: model.SybSyncRunning, StartedAt: now.UTC().Format(model.TimeLayout), + } + if err := repository.CreateSybSyncRun(db, run); err != nil { + return "", err + } + return runID, nil +} + +// FinishSybSyncRun 把同步报告持久化到对应记录。 +func FinishSybSyncRun(db *sql.DB, runID string, report SyncReport) error { + status := model.SybSyncSucceeded + errorMessage := "" + if report.Err != nil { + status = model.SybSyncFailed + errorMessage = report.Err.Error() + if len([]rune(errorMessage)) > 500 { + errorMessage = string([]rune(errorMessage)[:500]) + "…" + } + } + finishedAt := report.FinishedAt + if finishedAt.IsZero() { + finishedAt = time.Now().UTC() + } + return repository.FinishSybSyncRun(db, model.SybSyncRun{ + RunID: runID, Status: status, StockCount: report.StockCount, + DetailCount: report.DetailCount, Created: report.Created, Updated: report.Updated, + Skipped: report.SkippedZero, ErrorMessage: errorMessage, + CursorAdvanced: report.CursorAdvanced, FinishedAt: finishedAt.UTC().Format(model.TimeLayout), + }) +} + +// InterruptRunningSybSyncRuns 收敛上次进程退出前未完成的同步记录。 +func InterruptRunningSybSyncRuns(db *sql.DB, now time.Time) (int, error) { + return repository.InterruptRunningSybSyncRuns(db, now.UTC().Format(model.TimeLayout)) +} + +// ListSybSyncHistory 返回同步记录弹窗所需的分页视图。 +func ListSybSyncHistory(db *sql.DB, page int) (*SybSyncHistoryResult, error) { + total, err := repository.CountSybSyncRuns(db) + if err != nil { + return nil, err + } + if page < 1 { + page = 1 + } + totalPages := max(1, (total+sybSyncHistoryPageSize-1)/sybSyncHistoryPageSize) + if page > totalPages { + page = totalPages + } + runs, err := repository.ListSybSyncRuns(db, sybSyncHistoryPageSize, (page-1)*sybSyncHistoryPageSize) + if err != nil { + return nil, err + } + result := &SybSyncHistoryResult{Page: page, TotalPages: totalPages, Total: total} + for _, run := range runs { + statusText, statusClass := sybSyncRunStatusText(run.Status) + view := SybSyncRunView{ + RunID: run.RunID, Username: run.Username, + DateRange: run.DateFrom + " ~ " + run.DateTo, + StatusText: statusText, StatusClass: statusClass, + Summary: fmt.Sprintf("货运单 %d,明细 %d(新增 %d,更新 %d,跳过 %d)", + run.StockCount, run.DetailCount, run.Created, run.Updated, run.Skipped), + ErrorMessage: run.ErrorMessage, StartedAt: formatLocalTime(run.StartedAt), + FinishedAt: formatLocalTime(run.FinishedAt), + } + if run.CursorAdvanced { + view.CursorText = "已推进" + } else { + view.CursorText = "未推进" + } + result.Rows = append(result.Rows, view) + } + return result, nil +} + +func sybSyncRunStatusText(status model.SybSyncRunStatus) (string, string) { + switch status { + case model.SybSyncRunning: + return "同步中", "status-running" + case model.SybSyncSucceeded: + return "成功", "status-success" + case model.SybSyncFailed: + return "失败", "status-failed" + case model.SybSyncInterrupted: + return "已中断", "status-interrupted" + default: + return "未知", "" + } } // Summary 组装状态条文案,格式见工单 #46「报告要说清楚」: @@ -196,11 +382,7 @@ func (r SyncReport) Summary() string { if r.Err != nil { return "同步失败:" + r.Err.Error() } - prefix := "同步完成" - if r.Specified { - prefix = "指定日期同步完成" - } - msg := fmt.Sprintf("%s:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", prefix, + msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero) if len(r.Notes) > 0 { var reasons []string @@ -309,7 +491,7 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config. return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{}) } -// RunSybSyncWithOptions 执行自动增量或指定日期同步。 +// RunSybSyncWithOptions 执行日期范围同步;空范围仅兼容旧版内部自动增量调用。 // // 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。 // 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。 @@ -325,7 +507,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, maxMatches = defaultSybMaxMatches } - lastSyncedAt, _, err := repository.GetSybLastSyncedAt(db) + lastSyncedAt, hasCursor, err := repository.GetSybLastSyncedAt(db) if err != nil { report.Err = fmt.Errorf("读取上次同步进度失败: %w", err) report.FinishedAt = time.Now().UTC() @@ -334,6 +516,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, from, to := options.From, options.To advanceCursor := !options.IsSpecified() + cursorAt := model.NowISO() if options.IsSpecified() { validated, validateErr := NewSybSyncOptions(options.From, options.To, now) if validateErr != nil { @@ -343,10 +526,27 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, } from, to = validated.From, validated.To - // 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有 - // sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。 - autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now) - advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo + cursorAt, err = cursorISOForDate(to) + if err != nil { + report.Err = err + report.FinishedAt = time.Now().UTC() + return report + } + if !hasCursor { + // 统一日期入口第一次成功后,以操作员明确选择的结束日建立后续覆盖基线。 + advanceCursor = true + } else { + lastTime, ok := model.ParseISO(lastSyncedAt) + if !ok { + report.Err = fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt) + report.FinishedAt = time.Now().UTC() + return report + } + coveredDate := dateOf(lastTime) + // 只有范围衔接当前覆盖日期、且确实向后延伸时才推进。 + // 历史补拉或跳过缺口的范围只 upsert 数据,不改变覆盖基线。 + advanceCursor = from <= coveredDate && to > coveredDate + } } else { from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now) if err != nil { @@ -394,8 +594,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, if allTotal == 0 { report.FinishedAt = time.Now().UTC() if advanceCursor { - if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { + if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil { report.Err = fmt.Errorf("更新同步进度失败: %w", err) + } else { + report.CursorAdvanced = true } } return report @@ -481,9 +683,11 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, // ④ 全部成功,才更新 last_synced_at。 if advanceCursor { - if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { + if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil { report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+ "下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err) + } else { + report.CursorAdvanced = true } } report.FinishedAt = time.Now().UTC() diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index 100f478..320b0e0 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -119,6 +120,37 @@ func TestNewSybSyncOptions_指定日期校验(t *testing.T) { } } +func TestDefaultSybSyncRange_最近三天与缺口分段(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + t.Run("没有游标默认最近三天", func(t *testing.T) { + db := newSyncTestDB(t) + got, err := DefaultSybSyncRange(db, now) + if err != nil || got.From != "2026-08-07" || got.To != "2026-08-09" || got.Warning != "" { + t.Fatalf("默认范围错误: got=%+v err=%v", got, err) + } + }) + t.Run("短缺口自动扩展到今天", func(t *testing.T) { + db := newSyncTestDB(t) + if err := repository.SetSybLastSyncedAt(db, "2026-08-05T00:00:00Z"); err != nil { + t.Fatal(err) + } + got, err := DefaultSybSyncRange(db, now) + if err != nil || got.From != "2026-08-05" || got.To != "2026-08-09" || got.Warning == "" { + t.Fatalf("短缺口范围错误: got=%+v err=%v", got, err) + } + }) + t.Run("长缺口优先选择最早三十一天", func(t *testing.T) { + db := newSyncTestDB(t) + if err := repository.SetSybLastSyncedAt(db, "2026-07-01T00:00:00Z"); err != nil { + t.Fatal(err) + } + got, err := DefaultSybSyncRange(db, now) + if err != nil || got.From != "2026-07-01" || got.To != "2026-07-31" || !strings.Contains(got.Warning, "继续同步下一段") { + t.Fatalf("长缺口分段错误: got=%+v err=%v", got, err) + } + }) +} + // ── 金额换算 ────────────────────────────────────────── func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) { @@ -489,8 +521,8 @@ func TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) { if report.Err != nil { t.Fatalf("历史补拉失败: %v", report.Err) } - if !report.Specified || !strings.HasPrefix(report.Summary(), "指定日期同步完成") { - t.Fatalf("报告没有标明指定日期同步:%+v / %s", report, report.Summary()) + if !report.Specified || !strings.HasPrefix(report.Summary(), "同步完成") { + t.Fatalf("明确日期范围的报告不正确:%+v / %s", report, report.Summary()) } at, _, err := repository.GetSybLastSyncedAt(db) if err != nil { @@ -522,8 +554,64 @@ func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testin if err != nil { t.Fatalf("读取同步游标失败: %v", err) } - if at == original || at == "" { - t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", at) + if at != "2026-07-27T16:00:00Z" || !report.CursorAdvanced { + t.Fatalf("连续覆盖后应该把游标推进到所选结束日,实际 %q,报告 %+v", at, report) + } +} + +func TestRunSybSyncWithOptions_跳过缺口不推进游标(t *testing.T) { + srv := fakeSybServer(t, nil, 0) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + const original = "2026-07-01T00:00:00Z" + if err := repository.SetSybLastSyncedAt(db, original); err != nil { + t.Fatal(err) + } + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500}, + time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-07-05", To: "2026-07-10"}) + if report.Err != nil { + t.Fatalf("同步失败: %v", report.Err) + } + at, _, err := repository.GetSybLastSyncedAt(db) + if err != nil || at != original || report.CursorAdvanced { + t.Fatalf("跳过缺口不应推进游标: at=%q report=%+v err=%v", at, report, err) + } +} + +func TestSybSyncHistory_服务层创建失败记录并展示(t *testing.T) { + db := newSyncTestDB(t) + actor := model.User{ + UserID: "USR-HISTORY", Username: "buyer", PasswordHash: "test-hash", + Role: model.RolePurchaser, Status: model.UserActive, + PasswordChangedAt: "2026-08-09T00:00:00Z", CreatedAt: "2026-08-09T00:00:00Z", + UpdatedAt: "2026-08-09T00:00:00Z", + } + if err := repository.CreateInitialAdmin(db, actor); err != nil { + t.Fatal(err) + } + runID, err := CreateSybSyncRun(db, &actor, + SybSyncOptions{From: "2026-08-07", To: "2026-08-09"}, + time.Date(2026, 8, 9, 1, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if err := FinishSybSyncRun(db, runID, SyncReport{ + From: "2026-08-07", To: "2026-08-09", Err: fmt.Errorf("测试失败"), + FinishedAt: time.Date(2026, 8, 9, 1, 1, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + history, err := ListSybSyncHistory(db, 1) + if err != nil || history.Total != 1 || len(history.Rows) != 1 { + t.Fatalf("读取记录失败: history=%+v err=%v", history, err) + } + row := history.Rows[0] + if row.Username != "buyer" || row.StatusText != "失败" || row.ErrorMessage != "测试失败" || row.CursorText != "未推进" { + t.Fatalf("同步记录视图不正确: %+v", row) } } diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 7e1e73d..1443589 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -100,6 +100,11 @@ body { /* 搜索表单占据剩余空间,把删除按钮挤到右边 */ .toolbar .grow { flex: 1; } .toolbar label { color: #555; white-space: nowrap; } +.toolbar .sync-range-form input[type="date"] { + width: 138px; + min-width: 138px; +} +.sync-range-message { margin: -4px 0 10px; } /* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用; 蝦皮数据页和客户端列表页仍是撑满的。 这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后, @@ -225,6 +230,7 @@ tr.empty small { color: #aaa; } max-width: 720px; } .modal-narrow { max-width: 520px; } +.modal-wide { max-width: 1160px; } .field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -262,6 +268,39 @@ tr.empty small { color: #aaa; } } /* 把左边的按钮和右边的按钮推开 */ .modal-foot .grow { flex: 1; } +.button-link { + display: inline-block; + padding: 5px 12px; + border: 1px solid #ccd1d6; + border-radius: 3px; + background: #fff; + color: #24292f; + text-decoration: none; + white-space: nowrap; +} +.button-link:hover { background: #f0f2f4; } +.button-link.disabled { color: #999; background: #f5f6f8; } +.sync-history-table { max-height: 460px; } +.sync-history-table td { vertical-align: top; } +.sync-history-table .sync-error { + min-width: 220px; + max-width: 360px; + white-space: normal; + word-break: break-word; +} +.sync-history-foot > span:first-child { color: #555; } +.sync-status { + display: inline-block; + min-width: 52px; + padding: 2px 6px; + border-radius: 10px; + text-align: center; + font-weight: 600; +} +.status-running { color: #7a4b00; background: #fff3cd; } +.status-success { color: #176b36; background: #dff3e5; } +.status-failed { color: #9b1c1c; background: #fde8e8; } +.status-interrupted { color: #555; background: #eceff2; } button.primary { background: #1f6feb; border-color: #1f6feb; diff --git a/admin/syb/client.go b/admin/syb/client.go index 052ecd6..adef764 100644 --- a/admin/syb/client.go +++ b/admin/syb/client.go @@ -527,7 +527,7 @@ func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, p for _, raw := range wrap.List { id, ok := toInt64(raw["id"]) if !ok { - return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id: %v", raw) + return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id") } code, _ := raw["code"].(string) rows = append(rows, StockRow{ID: id, Code: code, Raw: raw}) @@ -588,7 +588,7 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet for _, raw := range wrap.List { id, ok := toInt64(raw["id"]) if !ok { - return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id: %v", raw) + return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id") } code, _ := raw["code"].(string) diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index af148e1..74f3eca 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -2,13 +2,19 @@ {{template "header" .}}
-{{/* 指定日期只用于补拉历史数据。日常增量仍走工具栏第一个“同步”按钮, - 避免操作员忘记清掉旧日期、下一次又误跑同一段历史数据。 */}} -