From cf70021ab9fb2aad9486ac2c75e38bf0484ce2eb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 23 Sep 2026 10:17:29 +0800 Subject: [PATCH] feat(yeeke): add read-only return sync #336 Co-Authored-By: Codex GPT-6-astra Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 4 + server/app/goauto/models/yeeke.go | 82 +++++ server/app/goauto/yeeke/sync.go | 182 +++++++++++ server/app/goauto/yeeke/sync_test.go | 42 +++ server/app/goauto/yeekeclient/client.go | 294 ++++++++++++++++++ server/app/goauto/yeekeclient/client_test.go | 40 +++ server/app/goauto/yeekeclient/connect.go | 42 +++ server/app/goauto/yeekeclient/session.go | 37 +++ .../1789800400000_yeeke_return_sync.go | 22 ++ 9 files changed, 745 insertions(+) create mode 100644 server/app/goauto/models/yeeke.go create mode 100644 server/app/goauto/yeeke/sync.go create mode 100644 server/app/goauto/yeeke/sync_test.go create mode 100644 server/app/goauto/yeekeclient/client.go create mode 100644 server/app/goauto/yeekeclient/client_test.go create mode 100644 server/app/goauto/yeekeclient/connect.go create mode 100644 server/app/goauto/yeekeclient/session.go create mode 100644 server/cmd/migrate/migration/version-local/1789800400000_yeeke_return_sync.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 0fc643e..e9aeb32 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -44,6 +44,10 @@ func MigratedModels() []any { &models.SYBShop{}, &models.SYBProductFilter{}, &models.SYBSyncRun{}, + &models.YeekeSession{}, + &models.YeekeReturnPackage{}, + &models.YeekeReturnItem{}, + &models.YeekeSyncRun{}, &models.SYBInnerCodeRecord{}, &models.SYBInnerCodeItem{}, &models.SYBInnerCodeApplyBatch{}, diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go new file mode 100644 index 0000000..2848083 --- /dev/null +++ b/server/app/goauto/models/yeeke.go @@ -0,0 +1,82 @@ +package models + +import "time" + +// YeekeSession stores only the opaque session material; credentials are kept +// outside the application database and supplied by the administrator at run time. +type YeekeSession struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_session_username"` + Token string `json:"-" gorm:"type:text;not null"` + CookiesJSON string `json:"-" gorm:"type:text;not null"` + UserID string `gorm:"size:128;not null;default:''"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (YeekeSession) TableName() string { return "yeeke_session" } + +type YeekeReturnPackage struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + ExternalID string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_return_package_external"` + OrderSN string `gorm:"size:128;not null;index"` + TrackingNo string `gorm:"size:128;not null;index"` + ShopID string `gorm:"size:128;not null;default:''"` + ShopName string `gorm:"size:255;not null;default:''"` + WareCode string `gorm:"size:128;not null;default:''"` + WareHouse string `gorm:"size:255;not null;default:''"` + WareName string `gorm:"size:255;not null;default:''"` + ClaimStatus string `gorm:"size:64;not null;default:''"` + ClaimTime *time.Time + CreateTime *time.Time + UpdateTime *time.Time + DestroyDeadLine *time.Time + LastSyncedAt time.Time `gorm:"not null;index"` + SyncStatus string `gorm:"size:32;not null;default:'ok'"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" } + +type YeekeReturnItem struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + PackageID uint64 `gorm:"not null;uniqueIndex:ux_yeeke_return_item_key,priority:1;index"` + ExternalKey string `gorm:"size:512;not null;uniqueIndex:ux_yeeke_return_item_key,priority:2"` + ItemID string `gorm:"size:128;not null;index"` + VariationID string `gorm:"size:128;not null;default:''"` + ItemName string `gorm:"size:500;not null;default:''"` + VariationName string `gorm:"size:500;not null;default:''"` + Image string `gorm:"type:text;not null"` + Quantity int64 `gorm:"not null;default:0"` + LastSyncedAt time.Time `gorm:"not null;index"` + SyncStatus string `gorm:"size:32;not null;default:'ok'"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (YeekeReturnItem) TableName() string { return "yeeke_return_item" } + +type YeekeSyncRun struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Status string `gorm:"size:32;not null;index"` + Trigger string `gorm:"size:32;not null;index"` + TotalPages int `gorm:"not null;default:0"` + ReadCount int `gorm:"not null;default:0"` + CreatedCount int `gorm:"not null;default:0"` + UpdatedCount int `gorm:"not null;default:0"` + SkippedCount int `gorm:"not null;default:0"` + FailedCount int `gorm:"not null;default:0"` + ErrorMessage string `gorm:"size:1000;not null;default:''"` + StartedAt time.Time `gorm:"not null"` + FinishedAt *time.Time + LastSuccessAt *time.Time + ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` + LeaseOwner string `gorm:"size:128;not null;default:''"` + LeaseExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" } diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go new file mode 100644 index 0000000..61bba7d --- /dev/null +++ b/server/app/goauto/yeeke/sync.go @@ -0,0 +1,182 @@ +package yeeke + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" + "gorm.io/gorm" + "strconv" + "time" +) + +type Config struct { + PageSize, MaxPages, Retry int + Lease time.Duration +} + +func (c Config) norm() Config { + if c.PageSize <= 0 || c.PageSize > 500 { + c.PageSize = 100 + } + if c.MaxPages <= 0 || c.MaxPages > 10000 { + c.MaxPages = 10000 + } + if c.Retry < 0 || c.Retry > 5 { + c.Retry = 2 + } + if c.Lease <= 0 { + c.Lease = 30 * time.Minute + } + return c +} + +type Report struct { + RunID uint64 + TotalPages, Read, Created, Updated, Skipped, Failed int + Status string +} + +func external(v any) string { return fmt.Sprint(v) } +func stamp(t *yeekeclient.Timestamp) *time.Time { + if t == nil || t.IsZero() { + return nil + } + x := t.Time + return &x +} +func packageKey(p yeekeclient.ReturnPackage) string { + if x := external(p.ID); x != "" && x != "" { + return x + } + return p.Ordersn + "/" + p.TrackingNo + "/" + external(p.ShopID) + "/" + p.CreateTime.String() +} +func itemKey(p yeekeclient.ReturnPackage, i yeekeclient.ReturnItem, n int) string { + return packageKey(p) + "/" + external(i.ID) + "/" + external(i.ItemID) + "/" + external(i.VariationID) + "/" + strconv.Itoa(n) +} +func (s *Service) acquire(ctx context.Context, trigger string) (*models.YeekeSyncRun, error) { + now := time.Now().UTC() + owner := fmt.Sprintf("%d", now.UnixNano()) + slot := uint8(1) + exp := now.Add(s.cfg.Lease) + r := &models.YeekeSyncRun{Status: "running", Trigger: trigger, StartedAt: now, ActiveSlot: &slot, LeaseOwner: owner, LeaseExpiresAt: &exp} + if e := s.db.WithContext(ctx).Create(r).Error; e != nil { + return nil, e + } + return r, nil +} + +type Service struct { + db *gorm.DB + client *yeekeclient.Client + cfg Config +} + +func NewService(db *gorm.DB, c *yeekeclient.Client, cfg Config) *Service { + return &Service{db: db, client: c, cfg: cfg.norm()} +} +func (s *Service) Sync(ctx context.Context, trigger string) (Report, error) { + r, e := s.acquire(ctx, trigger) + if e != nil { + return Report{}, e + } + rep := Report{RunID: r.ID, Status: "failed"} + defer func() { + now := time.Now().UTC() + s.db.Model(r).Updates(map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}) + }() + seen := map[string]bool{} + for page := 1; page <= s.cfg.MaxPages; page++ { + var p yeekeclient.ReturnPage + for a := 0; ; a++ { + p, e = s.client.List(ctx, page, s.cfg.PageSize) + if e == nil || a >= s.cfg.Retry { + break + } + select { + case <-ctx.Done(): + return rep, ctx.Err() + case <-time.After(time.Duration(a+1) * 100 * time.Millisecond): + } + } + if e != nil { + return rep, e + } + rep.TotalPages = page + if len(p.Records) == 0 { + break + } + finger := pageFingerprint(p) + if seen[finger] { + rep.Skipped += len(p.Records) + break + } + seen[finger] = true + for _, x := range p.Records { + created, updated, err := s.upsert(ctx, x) + if err != nil { + rep.Failed++ + continue + } + rep.Read++ + if created { + rep.Created++ + } else if updated { + rep.Updated++ + } else { + rep.Skipped++ + } + } + if len(p.Records) < s.cfg.PageSize { + break + } + if p.Pages > 0 && page >= p.Pages { + break + } + } + rep.Status = "succeeded" + return rep, nil +} +func pageFingerprint(p yeekeclient.ReturnPage) string { + b, _ := json.Marshal(p.Records) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} +func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) { + now := time.Now().UTC() + key := packageKey(p) + var row models.YeekeReturnPackage + e := s.db.WithContext(ctx).Where("external_id = ?", key).First(&row).Error + isNew := e == gorm.ErrRecordNotFound + if e != nil && !isNew { + return false, false, e + } + vals := map[string]any{"external_id": key, "order_sn": p.Ordersn, "tracking_no": p.TrackingNo, "shop_id": external(p.ShopID), "shop_name": p.ShopName, "ware_code": p.WareCode, "ware_house": p.WareHouse, "ware_name": p.WareName, "claim_status": external(p.Status), "claim_time": stamp(p.ClaimTime), "create_time": stamp(p.CreateTime), "update_time": stamp(p.UpdateTime), "destroy_dead_line": stamp(p.DestroyDeadLine), "last_synced_at": now, "sync_status": "ok"} + if isNew { + row = models.YeekeReturnPackage{ExternalID: key} + if e = s.db.WithContext(ctx).Create(&row).Error; e != nil { + return false, false, e + } + } + if e = s.db.WithContext(ctx).Model(&row).Updates(vals).Error; e != nil { + return false, false, e + } + for n, i := range p.Items { + ik := itemKey(p, i, n) + ir := models.YeekeReturnItem{} + ie := s.db.Where("package_id = ? AND external_key = ?", row.ID, ik).First(&ir).Error + iv := map[string]any{"package_id": row.ID, "external_key": ik, "item_id": external(i.ItemID), "variation_id": external(i.VariationID), "item_name": i.ItemName, "variation_name": i.VariationName, "image": i.Image, "quantity": i.Quantity, "last_synced_at": now, "sync_status": "ok"} + if ie == gorm.ErrRecordNotFound { + if e = s.db.Create(&models.YeekeReturnItem{PackageID: row.ID, ExternalKey: ik}).Error; e != nil { + return false, false, e + } + } + if e = s.db.Model(&ir).Where("package_id = ? AND external_key = ?", row.ID, ik).Updates(iv).Error; e != nil { + return false, false, e + } + } + return isNew, !isNew, nil +} diff --git a/server/app/goauto/yeeke/sync_test.go b/server/app/goauto/yeeke/sync_test.go new file mode 100644 index 0000000..2648921 --- /dev/null +++ b/server/app/goauto/yeeke/sync_test.go @@ -0,0 +1,42 @@ +package yeeke + +import ( + "context" + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSyncIsIdempotentAndKeepsVariationsSeparate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":true,"result":{"records":[{"id":"p1","ordersn":"o","trackingNo":"t","status":1,"items":[{"id":"a","itemId":"i","variationId":"v1","itemName":"n","variationName":"red","variationQuantityPurchased":1},{"id":"b","itemId":"i","variationId":"v2","itemName":"n","variationName":"blue","variationQuantityPurchased":1}]}],"total":1,"pages":1}}`)) + })) + defer server.Close() + db, _ := gorm.Open(sqlite.Open("file:yeeke-sync?mode=memory&cache=shared"), &gorm.Config{}) + if e := migrations.Migrate(db); e != nil { + t.Fatal(e) + } + c, _ := yeekeclient.New(server.URL) + s := NewService(db, c, Config{PageSize: 10}) + if _, e := s.Sync(context.Background(), "manual"); e != nil { + t.Fatal(e) + } + if _, e := s.Sync(context.Background(), "manual"); e != nil { + t.Fatal(e) + } + var n int64 + db.Model(&models.YeekeReturnPackage{}).Count(&n) + if n != 1 { + t.Fatalf("packages=%d", n) + } + db.Model(&models.YeekeReturnItem{}).Count(&n) + if n != 2 { + t.Fatalf("items=%d", n) + } +} diff --git a/server/app/goauto/yeekeclient/client.go b/server/app/goauto/yeekeclient/client.go new file mode 100644 index 0000000..8a15a44 --- /dev/null +++ b/server/app/goauto/yeekeclient/client.go @@ -0,0 +1,294 @@ +package yeekeclient + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" +) + +var ErrSessionInvalid = errors.New("yeeke session invalid") +var ErrNoSession = errors.New("yeeke session unavailable") + +type Session struct { + Username, Token, CookiesJSON, UserID string + ExpiresAt time.Time +} +type Captcha struct { + Image []byte + CheckKey string + ContentType string +} +type Client struct { + baseURL string + http *http.Client + jar *cookiejar.Jar + token string + retry int +} + +func New(baseURL string) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, fmt.Errorf("yeeke base_url required") + } + j, e := cookiejar.New(nil) + if e != nil { + return nil, e + } + return &Client{baseURL: baseURL, jar: j, http: &http.Client{Jar: j, Timeout: 60 * time.Second}}, nil +} +func (c *Client) SetToken(t string) { c.token = t } +func (c *Client) Token() string { return c.token } + +type cookieDTO struct{ Name, Value, Path string } + +func (c *Client) ExportCookiesJSON() (string, error) { + u, e := url.Parse(c.baseURL) + if e != nil { + return "", e + } + a := []cookieDTO{} + for _, x := range c.jar.Cookies(u) { + a = append(a, cookieDTO{x.Name, x.Value, x.Path}) + } + b, e := json.Marshal(a) + return string(b), e +} +func (c *Client) ImportCookiesJSON(s string) error { + var a []cookieDTO + if e := json.Unmarshal([]byte(s), &a); e != nil { + return e + } + u, e := url.Parse(c.baseURL) + if e != nil { + return e + } + cs := []*http.Cookie{} + for _, x := range a { + if x.Name != "" { + p := x.Path + if p == "" { + p = "/" + } + cs = append(cs, &http.Cookie{Name: x.Name, Value: x.Value, Path: p}) + } + } + c.jar.SetCookies(u, cs) + return nil +} +func (c *Client) do(ctx context.Context, method, path string, body any, query url.Values) (json.RawMessage, error) { + b := io.Reader(nil) + if body != nil { + x, e := json.Marshal(body) + if e != nil { + return nil, e + } + b = bytes.NewReader(x) + } + u := c.baseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, e := http.NewRequestWithContext(ctx, method, u, b) + if e != nil { + return nil, e + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + q := req.URL.Query() + q.Set("token", c.token) + req.URL.RawQuery = q.Encode() + } + resp, e := c.http.Do(req) + if e != nil { + return nil, e + } + defer resp.Body.Close() + raw, e := io.ReadAll(resp.Body) + if e != nil { + return nil, e + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, ErrSessionInvalid + } + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("yeeke http %d", resp.StatusCode) + } + var env struct { + Success bool `json:"success"` + Code int `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if e = json.Unmarshal(raw, &env); e != nil { + return nil, e + } + if !env.Success { + if env.Code == 401 || strings.Contains(env.Message, "登录") || strings.Contains(env.Message, "token") { + return nil, ErrSessionInvalid + } + return nil, fmt.Errorf("yeeke request failed code=%d", env.Code) + } + return env.Result, nil +} +func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) { + raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, nil) + if e != nil { + return nil, e + } + var p struct { + Image string `json:"image"` + CheckKey string `json:"checkKey"` + } + if e = json.Unmarshal(raw, &p); e != nil { + return nil, e + } + s := p.Image + if i := strings.Index(s, ","); i >= 0 { + s = s[i+1:] + } + img, e := base64.StdEncoding.DecodeString(s) + if e != nil { + return nil, e + } + return &Captcha{Image: img, CheckKey: p.CheckKey, ContentType: "image/jpeg"}, nil +} + +type LoginResult struct { + Token, UserID, Username string + ExpiresAt time.Time +} + +type OCR interface { + Recognize(context.Context, []byte) (string, error) +} + +// LoginWithOCR keeps captcha bytes in memory and never includes credentials or +// recognized text in returned errors. A fresh image is fetched for every try. +func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password string, maxAttempts int) (*LoginResult, error) { + if ocr == nil { + return nil, fmt.Errorf("yeeke OCR unavailable") + } + if maxAttempts <= 0 || maxAttempts > 5 { + maxAttempts = 3 + } + for i := 0; i < maxAttempts; i++ { + cap, e := c.FetchCaptcha(ctx) + if e != nil { + return nil, e + } + code, e := ocr.Recognize(ctx, cap.Image) + if e != nil { + return nil, fmt.Errorf("yeeke OCR unavailable") + } + code = strings.TrimSpace(code) + if code == "" { + continue + } + if out, e := c.Login(ctx, username, password, code, cap.CheckKey); e == nil { + return out, nil + } + } + return nil, fmt.Errorf("yeeke login failed after limited captcha attempts") +} + +func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) { + if username == "" || password == "" || captcha == "" || checkKey == "" { + return nil, fmt.Errorf("login fields required") + } + raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/sys/login", map[string]string{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt"}, nil) + if e != nil { + return nil, e + } + var p struct { + Token string `json:"token"` + UserInfo struct { + ID any `json:"id"` + Username string `json:"username"` + } `json:"userInfo"` + } + if e = json.Unmarshal(raw, &p); e != nil { + return nil, e + } + if p.Token == "" { + return nil, fmt.Errorf("yeeke login response missing token") + } + c.token = p.Token + return &LoginResult{Token: p.Token, UserID: fmt.Sprint(p.UserInfo.ID), Username: p.UserInfo.Username, ExpiresAt: time.Now().UTC().Add(24 * time.Hour)}, nil +} +func (c *Client) CheckSession(ctx context.Context) error { + _, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/userInfo", nil, nil) + return e +} + +type ReturnPage struct { + Records []ReturnPackage `json:"records"` + Total int `json:"total"` + Pages int `json:"pages"` +} +type Timestamp struct{ time.Time } + +func (t *Timestamp) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) != nil || s == "" { + return nil + } + for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if x, e := time.ParseInLocation(f, s, time.UTC); e == nil { + t.Time = x + return nil + } + } + return nil +} + +type ReturnPackage struct { + ID any `json:"id"` + Ordersn string `json:"ordersn"` + TrackingNo string `json:"trackingNo"` + ShopID any `json:"shopId"` + ShopName string `json:"shopName"` + WareCode string `json:"wareCode"` + WareHouse string `json:"wareHouse"` + WareName string `json:"wareName"` + Status any `json:"status"` + ClaimTime *Timestamp `json:"claimTime"` + CreateTime *Timestamp `json:"createTime"` + UpdateTime *Timestamp `json:"updateTime"` + DestroyDeadLine *Timestamp `json:"destroyDeadLine"` + Items []ReturnItem `json:"items"` +} +type ReturnItem struct { + ID any `json:"id"` + ItemID any `json:"itemId"` + VariationID any `json:"variationId"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + Image string `json:"image"` + Quantity int64 `json:"variationQuantityPurchased"` +} + +func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) { + body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": 1, "status": 1, "relationFlag": 1, "orderBy": "createTime", "order": "desc"} + raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil) + if e != nil { + return ReturnPage{}, e + } + var p ReturnPage + if e = json.Unmarshal(raw, &p); e != nil { + return p, e + } + return p, nil +} diff --git a/server/app/goauto/yeekeclient/client_test.go b/server/app/goauto/yeekeclient/client_test.go new file mode 100644 index 0000000..15af987 --- /dev/null +++ b/server/app/goauto/yeekeclient/client_test.go @@ -0,0 +1,40 @@ +package yeekeclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCaptchaLoginAndReadOnlyList(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/agent-foreign/sys/randomImage": + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}}) + case "/agent-foreign/sys/login": + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}}) + case "/agent-foreign/packageClaimRec/relation/list": + if r.URL.Query().Get("token") != "opaque" { + t.Errorf("token missing") + } + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"records": []any{}, "total": 0}}) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + c, _ := New(s.URL) + cap, e := c.FetchCaptcha(context.Background()) + if e != nil || string(cap.Image) != "Hi" || cap.CheckKey != "k" { + t.Fatalf("captcha=%+v err=%v", cap, e) + } + if _, e = c.Login(context.Background(), "u", "p", "1234", "k"); e != nil { + t.Fatal(e) + } + if _, e = c.List(context.Background(), 1, 10); e != nil { + t.Fatal(e) + } +} diff --git a/server/app/goauto/yeekeclient/connect.go b/server/app/goauto/yeekeclient/connect.go new file mode 100644 index 0000000..a2f1aae --- /dev/null +++ b/server/app/goauto/yeekeclient/connect.go @@ -0,0 +1,42 @@ +package yeekeclient + +import ( + "context" + "errors" + "time" +) + +type Credentials struct{ Username, Password string } + +// Connect restores and validates a cached session. Only an explicit invalid +// response deletes it; timeouts and 5xx preserve the usable cache. +func Connect(ctx context.Context, store *SessionStore, creds Credentials, baseURL string, ocr OCR, maxLogin int) (*Client, error) { + c, e := New(baseURL) + if e != nil { + return nil, e + } + s, e := store.Load(ctx, creds.Username, time.Now().UTC()) + if e == nil { + if e = c.ImportCookiesJSON(s.CookiesJSON); e == nil { + c.SetToken(s.Token) + if e = c.CheckSession(ctx); e == nil { + return c, nil + } else if errors.Is(e, ErrSessionInvalid) { + _ = store.Delete(ctx, creds.Username) + } else { + return nil, e + } + } + } else if !errors.Is(e, ErrNoSession) { + return nil, e + } + r, e := c.LoginWithOCR(ctx, ocr, creds.Username, creds.Password, maxLogin) + if e != nil { + return nil, e + } + cookies, _ := c.ExportCookiesJSON() + if e = store.Save(ctx, Session{Username: creds.Username, Token: r.Token, UserID: r.UserID, CookiesJSON: cookies, ExpiresAt: r.ExpiresAt}); e != nil { + return nil, e + } + return c, nil +} diff --git a/server/app/goauto/yeekeclient/session.go b/server/app/goauto/yeekeclient/session.go new file mode 100644 index 0000000..8bc525b --- /dev/null +++ b/server/app/goauto/yeekeclient/session.go @@ -0,0 +1,37 @@ +package yeekeclient + +import ( + "context" + "errors" + "go-admin/app/goauto/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "strings" + "time" +) + +type SessionStore struct{ db *gorm.DB } + +func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} } +func (s *SessionStore) Save(ctx context.Context, x Session) error { + if strings.TrimSpace(x.Username) == "" || x.Token == "" || x.ExpiresAt.IsZero() { + return errors.New("invalid yeeke session") + } + return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "username"}}, DoUpdates: clause.AssignmentColumns([]string{"token", "cookies_json", "user_id", "expires_at", "updated_at"})}).Create(&models.YeekeSession{Username: strings.TrimSpace(x.Username), Token: x.Token, CookiesJSON: x.CookiesJSON, UserID: x.UserID, ExpiresAt: x.ExpiresAt.UTC()}).Error +} +func (s *SessionStore) Load(ctx context.Context, user string, now time.Time) (Session, error) { + var r models.YeekeSession + if e := s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).First(&r).Error; e != nil { + if errors.Is(e, gorm.ErrRecordNotFound) { + return Session{}, ErrNoSession + } + return Session{}, e + } + if !now.UTC().Before(r.ExpiresAt) { + return Session{}, ErrNoSession + } + return Session{Username: r.Username, Token: r.Token, CookiesJSON: r.CookiesJSON, UserID: r.UserID, ExpiresAt: r.ExpiresAt}, nil +} +func (s *SessionStore) Delete(ctx context.Context, user string) error { + return s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).Delete(&models.YeekeSession{}).Error +} diff --git a/server/cmd/migrate/migration/version-local/1789800400000_yeeke_return_sync.go b/server/cmd/migrate/migration/version-local/1789800400000_yeeke_return_sync.go new file mode 100644 index 0000000..1b5d788 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800400000_yeeke_return_sync.go @@ -0,0 +1,22 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeReturnSync) +} +func migrateYeekeReturnSync(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}