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
+56 -12
View File
@@ -86,7 +86,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
// 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作;
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
// 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。
needLogin := loginReason != ""
needLogin := c.Query("login") == "1"
today := service.SybToday(time.Now())
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
hasSyncRange := dateFrom != "" || dateTo != ""
if dateFrom == "" {
dateFrom = today
}
if dateTo == "" {
dateTo = today
}
cfg, cfgErr := config.Load()
switch {
@@ -119,6 +129,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"LoginHint": loginHint,
"Username": username,
"ConfigProblem": configProblem,
"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()),
}))
}
@@ -142,6 +158,12 @@ func sybStatusLine(result *service.SybListResult) string {
// │ └→ 识别失败/OCR 不可达 ──┐
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
func (h *Handler) SybSync(c *gin.Context) {
options, err := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
if err != nil {
h.sybRedirectRange(c, err.Error())
return
}
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
@@ -166,7 +188,7 @@ func (h *Handler) SybSync(c *gin.Context) {
h.sybRedirectLogin(c, reason)
return
}
if !h.startSybSync(client, *cfg) {
if !h.startSybSync(client, *cfg, options) {
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
return
}
@@ -174,7 +196,7 @@ func (h *Handler) SybSync(c *gin.Context) {
return
}
if !h.startSybSync(client, *cfg) {
if !h.startSybSync(client, *cfg, options) {
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
return
}
@@ -244,6 +266,12 @@ func (h *Handler) SybCaptcha(c *gin.Context) {
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
// 界面上只有验证码是操作员手输的,见工单 #46。
func (h *Handler) SybLoginAndSync(c *gin.Context) {
options, optionErr := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
if optionErr != nil {
h.sybRedirectRange(c, optionErr.Error())
return
}
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
@@ -275,7 +303,7 @@ 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) {
if !h.startSybSync(client, *cfg, options) {
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
return
}
@@ -290,13 +318,13 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) {
//
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions) bool {
if !service.TryStartSybSync() {
return false
}
db := h.db
go func() {
report := service.RunSybSync(context.Background(), db, client, cfg.Syb, time.Now())
report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options)
service.FinishSybSync(report)
}()
return true
@@ -306,9 +334,7 @@ func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
params := url.Values{}
if q := c.PostForm("order_no"); q != "" {
params.Set("order_no", q)
}
appendSybFormState(params, c)
if msg != "" {
params.Set("msg", msg)
}
@@ -325,9 +351,8 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) {
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
params := url.Values{}
if q := c.PostForm("order_no"); q != "" {
params.Set("order_no", q)
}
appendSybFormState(params, c)
params.Set("login", "1")
if reason != "" {
params.Set("login_reason", reason)
}
@@ -338,6 +363,25 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
c.Redirect(http.StatusSeeOther, target)
}
// 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())
}
// appendSybFormState 保存同步/登录跳转后仍需要的页面状态。
func appendSybFormState(params url.Values, c *gin.Context) {
for _, name := range []string{"order_no", "date_from", "date_to"} {
if value := strings.TrimSpace(c.PostForm(name)); value != "" {
params.Set(name, value)
}
}
}
// SybMatch 保存规格匹配结果。
//
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
+68
View File
@@ -0,0 +1,68 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func sybPostContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
request := httptest.NewRequest(http.MethodPost, "/syb/sync", strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
context.Request = request
return context, recorder
}
func TestSybRedirectLogin_保留指定日期并打开登录弹窗(t *testing.T) {
context, recorder := sybPostContext(t, url.Values{
"order_no": {"ORDER-1"},
"date_from": {"2026-08-01"},
"date_to": {"2026-08-09"},
})
(&Handler{}).sybRedirectLogin(context, "")
if context.Writer.Status() != http.StatusSeeOther {
t.Fatalf("状态码 = %d,期望 %d", context.Writer.Status(), http.StatusSeeOther)
}
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatalf("解析 Location 失败: %v", err)
}
query := location.Query()
for name, want := range map[string]string{
"login": "1", "order_no": "ORDER-1",
"date_from": "2026-08-01", "date_to": "2026-08-09",
} {
if got := query.Get(name); got != want {
t.Errorf("%s = %q,期望 %q;Location=%s", name, got, want, location.String())
}
}
}
func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T) {
context, recorder := sybPostContext(t, url.Values{
"date_from": {"2026-08-09"},
"date_to": {"2026-08-01"},
})
(&Handler{}).sybRedirectRange(context, "开始日期不能晚于结束日期")
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
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("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" {
t.Fatalf("日期错误后应该保留两端输入,Location=%s", location.String())
}
}