feat: 统一顺运宝日期同步并记录历史 (#59)

This commit is contained in:
chengma
2026-08-09 18:22:58 +08:00
parent ea7932db63
commit 05a54b044d
18 changed files with 934 additions and 144 deletions
+97 -19
View File
@@ -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())
}
+36 -3
View File
@@ -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)
}
}
}