feat: 顺运宝支持指定日期同步 (#53)

This commit is contained in:
chengma
2026-08-09 13:24:02 +08:00
parent 24b6c742c1
commit a18d4e67ae
10 changed files with 435 additions and 29 deletions
+97 -14
View File
@@ -32,6 +32,49 @@ import (
const dateLayout = "2006-01-02"
// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量;
// 两者都有值是操作员明确发起的指定日期补同步。
//
// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员
// 以为补拉了指定日期,实际却跑了另一段数据。
type SybSyncOptions struct {
From string
To string
}
// IsSpecified 判断这是不是指定日期同步。
func (o SybSyncOptions) IsSpecified() bool {
return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != ""
}
// NewSybSyncOptions 清理并校验浏览器提交的日期范围。
// 日期按顺运宝服务端 UTC+8 解释,起止两天都包含。
func NewSybSyncOptions(fromRaw, toRaw string, now time.Time) (SybSyncOptions, error) {
from := strings.TrimSpace(fromRaw)
to := strings.TrimSpace(toRaw)
if from == "" && to == "" {
return SybSyncOptions{}, nil
}
if from == "" || to == "" {
return SybSyncOptions{}, fmt.Errorf("开始日期和结束日期必须同时填写")
}
fromDate, err := time.Parse(dateLayout, from)
if err != nil || fromDate.Format(dateLayout) != from {
return SybSyncOptions{}, fmt.Errorf("开始日期格式不正确,请使用 YYYY-MM-DD")
}
toDate, err := time.Parse(dateLayout, to)
if err != nil || toDate.Format(dateLayout) != to {
return SybSyncOptions{}, fmt.Errorf("结束日期格式不正确,请使用 YYYY-MM-DD")
}
if fromDate.After(toDate) {
return SybSyncOptions{}, fmt.Errorf("开始日期不能晚于结束日期")
}
if to > dateOf(now) {
return SybSyncOptions{}, fmt.Errorf("结束日期不能晚于顺运宝服务端今天(%s)", dateOf(now))
}
return SybSyncOptions{From: from, To: to}, nil
}
// sybLocation 是顺运宝服务端的时区。
//
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
@@ -57,6 +100,12 @@ func dateOf(t time.Time) string {
return t.In(sybLocation).Format(dateLayout)
}
// SybToday 返回顺运宝服务端时区下的今天,供页面 date 输入的 max/default
// 使用。页面和同步校验共用这一处,避免本机时区不同导致显示和校验差一天。
func SybToday(now time.Time) string {
return dateOf(now)
}
// syncDateRange 算出这次同步该拉哪个日期范围。
//
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
@@ -97,8 +146,9 @@ type SkipNote struct {
// SyncReport 是一次同步的结果,供状态条显示。
type SyncReport struct {
From, To string
StockCount int // 拉到的货运单数
DetailCount int // 落库的商品明细行数(不含跳过的)
Specified bool // true 表示操作员发起的指定日期补同步
StockCount int // 拉到的货运单数
DetailCount int // 落库的商品明细行数(不含跳过的)
Created int
Updated int
SkippedZero int // quantity <= 0 被跳过的条数
@@ -116,7 +166,11 @@ func (r SyncReport) Summary() string {
if r.Err != nil {
return "同步失败:" + r.Err.Error()
}
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
prefix := "同步完成"
if r.Specified {
prefix = "指定日期同步完成"
}
msg := fmt.Sprintf("%s:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", prefix,
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
if len(r.Notes) > 0 {
var reasons []string
@@ -222,7 +276,15 @@ func roundYuanToCent(yuan float64) int64 {
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport {
report := SyncReport{StartedAt: now}
return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{})
}
// RunSybSyncWithOptions 执行自动增量或指定日期同步。
//
// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport {
report := SyncReport{StartedAt: now, Specified: options.IsSpecified()}
pageSize := cfg.PageSize
if pageSize <= 0 {
@@ -240,11 +302,28 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
return report
}
from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
if err != nil {
report.Err = err
report.FinishedAt = time.Now().UTC()
return report
from, to := options.From, options.To
advanceCursor := !options.IsSpecified()
if options.IsSpecified() {
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
if validateErr != nil {
report.Err = validateErr
report.FinishedAt = time.Now().UTC()
return report
}
from, to = validated.From, validated.To
// 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有
// sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。
autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo
} else {
from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
if err != nil {
report.Err = err
report.FinishedAt = time.Now().UTC()
return report
}
}
report.From, report.To = from, to
@@ -263,8 +342,10 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
}
if total == 0 {
report.FinishedAt = time.Now().UTC()
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
if advanceCursor {
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
}
}
return report
}
@@ -329,9 +410,11 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
}
// ④ 全部成功,才更新 last_synced_at。
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
if advanceCursor {
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
}
}
report.FinishedAt = time.Now().UTC()
return report