feat(yeeke): add read-only return sync #336
Co-Authored-By: Codex GPT-6-astra <noreply@openai.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
co-authored by
Codex GPT-6-astra
parent
66301e89ce
commit
cf70021ab9
@@ -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{},
|
||||
|
||||
@@ -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" }
|
||||
@@ -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 != "<nil>" && 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user