feat: 统一顺运宝日期同步并记录历史 (#59)
This commit is contained in:
@@ -25,10 +25,10 @@ syb:
|
|||||||
page_size: 20
|
page_size: 20
|
||||||
|
|
||||||
# 整个日期范围的货运单数上限,默认 10000;超过就停止,不拉列表和明细。
|
# 整个日期范围的货运单数上限,默认 10000;超过就停止,不拉列表和明细。
|
||||||
# 指定日期同步另有最多 31 天限制,自动增量不受天数限制。
|
# 页面每次最多同步 31 个自然日。
|
||||||
max_matches: 10000
|
max_matches: 10000
|
||||||
|
|
||||||
# 首次同步的起始日期。之后按「上次同步时间」增量拉,不再用这个值。
|
# 兼容旧版自动增量入口的首次起始日期;新版页面默认最近 3 天,不使用此值。
|
||||||
sync_from: "2026-07-01"
|
sync_from: "2026-07-01"
|
||||||
|
|
||||||
# 验证码自动识别服务地址(工单 #47)。留空 = 只用手工输入弹窗,不报错。
|
# 验证码自动识别服务地址(工单 #47)。留空 = 只用手工输入弹窗,不报错。
|
||||||
|
|||||||
+96
-18
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,6 +60,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
|||||||
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError,
|
||||||
|
"读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
status := msg
|
status := msg
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -88,15 +95,25 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
|||||||
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
|
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
|
||||||
// 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。
|
// 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。
|
||||||
needLogin := c.Query("login") == "1"
|
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")
|
dateFrom := c.Query("date_from")
|
||||||
dateTo := c.Query("date_to")
|
dateTo := c.Query("date_to")
|
||||||
hasSyncRange := dateFrom != "" || dateTo != ""
|
rangeWarning := ""
|
||||||
if dateFrom == "" {
|
if dateFrom == "" {
|
||||||
dateFrom = today
|
dateFrom = defaults.From
|
||||||
}
|
}
|
||||||
if dateTo == "" {
|
if dateTo == "" {
|
||||||
dateTo = today
|
dateTo = defaults.To
|
||||||
|
}
|
||||||
|
if c.Query("date_from") == "" && c.Query("date_to") == "" {
|
||||||
|
rangeWarning = defaults.Warning
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, cfgErr := config.Load()
|
cfg, cfgErr := config.Load()
|
||||||
@@ -133,13 +150,39 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
|||||||
"Today": today,
|
"Today": today,
|
||||||
"SyncDateFrom": dateFrom,
|
"SyncDateFrom": dateFrom,
|
||||||
"SyncDateTo": dateTo,
|
"SyncDateTo": dateTo,
|
||||||
"HasSyncRange": hasSyncRange,
|
|
||||||
"NeedRangeSync": c.Query("range_sync") == "1",
|
|
||||||
"RangeError": c.Query("range_error"),
|
"RangeError": c.Query("range_error"),
|
||||||
|
"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()),
|
"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 覆盖时)。
|
// sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
|
||||||
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
|
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
|
||||||
func sybStatusLine(result *service.SybListResult) string {
|
func sybStatusLine(result *service.SybListResult) string {
|
||||||
@@ -189,19 +232,29 @@ func (h *Handler) SybSync(c *gin.Context) {
|
|||||||
h.sybRedirectLogin(c, reason)
|
h.sybRedirectLogin(c, reason)
|
||||||
return
|
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, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.sybRedirect(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果")
|
h.sybRedirectAfterStart(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果")
|
||||||
return
|
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, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果")
|
h.sybRedirectAfterStart(c, "同步已开始,请稍后刷新页面查看结果")
|
||||||
}
|
}
|
||||||
|
|
||||||
// attemptSybAutoLogin 尝试用配置的 OCR 服务自动识别验证码并登录。
|
// 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)
|
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, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.sybRedirect(c, "登录成功,同步已开始,请稍后刷新页面查看结果")
|
h.sybRedirectAfterStart(c, "登录成功,同步已开始,请稍后刷新页面查看结果")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。
|
// startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。
|
||||||
@@ -319,16 +377,27 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
|
||||||
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
|
// 那个请求上下文会在这次 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() {
|
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
|
db := h.db
|
||||||
go func() {
|
go func() {
|
||||||
report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options)
|
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)
|
service.FinishSybSync(report)
|
||||||
}()
|
}()
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。
|
// sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。
|
||||||
@@ -346,6 +415,16 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) {
|
|||||||
c.Redirect(http.StatusSeeOther, target)
|
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 参数,
|
// sybRedirectLogin 同 sybRedirect,但把 reason 放进 login_reason 参数,
|
||||||
// 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要
|
// 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要
|
||||||
// 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时
|
// 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时
|
||||||
@@ -364,12 +443,11 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
|
|||||||
c.Redirect(http.StatusSeeOther, target)
|
c.Redirect(http.StatusSeeOther, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sybRedirectRange 把服务端日期校验错误带回指定日期弹窗。输入值一并保留,
|
// sybRedirectRange 把服务端日期校验错误带回工具栏日期表单。输入值一并保留,
|
||||||
// 操作员只需改错的那一端,不必重新填整张表单。
|
// 操作员只需改错的那一端,不必重新填写。
|
||||||
func (h *Handler) sybRedirectRange(c *gin.Context, message string) {
|
func (h *Handler) sybRedirectRange(c *gin.Context, message string) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
appendSybFormState(params, c)
|
appendSybFormState(params, c)
|
||||||
params.Set("range_sync", "1")
|
|
||||||
params.Set("range_error", message)
|
params.Set("range_error", message)
|
||||||
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
|
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
context, recorder := sybPostContext(t, url.Values{
|
||||||
"date_from": {"2026-08-09"},
|
"date_from": {"2026-08-09"},
|
||||||
"date_to": {"2026-08-01"},
|
"date_to": {"2026-08-01"},
|
||||||
@@ -59,10 +59,43 @@ func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T)
|
|||||||
t.Fatalf("解析 Location 失败: %v", err)
|
t.Fatalf("解析 Location 失败: %v", err)
|
||||||
}
|
}
|
||||||
query := location.Query()
|
query := location.Query()
|
||||||
if query.Get("range_sync") != "1" || query.Get("range_error") == "" {
|
if query.Get("range_error") == "" {
|
||||||
t.Fatalf("日期错误后应该重开弹窗并显示错误,Location=%s", location.String())
|
t.Fatalf("日期错误后应该显示错误,Location=%s", location.String())
|
||||||
}
|
}
|
||||||
if query.Get("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" {
|
if query.Get("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" {
|
||||||
t.Fatalf("日期错误后应该保留两端输入,Location=%s", location.String())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"cmautobuy/admin/handler/api"
|
"cmautobuy/admin/handler/api"
|
||||||
"cmautobuy/admin/handler/web"
|
"cmautobuy/admin/handler/web"
|
||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 模板和静态文件打进二进制,这样打包后只有一个 exe,
|
// 模板和静态文件打进二进制,这样打包后只有一个 exe,
|
||||||
@@ -59,6 +61,13 @@ func main() {
|
|||||||
if err := repository.CheckSchema(db); err != nil {
|
if err := repository.CheckSchema(db); err != nil {
|
||||||
log.Fatalf("%v", err)
|
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("数据库已就绪")
|
log.Printf("数据库已就绪")
|
||||||
|
|
||||||
// 3. Web 引擎
|
// 3. Web 引擎
|
||||||
|
|||||||
+4
-1
@@ -67,13 +67,16 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
sybResponse := httptest.NewRecorder()
|
sybResponse := httptest.NewRecorder()
|
||||||
router.ServeHTTP(sybResponse, sybRequest)
|
router.ServeHTTP(sybResponse, sybRequest)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"指定日期同步", `name="date_from"`, `name="date_to"`,
|
"同步记录", `name="date_from"`, `name="date_to"`,
|
||||||
"按顺运宝货运单创建日期(UTC+8)同步",
|
"按顺运宝货运单创建日期(UTC+8)同步",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(sybResponse.Body.String(), want) {
|
if !strings.Contains(sybResponse.Body.String(), want) {
|
||||||
t.Errorf("顺运宝页面缺少 %q", want)
|
t.Errorf("顺运宝页面缺少 %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(sybResponse.Body.String(), "指定日期同步") {
|
||||||
|
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
|
||||||
|
}
|
||||||
|
|
||||||
pdd, err := repository.EnsurePddProduct(
|
pdd, err := repository.EnsurePddProduct(
|
||||||
db, "737116531267",
|
db, "737116531267",
|
||||||
|
|||||||
@@ -219,6 +219,36 @@ type SybOrder struct {
|
|||||||
UpdatedAt string
|
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 是「蝦皮的这个规格 = 拼多多的那个规格」。
|
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
|
||||||
//
|
//
|
||||||
// # 为什么主键要带上 PddGoodsID
|
// # 为什么主键要带上 PddGoodsID
|
||||||
|
|||||||
+37
-2
@@ -264,7 +264,7 @@ var migrations = [][]string{
|
|||||||
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
||||||
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
||||||
// 和代码对不上的库静默启动。
|
// 和代码对不上的库静默启动。
|
||||||
const schemaVersion = 7
|
const schemaVersion = 8
|
||||||
|
|
||||||
// migrationV4 给 PDD 商品增加店铺名。
|
// migrationV4 给 PDD 商品增加店铺名。
|
||||||
//
|
//
|
||||||
@@ -353,6 +353,32 @@ var migrationV7 = []string{
|
|||||||
ON client_user_assignments(client_id, started_at DESC);`,
|
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 把数据库升到最新版本。
|
// Migrate 把数据库升到最新版本。
|
||||||
// 已经是最新的就什么都不做,可以重复调用。
|
// 已经是最新的就什么都不做,可以重复调用。
|
||||||
func Migrate(db *sql.DB) error {
|
func Migrate(db *sql.DB) error {
|
||||||
@@ -431,6 +457,14 @@ func Migrate(db *sql.DB) error {
|
|||||||
if err := runSQLMigration(db, 7, migrationV7); err != nil {
|
if err := runSQLMigration(db, 7, migrationV7); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
reached = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// v8 只新增顺运宝同步记录表和倒序索引。
|
||||||
|
if reached < 8 {
|
||||||
|
if err := runSQLMigration(db, 8, migrationV8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -892,7 +926,7 @@ var requiredTables = []string{
|
|||||||
"syb_orders", "sku_mappings", "tasks", "clients",
|
"syb_orders", "sku_mappings", "tasks", "clients",
|
||||||
"idempotency_keys", "task_claims",
|
"idempotency_keys", "task_claims",
|
||||||
"syb_session", "syb_sync_state",
|
"syb_session", "syb_sync_state",
|
||||||
"users", "web_sessions", "client_user_assignments",
|
"users", "web_sessions", "client_user_assignments", "syb_sync_runs",
|
||||||
}
|
}
|
||||||
|
|
||||||
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
|
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
|
||||||
@@ -902,6 +936,7 @@ var requiredTables = []string{
|
|||||||
var requiredColumns = map[string][]string{
|
var requiredColumns = map[string][]string{
|
||||||
"pdd_products": {"shop_name"},
|
"pdd_products": {"shop_name"},
|
||||||
"syb_orders": {"product_spec"},
|
"syb_orders": {"product_spec"},
|
||||||
|
"syb_sync_runs": {"user_id", "date_from", "date_to", "status", "cursor_advanced", "started_at", "finished_at"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
|
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件是工单 #20 的核心交付物:证明不管从哪种库起步,迁到最新版本后
|
// 本文件是工单 #20 的核心交付物:证明不管从哪种库起步,迁到最新版本后
|
||||||
@@ -623,11 +625,10 @@ func TestMigrate_v6用户表与v7客户端归属表均存在(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #52/#54:显式覆盖每一个已发布版本起点,证明 Client 现实库
|
// 显式覆盖每一个已发布版本起点,证明现实库不会因为后续新增表而卡住。
|
||||||
// 不会因为 v6/v7 的新增表而卡在中间版本。v2 的两种历史结构另由上面的收敛
|
// v2 的两种历史结构另由上面的收敛测试持续覆盖。
|
||||||
// 测试持续覆盖;这里验证顺序发布的 v1-v5 路径。
|
func TestMigrate_v1到v7均可升级到最新版本(t *testing.T) {
|
||||||
func TestMigrate_v1到v5均可升级到最新版本(t *testing.T) {
|
for version := 1; version <= 7; version++ {
|
||||||
for version := 1; version <= 5; version++ {
|
|
||||||
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
|
t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) {
|
||||||
db := newPublishedVersionDB(t, version)
|
db := newPublishedVersionDB(t, version)
|
||||||
if err := Migrate(db); err != nil {
|
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 {
|
func newPublishedVersionDB(t *testing.T, version int) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := Open(t.TempDir())
|
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)
|
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
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,7 +1166,7 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
|||||||
if err := migrateV3(db); err != nil {
|
if err := migrateV3(db); err != nil {
|
||||||
t.Fatalf("准备 v3 数据库失败: %v", err)
|
t.Fatalf("准备 v3 数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
// 故意跳过 v4(不加 shop_name),但把 v5-v7 补上——否则 CheckSchema 会先
|
// 故意跳过 v4(不加 shop_name),但把 v5-v8 补上——否则 CheckSchema 会先
|
||||||
// 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
// 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
||||||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||||||
t.Fatalf("准备 v5 数据库失败: %v", err)
|
t.Fatalf("准备 v5 数据库失败: %v", err)
|
||||||
@@ -1131,6 +1177,9 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
|||||||
if err := runSQLMigration(db, 7, migrationV7); err != nil {
|
if err := runSQLMigration(db, 7, migrationV7); err != nil {
|
||||||
t.Fatalf("准备 v7 数据库失败: %v", err)
|
t.Fatalf("准备 v7 数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runSQLMigration(db, 8, migrationV8); err != nil {
|
||||||
|
t.Fatalf("准备 v8 数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
err := CheckSchema(db)
|
err := CheckSchema(db)
|
||||||
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
||||||
@@ -1152,3 +1201,14 @@ func TestCheckSchema_缺表时拒绝(t *testing.T) {
|
|||||||
t.Errorf("错误信息应该指出缺的是哪张表,实际: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,113 @@ func SetSybLastSyncedAt(q Execer, at string) error {
|
|||||||
return nil
|
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 写入或更新一条顺运宝货运单明细行。
|
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
|
||||||
|
|||||||
@@ -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) {
|
func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
|
||||||
db := newSybTestDB(t)
|
db := newSybTestDB(t)
|
||||||
mustUpsert := func(sybID, orderNo, title string) {
|
mustUpsert := func(sybID, orderNo, title string) {
|
||||||
|
|||||||
+221
-17
@@ -36,8 +36,8 @@ const (
|
|||||||
defaultSybMaxMatches = 10000
|
defaultSybMaxMatches = 10000
|
||||||
)
|
)
|
||||||
|
|
||||||
// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量;
|
// SybSyncOptions 表示一次同步的日期选择。页面必须明确传入两端日期;两端都空
|
||||||
// 两者都有值是操作员明确发起的指定日期补同步。
|
// 仅保留给旧版内部调用兼容。
|
||||||
//
|
//
|
||||||
// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员
|
// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员
|
||||||
// 以为补拉了指定日期,实际却跑了另一段数据。
|
// 以为补拉了指定日期,实际却跑了另一段数据。
|
||||||
@@ -46,7 +46,7 @@ type SybSyncOptions struct {
|
|||||||
To string
|
To string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSpecified 判断这是不是指定日期同步。
|
// IsSpecified 判断是否明确给出了日期范围。
|
||||||
func (o SybSyncOptions) IsSpecified() bool {
|
func (o SybSyncOptions) IsSpecified() bool {
|
||||||
return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != ""
|
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
|
days := int(toDate.Sub(fromDate).Hours()/24) + 1
|
||||||
if days > maxSpecifiedSyncDays {
|
if days > maxSpecifiedSyncDays {
|
||||||
return SybSyncOptions{}, fmt.Errorf("指定日期同步最多选择 %d 天,当前范围为 %d 天,请分段同步",
|
return SybSyncOptions{}, fmt.Errorf("同步日期范围最多选择 %d 天,当前范围为 %d 天,请分段同步",
|
||||||
maxSpecifiedSyncDays, days)
|
maxSpecifiedSyncDays, days)
|
||||||
}
|
}
|
||||||
return SybSyncOptions{From: from, To: to}, nil
|
return SybSyncOptions{From: from, To: to}, nil
|
||||||
@@ -136,6 +136,63 @@ func SybToday(now time.Time) string {
|
|||||||
return dateOf(now)
|
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 算出这次同步该拉哪个日期范围。
|
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||||
//
|
//
|
||||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||||
@@ -186,6 +243,135 @@ type SyncReport struct {
|
|||||||
Err error
|
Err error
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
FinishedAt 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「报告要说清楚」:
|
// Summary 组装状态条文案,格式见工单 #46「报告要说清楚」:
|
||||||
@@ -196,11 +382,7 @@ func (r SyncReport) Summary() string {
|
|||||||
if r.Err != nil {
|
if r.Err != nil {
|
||||||
return "同步失败:" + r.Err.Error()
|
return "同步失败:" + r.Err.Error()
|
||||||
}
|
}
|
||||||
prefix := "同步完成"
|
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
|
||||||
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)
|
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||||
if len(r.Notes) > 0 {
|
if len(r.Notes) > 0 {
|
||||||
var reasons []string
|
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{})
|
return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSybSyncWithOptions 执行自动增量或指定日期同步。
|
// RunSybSyncWithOptions 执行日期范围同步;空范围仅兼容旧版内部自动增量调用。
|
||||||
//
|
//
|
||||||
// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。
|
// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。
|
||||||
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
|
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
|
||||||
@@ -325,7 +507,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
|||||||
maxMatches = defaultSybMaxMatches
|
maxMatches = defaultSybMaxMatches
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSyncedAt, _, err := repository.GetSybLastSyncedAt(db)
|
lastSyncedAt, hasCursor, err := repository.GetSybLastSyncedAt(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
report.Err = fmt.Errorf("读取上次同步进度失败: %w", err)
|
report.Err = fmt.Errorf("读取上次同步进度失败: %w", err)
|
||||||
report.FinishedAt = time.Now().UTC()
|
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
|
from, to := options.From, options.To
|
||||||
advanceCursor := !options.IsSpecified()
|
advanceCursor := !options.IsSpecified()
|
||||||
|
cursorAt := model.NowISO()
|
||||||
if options.IsSpecified() {
|
if options.IsSpecified() {
|
||||||
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
|
validated, validateErr := NewSybSyncOptions(options.From, options.To, now)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
@@ -343,10 +526,27 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
|||||||
}
|
}
|
||||||
from, to = validated.From, validated.To
|
from, to = validated.From, validated.To
|
||||||
|
|
||||||
// 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有
|
cursorAt, err = cursorISOForDate(to)
|
||||||
// sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。
|
if err != nil {
|
||||||
autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
report.Err = err
|
||||||
advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo
|
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 {
|
} else {
|
||||||
from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,8 +594,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
|||||||
if allTotal == 0 {
|
if allTotal == 0 {
|
||||||
report.FinishedAt = time.Now().UTC()
|
report.FinishedAt = time.Now().UTC()
|
||||||
if advanceCursor {
|
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)
|
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||||
|
} else {
|
||||||
|
report.CursorAdvanced = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return report
|
return report
|
||||||
@@ -481,9 +683,11 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
|||||||
|
|
||||||
// ④ 全部成功,才更新 last_synced_at。
|
// ④ 全部成功,才更新 last_synced_at。
|
||||||
if advanceCursor {
|
if advanceCursor {
|
||||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
if err := repository.SetSybLastSyncedAt(db, cursorAt); err != nil {
|
||||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||||
|
} else {
|
||||||
|
report.CursorAdvanced = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
report.FinishedAt = time.Now().UTC()
|
report.FinishedAt = time.Now().UTC()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"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) {
|
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
|
||||||
@@ -489,8 +521,8 @@ func TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) {
|
|||||||
if report.Err != nil {
|
if report.Err != nil {
|
||||||
t.Fatalf("历史补拉失败: %v", report.Err)
|
t.Fatalf("历史补拉失败: %v", report.Err)
|
||||||
}
|
}
|
||||||
if !report.Specified || !strings.HasPrefix(report.Summary(), "指定日期同步完成") {
|
if !report.Specified || !strings.HasPrefix(report.Summary(), "同步完成") {
|
||||||
t.Fatalf("报告没有标明指定日期同步:%+v / %s", report, report.Summary())
|
t.Fatalf("明确日期范围的报告不正确:%+v / %s", report, report.Summary())
|
||||||
}
|
}
|
||||||
at, _, err := repository.GetSybLastSyncedAt(db)
|
at, _, err := repository.GetSybLastSyncedAt(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -522,8 +554,64 @@ func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("读取同步游标失败: %v", err)
|
t.Fatalf("读取同步游标失败: %v", err)
|
||||||
}
|
}
|
||||||
if at == original || at == "" {
|
if at != "2026-07-27T16:00:00Z" || !report.CursorAdvanced {
|
||||||
t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", at)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ body {
|
|||||||
/* 搜索表单占据剩余空间,把删除按钮挤到右边 */
|
/* 搜索表单占据剩余空间,把删除按钮挤到右边 */
|
||||||
.toolbar .grow { flex: 1; }
|
.toolbar .grow { flex: 1; }
|
||||||
.toolbar label { color: #555; white-space: nowrap; }
|
.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 商品页、顺运宝数据页、采集采购页在用;
|
/* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用;
|
||||||
蝦皮数据页和客户端列表页仍是撑满的。
|
蝦皮数据页和客户端列表页仍是撑满的。
|
||||||
这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后,
|
这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后,
|
||||||
@@ -225,6 +230,7 @@ tr.empty small { color: #aaa; }
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
.modal-narrow { max-width: 520px; }
|
.modal-narrow { max-width: 520px; }
|
||||||
|
.modal-wide { max-width: 1160px; }
|
||||||
.field-row {
|
.field-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -262,6 +268,39 @@ tr.empty small { color: #aaa; }
|
|||||||
}
|
}
|
||||||
/* 把左边的按钮和右边的按钮推开 */
|
/* 把左边的按钮和右边的按钮推开 */
|
||||||
.modal-foot .grow { flex: 1; }
|
.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 {
|
button.primary {
|
||||||
background: #1f6feb;
|
background: #1f6feb;
|
||||||
border-color: #1f6feb;
|
border-color: #1f6feb;
|
||||||
|
|||||||
+2
-2
@@ -527,7 +527,7 @@ func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, p
|
|||||||
for _, raw := range wrap.List {
|
for _, raw := range wrap.List {
|
||||||
id, ok := toInt64(raw["id"])
|
id, ok := toInt64(raw["id"])
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id: %v", raw)
|
return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id")
|
||||||
}
|
}
|
||||||
code, _ := raw["code"].(string)
|
code, _ := raw["code"].(string)
|
||||||
rows = append(rows, StockRow{ID: id, Code: code, Raw: raw})
|
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 {
|
for _, raw := range wrap.List {
|
||||||
id, ok := toInt64(raw["id"])
|
id, ok := toInt64(raw["id"])
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id: %v", raw)
|
return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id")
|
||||||
}
|
}
|
||||||
code, _ := raw["code"].(string)
|
code, _ := raw["code"].(string)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<form class="inline" method="post" action="/syb/sync">
|
<form class="inline sync-range-form" method="post" action="/syb/sync">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||||
|
<label for="sync-date-from">开始日期</label>
|
||||||
|
<input id="sync-date-from" type="date" name="date_from"
|
||||||
|
value="{{.SyncDateFrom}}" max="{{.Today}}" required>
|
||||||
|
<label for="sync-date-to">结束日期</label>
|
||||||
|
<input id="sync-date-to" type="date" name="date_to"
|
||||||
|
value="{{.SyncDateTo}}" max="{{.Today}}" required>
|
||||||
<button type="submit" class="primary">同步</button>
|
<button type="submit" class="primary">同步</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button type="button" data-modal-open="sync-range-modal">指定日期同步</button>
|
<button type="button" data-modal-open="sync-history-modal">同步记录</button>
|
||||||
|
|
||||||
<form class="inline" method="post" action="/syb/create-task">
|
<form class="inline" method="post" action="/syb/create-task">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
@@ -30,44 +36,65 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* 指定日期只用于补拉历史数据。日常增量仍走工具栏第一个“同步”按钮,
|
{{if .RangeError}}<p class="missing sync-range-message" role="alert">{{.RangeError}}</p>{{end}}
|
||||||
避免操作员忘记清掉旧日期、下一次又误跑同一段历史数据。 */}}
|
{{if .RangeWarning}}<p class="hint sync-range-message">{{.RangeWarning}}</p>{{end}}
|
||||||
<div class="modal-backdrop" id="sync-range-modal" {{if not .NeedRangeSync}}hidden{{end}}>
|
<p class="hint sync-range-message">
|
||||||
<div class="modal modal-narrow" role="dialog" aria-modal="true"
|
按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含,每次最多 31 天。
|
||||||
aria-labelledby="sync-range-modal-title">
|
</p>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="sync-history-modal" {{if not .NeedSyncHistory}}hidden{{end}}>
|
||||||
|
<div class="modal modal-wide" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="sync-history-modal-title">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h2 id="sync-range-modal-title">指定日期同步</h2>
|
<h2 id="sync-history-modal-title">同步记录</h2>
|
||||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/syb/sync">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{if .RangeError}}
|
<p class="hint">共 {{.SyncHistory.Total}} 条,按发起时间从新到旧排列。</p>
|
||||||
<p class="missing" role="alert">{{.RangeError}}</p>
|
<div class="table-wrap sync-history-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>状态</th><th>日期范围</th><th>操作账号</th><th>开始时间</th>
|
||||||
|
<th>完成时间</th><th>同步结果</th><th>覆盖进度</th><th>失败原因</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .SyncHistory.Rows}}
|
||||||
|
<tr>
|
||||||
|
<td><span class="sync-status {{.StatusClass}}">{{.StatusText}}</span></td>
|
||||||
|
<td>{{.DateRange}}</td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{.StartedAt}}</td>
|
||||||
|
<td>{{.FinishedAt}}</td>
|
||||||
|
<td>{{.Summary}}</td>
|
||||||
|
<td>{{.CursorText}}</td>
|
||||||
|
<td class="sync-error">{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}—{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr class="empty"><td colspan="8">还没有同步记录。</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="field-row">
|
</tbody>
|
||||||
<div class="field">
|
</table>
|
||||||
<label for="sync-date-from">开始日期</label>
|
|
||||||
<input id="sync-date-from" type="date" name="date_from"
|
|
||||||
value="{{.SyncDateFrom}}" max="{{.Today}}" required autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="sync-date-to">结束日期</label>
|
|
||||||
<input id="sync-date-to" type="date" name="date_to"
|
|
||||||
value="{{.SyncDateTo}}" max="{{.Today}}" required>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
<div class="modal-foot sync-history-foot">
|
||||||
按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含。
|
<span>第 {{.SyncHistory.Page}} / {{.SyncHistory.TotalPages}} 页</span>
|
||||||
每次最多选择 31 天;日常同步请直接使用工具栏的“同步”。
|
<span class="grow"></span>
|
||||||
</p>
|
{{if .HistoryPagination.HasPrev}}
|
||||||
|
<a class="button-link" href="{{.HistoryPagination.FirstURL}}">首页</a>
|
||||||
|
<a class="button-link" href="{{.HistoryPagination.PrevURL}}">上一页</a>
|
||||||
|
{{else}}
|
||||||
|
<span class="button-link disabled">首页</span><span class="button-link disabled">上一页</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .HistoryPagination.HasNext}}
|
||||||
|
<a class="button-link" href="{{.HistoryPagination.NextURL}}">下一页</a>
|
||||||
|
<a class="button-link" href="{{.HistoryPagination.LastURL}}">末页</a>
|
||||||
|
{{else}}
|
||||||
|
<span class="button-link disabled">下一页</span><span class="button-link disabled">末页</span>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" data-modal-close>关闭</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
|
||||||
<button type="button" data-modal-close>取消</button>
|
|
||||||
<button type="submit" class="primary">开始同步</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,11 +188,9 @@
|
|||||||
<form method="post" action="/syb/login-and-sync">
|
<form method="post" action="/syb/login-and-sync">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||||
{{/* 指定日期同步遇到登录时必须把范围带过来;为空就是普通增量同步。 */}}
|
{{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}}
|
||||||
{{if .HasSyncRange}}
|
|
||||||
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
|
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
|
||||||
<input type="hidden" name="date_to" value="{{.SyncDateTo}}">
|
<input type="hidden" name="date_to" value="{{.SyncDateTo}}">
|
||||||
{{end}}
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
|
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
|
||||||
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
|
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
|||||||
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
|
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
|
||||||
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
|
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
|
||||||
| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 |
|
| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 |
|
||||||
|
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
|
||||||
|
|
||||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||||
@@ -498,6 +499,16 @@ CREATE TABLE syb_sync_state (
|
|||||||
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
|
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
|
||||||
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
|
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
|
||||||
|
|
||||||
|
### 5.2 `syb_sync_runs` 同步记录(v8)
|
||||||
|
|
||||||
|
每次从页面发起同步时,先写一条 `running` 记录,再启动后台任务。记录保存操作
|
||||||
|
账号、日期范围、开始/完成时间、结果统计、是否推进覆盖游标以及必要的失败原因。
|
||||||
|
状态只能是 `running`、`succeeded`、`failed`、`interrupted`;Admin 启动时把上次
|
||||||
|
进程遗留的 `running` 记录改成 `interrupted`。
|
||||||
|
|
||||||
|
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
|
||||||
|
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
|
||||||
|
|
||||||
## 6. `sku_mappings` 规格映射
|
## 6. `sku_mappings` 规格映射
|
||||||
|
|
||||||
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
||||||
|
|||||||
@@ -411,10 +411,15 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
|||||||
### 6.1 工具条
|
### 6.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[同步] [指定日期同步] [创建采购任务] 订单号 [___] [搜索] [删除]
|
开始日期 [2026-08-07] 结束日期 [2026-08-09] [同步] [同步记录]
|
||||||
|
[创建采购任务] 订单号 [___] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
`[必须]` 「同步」按工单 #46 实现:
|
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。通常默认最近 3 天;如果
|
||||||
|
覆盖游标更早,则从游标当天开始补齐。缺口超过 31 天时只预选最早 31 天,并明确
|
||||||
|
提示本段成功后继续下一段。
|
||||||
|
|
||||||
|
`[必须]` 「同步」是唯一同步入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
点「同步」
|
点「同步」
|
||||||
@@ -428,7 +433,7 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
|||||||
```
|
```
|
||||||
|
|
||||||
同步是长任务,不阻塞 HTTP 请求线程——点完立即跳转,结果异步写进内存态的
|
同步是长任务,不阻塞 HTTP 请求线程——点完立即跳转,结果异步写进内存态的
|
||||||
"最近一次同步报告",下次刷新页面时状态条会显示:
|
“最近一次同步报告”和持久化同步记录,下次刷新页面时状态条会显示:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
|
同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
|
||||||
@@ -438,25 +443,16 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
|||||||
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
|
有跳过/失败会在后面列出具体原因,不是只给个数字。同步中再次点「同步」会提示
|
||||||
"已经有一个同步任务在跑",不会并发跑两个。
|
"已经有一个同步任务在跑",不会并发跑两个。
|
||||||
|
|
||||||
`[必须]` 工单 #53 增加的「指定日期同步」是次要操作,不改变上面「同步」的
|
|
||||||
自动增量语义。点击后打开小弹窗:
|
|
||||||
|
|
||||||
```text
|
|
||||||
指定日期同步
|
|
||||||
|
|
||||||
开始日期 [2026-08-01] 结束日期 [2026-08-09]
|
|
||||||
按货运单创建日期(UTC+8)同步,开始日和结束日都包含。每次最多选择 31 天。
|
|
||||||
|
|
||||||
[取消] [开始同步]
|
|
||||||
```
|
|
||||||
|
|
||||||
- 两端都必填,结束日期不得晚于 UTC+8 下的今天,闭区间最多 31 天。
|
- 两端都必填,结束日期不得晚于 UTC+8 下的今天,闭区间最多 31 天。
|
||||||
- 指定历史范围用于首次分批拉取和补数据;日常操作仍点「同步」。
|
- 日期范围经过自动 OCR 或手工验证码登录后必须原样保留。
|
||||||
- 指定范围经过自动 OCR 或手工验证码登录后必须原样保留。
|
- 日期校验失败时保留原输入,并在工具条下方显示可读错误。
|
||||||
- 日期校验失败时重开弹窗、保留原输入,并在字段区域显示可读错误。
|
- 是否推进覆盖游标的规则见
|
||||||
- 指定历史范围默认不推进自动增量游标;游标规则见
|
|
||||||
[08 顺运宝接口](08-顺运宝接口.md) §4.4。
|
[08 顺运宝接口](08-顺运宝接口.md) §4.4。
|
||||||
|
|
||||||
|
`[必须]` 「同步记录」打开宽弹窗,按开始时间从新到旧分页显示状态、日期范围、
|
||||||
|
操作账号、开始/完成时间、同步统计、覆盖游标结果和失败原因。状态至少区分同步中、
|
||||||
|
成功、失败、已中断;不能只靠颜色表达。刷新或重启 Admin 后记录仍可查看。
|
||||||
|
|
||||||
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
||||||
|
|
||||||
### 6.2 表格列
|
### 6.2 表格列
|
||||||
|
|||||||
+13
-17
@@ -196,33 +196,29 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
|||||||
全部页去重后的货运单 ID 数也必须完全相等。分页期间总数变化、短页造成缺失或
|
全部页去重后的货运单 ID 数也必须完全相等。分页期间总数变化、短页造成缺失或
|
||||||
重复 ID 都视为本次同步失败,不推进游标。
|
重复 ID 都视为本次同步失败,不推进游标。
|
||||||
|
|
||||||
### 4.4 自动增量与指定日期补同步
|
### 4.4 统一日期范围同步与覆盖游标
|
||||||
|
|
||||||
页面有两种同步入口,但共用 §4.2 的日期范围接口、分页、明细读取和 upsert:
|
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。通常默认最近
|
||||||
|
3 天;如果 `last_synced_at` 更早,默认范围从覆盖游标当天开始。缺口超过 31 天时
|
||||||
|
只选择最早一段 31 天,本段成功后下一次继续显示后一段。
|
||||||
|
|
||||||
| 模式 | 日期范围 | `last_synced_at` |
|
`[必须]` 首次成功同步以所选结束日建立覆盖游标。已有游标时,只有日期范围从
|
||||||
|---|---|---|
|
游标当天或更早开始、并且结束日在游标之后,全部成功后才推进游标。局部历史补拉
|
||||||
| 自动增量「同步」 | 上次成功同步日期当天 ~ UTC+8 下的今天;首次从 `sync_from` 开始 | 全部成功后推进 |
|
或跳过缺口的范围只 upsert 数据,不动游标。
|
||||||
| 「指定日期同步」 | 操作员填写的开始日 ~ 结束日,两端都包含,最多 31 天 | 见下方安全规则 |
|
|
||||||
|
|
||||||
`[必须]` 指定范围只有在**完整覆盖自动增量本来应该同步的区间**时,全部成功后
|
例如覆盖游标为 `2026-08-01`,同步 `2026-08-01 ~ 2026-08-09` 可以推进到
|
||||||
才可以推进 `last_synced_at`。局部历史补拉只 upsert 数据,不动游标。
|
`2026-08-09`;只同步 `2026-08-07 ~ 2026-08-08` 不能推进,因为中间有缺口。
|
||||||
|
|
||||||
例如系统本来应该同步 `2026-08-01 ~ 2026-08-09`,操作员只补拉
|
|
||||||
`2026-08-07 ~ 2026-08-08`。如果这时把游标推进到当前时间,前后没有覆盖的
|
|
||||||
日期可能永远不会再自动同步,而且不会报错。
|
|
||||||
|
|
||||||
`[必须]` 日期格式固定 `YYYY-MM-DD`,按 UTC+8 解释;两端必须同时填写,
|
`[必须]` 日期格式固定 `YYYY-MM-DD`,按 UTC+8 解释;两端必须同时填写,
|
||||||
开始不得晚于结束,结束不得晚于 UTC+8 下的今天;闭区间最多 31 天。自动增量
|
开始不得晚于结束,结束不得晚于 UTC+8 下的今天;闭区间最多 31 天。中途失败
|
||||||
不受 31 天人工选择限制,但同样逐日查询。中途失败或超过
|
或超过 `max_matches` 时不推进游标。
|
||||||
`max_matches` 时,两种模式都不推进游标。
|
|
||||||
|
|
||||||
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
|
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
|
||||||
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
|
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
|
||||||
可以保留,但只有所有日期全部成功才推进游标。
|
可以保留,但只有所有日期全部成功才推进游标。
|
||||||
|
|
||||||
`[必须]` 登录和验证码只是同步前置步骤。指定范围经过自动 OCR 降级、手工
|
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
|
||||||
输入验证码和 303 跳转时必须原样保留,不能悄悄退化成自动增量。
|
输入验证码和 303 跳转时必须原样保留。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user