Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0b86092a6 | ||
|
|
5faf870d0f | ||
|
|
6773c88a64 | ||
|
|
248eae01ee | ||
|
|
ac5d8336ec | ||
|
|
8ba3470728 | ||
|
|
b1629b900a | ||
|
|
c33e83823a | ||
|
|
faac8bb155 | ||
|
|
e845651933 | ||
|
|
3bc4784402 | ||
|
|
ab292d6a17 | ||
|
|
02bddbf304 | ||
|
|
3e82ad6570 | ||
|
|
b015719947 |
@@ -21,6 +21,7 @@ import (
|
||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
goautoyeeke "go-admin/app/goauto/yeeke"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -69,4 +70,5 @@ func InitRouter() {
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||
goautoyeeke.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleYeekeReturns = "yeeke_returns"
|
||||
ModuleYeekeSyncRuns = "yeeke_sync_runs"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -67,6 +69,8 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
ModulePDDProducts,
|
||||
ModuleCollectionTasks,
|
||||
ModulePurchaseTasks,
|
||||
ModuleYeekeReturns,
|
||||
ModuleYeekeSyncRuns,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,6 +105,8 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
{Key: ModuleYeekeReturns, Title: "yeeke 退货包裹", Path: "/yeeke-returns", RouteName: "GoAutoYeekeReturns", Component: "/goauto/yeeke-returns/index", Icon: "goods", Sort: 63, PurchaserDefault: true},
|
||||
{Key: ModuleYeekeSyncRuns, Title: "yeeke 同步记录", Path: "/yeeke-sync-runs", RouteName: "GoAutoYeekeSyncRuns", Component: "/goauto/yeeke-sync-runs/index", Icon: "time", Sort: 64, PurchaserDefault: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -161,6 +167,12 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModulePurchaseTasks
|
||||
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
|
||||
return ModuleAIMatching
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync-runs"):
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync"):
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||
return ModuleYeekeReturns
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
if len(modules) != 15 {
|
||||
t.Fatalf("got %d modules, want 15", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -31,6 +31,30 @@ func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYeekeSyncRunsModuleIsRegisteredAndPurchaserVisible(t *testing.T) {
|
||||
module, ok := ModuleByRouteName("GoAutoYeekeSyncRuns")
|
||||
if !ok {
|
||||
t.Fatal("yeeke sync-runs module not found")
|
||||
}
|
||||
if module.Key != ModuleYeekeSyncRuns {
|
||||
t.Fatalf("unexpected key: %s", module.Key)
|
||||
}
|
||||
if module.Path != "/yeeke-sync-runs" || module.Component != "/goauto/yeeke-sync-runs/index" {
|
||||
t.Fatalf("unexpected route wiring: %#v", module)
|
||||
}
|
||||
if !module.PurchaserDefault {
|
||||
t.Fatal("yeeke sync-runs module must be purchaser-visible per #337")
|
||||
}
|
||||
if len(module.APIs) == 0 {
|
||||
t.Fatal("yeeke sync-runs module must own its APIs")
|
||||
}
|
||||
for _, permission := range module.APIs {
|
||||
if permission.Path == "/api/admin/v1/yeeke-returns" && permission.Method == "GET" {
|
||||
t.Fatal("the return-package list API belongs to yeeke_returns, not yeeke_sync_runs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
|
||||
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
|
||||
if !ok {
|
||||
@@ -56,8 +80,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 64 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..64", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +95,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
|
||||
@@ -124,6 +124,14 @@ var AdminAPIs = []APIPermission{
|
||||
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
|
||||
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
|
||||
|
||||
{"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true},
|
||||
{"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true},
|
||||
{"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true},
|
||||
{"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true},
|
||||
{"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true},
|
||||
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||
|
||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 13 {
|
||||
if len(s.Modules) != 15 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -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,87 @@
|
||||
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:''"`
|
||||
// StatusUnrecognized is set when ClaimStatus is not one of the values the
|
||||
// sync code currently understands. It is never bucketed into a known
|
||||
// status silently (#336): the raw value is still kept in ClaimStatus, and
|
||||
// this flag lets an operator find and review these rows.
|
||||
StatusUnrecognized bool `gorm:"not null;default:false;index"`
|
||||
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,209 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler exposes the yeeke read-only admin surface: manual sync trigger and
|
||||
// sync run history/summary. It never returns credentials, tokens, captcha
|
||||
// text or a full raw yeeke response — only the counters already stored on
|
||||
// models.YeekeSyncRun (#336 requirement #7).
|
||||
type Handler struct {
|
||||
// DB lets tests inject a database directly; production requests resolve
|
||||
// it from the gin context via pkg.GetOrm, same as sybimport.Handler.
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
||||
db := h.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return nil, false
|
||||
}
|
||||
return db, true
|
||||
}
|
||||
|
||||
// SyncRunDTO is the read-only shape returned to admin/purchaser. It embeds
|
||||
// only the summary fields already computed by the sync run itself; it never
|
||||
// carries yeeke_session (token/cookies) or a raw page response.
|
||||
type SyncRunDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Trigger string `json:"trigger"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
ReadCount int `json:"readCount"`
|
||||
CreatedCount int `json:"createdCount"`
|
||||
UpdatedCount int `json:"updatedCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt *string `json:"finishedAt"`
|
||||
LastSuccessAt *string `json:"lastSuccessAt"`
|
||||
}
|
||||
|
||||
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
|
||||
dto := SyncRunDTO{
|
||||
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
|
||||
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
|
||||
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage,
|
||||
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if r.FinishedAt != nil {
|
||||
s := r.FinishedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
dto.FinishedAt = &s
|
||||
}
|
||||
if r.LastSuccessAt != nil {
|
||||
s := r.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
dto.LastSuccessAt = &s
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// ListSyncRuns returns the most recent sync runs, newest first, optionally
|
||||
// filtered by trigger, status and started-at range (#337 backward-compatible
|
||||
// extension: all filters are optional query params, existing callers with no
|
||||
// filters keep the #336 behavior). Visible to admin and purchaser alike
|
||||
// (#336 requirement #7); it is mounted without Casbin role gating, mirroring
|
||||
// sybimport's /sync-runs.
|
||||
func (h Handler) ListSyncRuns(c *gin.Context) {
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
query := db.Model(&models.YeekeSyncRun{})
|
||||
if v := strings.TrimSpace(c.Query("trigger")); v != "" {
|
||||
query = query.Where("trigger = ?", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("status")); v != "" {
|
||||
query = query.Where("status = ?", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("startedFrom")); v != "" {
|
||||
t, perr := parseFilterTime(v)
|
||||
if perr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedFrom 格式无效"})
|
||||
return
|
||||
}
|
||||
query = query.Where("started_at >= ?", t)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("startedTo")); v != "" {
|
||||
t, perr := parseFilterTime(v)
|
||||
if perr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedTo 格式无效"})
|
||||
return
|
||||
}
|
||||
query = query.Where("started_at <= ?", t)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
var rows []models.YeekeSyncRun
|
||||
if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
items := make([]SyncRunDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, toDTO(row))
|
||||
}
|
||||
var last models.YeekeSyncRun
|
||||
lastSuccessAt := ""
|
||||
if err := db.Where("status = ?", "succeeded").Order("id desc").First(&last).Error; err == nil && last.LastSuccessAt != nil {
|
||||
lastSuccessAt = last.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||
"items": items, "total": total, "page": page, "pageSize": pageSize,
|
||||
"lastSuccessAt": lastSuccessAt,
|
||||
}})
|
||||
}
|
||||
|
||||
// SyncRunDetail returns one run's summary.
|
||||
func (h Handler) SyncRunDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("runId"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "runId 无效"})
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var row models.YeekeSyncRun
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "同步记录不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": toDTO(row)}})
|
||||
}
|
||||
|
||||
// TriggerSync starts a manual sync run. Only admin and purchaser may call it,
|
||||
// same as SYB's manual Import (sybimport.Handler.Import) — this mirrors that
|
||||
// role check exactly.
|
||||
func (h Handler) TriggerSync(c *gin.Context) {
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role != "admin" && role != "purchaser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := StartSync(c.Request.Context(), db, nil, "manual", false)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAlreadyRunning) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "ALREADY_RUNNING", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// `[必须]` err here is only ever a config/connect-stage message built in
|
||||
// start.go/yeekeclient — never a raw yeeke response, never a token or
|
||||
// credential. api.GetRequestLogger keeps the same text out of the HTTP
|
||||
// body while still recording it server-side for operators.
|
||||
api.GetRequestLogger(c).Errorf("yeeke manual sync failed to start: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"code": "SYNC_START_FAILED", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "skipped": result.Skipped}})
|
||||
}
|
||||
|
||||
// parseFilterTime accepts either a full RFC3339-ish "2006-01-02T15:04:05Z" or
|
||||
// a plain "2006-01-02" date, matching the query-string shapes used by both
|
||||
// the return-package and sync-run filters.
|
||||
func parseFilterTime(v string) (time.Time, error) {
|
||||
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Parse("2006-01-02", v)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReturnSyncInvokeTarget is the go-admin job invoke_target key for the
|
||||
// scheduled yeeke return sync (#336). The job row itself is seeded disabled
|
||||
// (Status: 2) by migrations/version-local; an admin turns it on explicitly.
|
||||
const ReturnSyncInvokeTarget = "GoAutoYeekeReturnSync"
|
||||
|
||||
// ReturnSyncJob is registered in go-admin's ExecJob map (app/jobs/examples.go).
|
||||
// ExecWithDB is the production path; Exec exists only to satisfy the legacy
|
||||
// jobs.JobExec interface and fails closed if an older caller forgets to
|
||||
// provide the current database.
|
||||
type ReturnSyncJob struct{}
|
||||
|
||||
func (ReturnSyncJob) Exec(_ interface{}) error {
|
||||
return errors.New("yeeke 定时同步缺少数据库连接")
|
||||
}
|
||||
|
||||
// ExecWithDB starts a sync sharing the same StartSync entry point, and hence
|
||||
// the same syncGate/active_slot lease, as the manual admin trigger — a
|
||||
// scheduled tick that lands while a manual run (or a previous tick) is still
|
||||
// in progress is skipped rather than queued or run concurrently.
|
||||
func (ReturnSyncJob) ExecWithDB(db *gorm.DB, _ interface{}) error {
|
||||
_, err := StartSync(context.Background(), db, nil, "scheduled", true)
|
||||
if errors.Is(err, ErrAlreadyRunning) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReturnItemDTO is the read-only shape of one item inside a return package.
|
||||
// It never carries anything beyond what #336's sync already stores, and the
|
||||
// image field is always the external yeeke URL (never downloaded/proxied,
|
||||
// per #337 non-goal).
|
||||
type ReturnItemDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
VariationID string `json:"variationId"`
|
||||
ItemName string `json:"itemName"`
|
||||
VariationName string `json:"variationName"`
|
||||
Image string `json:"image"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ReturnPackageDTO is the read-only shape of one return package returned to
|
||||
// admin/purchaser. StatusUnrecognized is surfaced verbatim (#337 #4 / #336):
|
||||
// an unknown ClaimStatus is never bucketed into a known value.
|
||||
type ReturnPackageDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderSN string `json:"orderSn"`
|
||||
TrackingNo string `json:"trackingNo"`
|
||||
ShopID string `json:"shopId"`
|
||||
ShopName string `json:"shopName"`
|
||||
WareCode string `json:"wareCode"`
|
||||
WareHouse string `json:"wareHouse"`
|
||||
WareName string `json:"wareName"`
|
||||
ClaimStatus string `json:"claimStatus"`
|
||||
StatusUnrecognized bool `json:"statusUnrecognized"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
ClaimTime *string `json:"claimTime"`
|
||||
CreateTime *string `json:"createTime"`
|
||||
UpdateTime *string `json:"updateTime"`
|
||||
DestroyDeadLine *string `json:"destroyDeadLine"`
|
||||
LastSyncedAt string `json:"lastSyncedAt"`
|
||||
SyncStatus string `json:"syncStatus"`
|
||||
Items []ReturnItemDTO `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ReturnItemRowDTO is one row of the #337 item-level admin list: one row per
|
||||
// yeeke_return_item, joined with its parent package. A package with no items
|
||||
// still produces exactly one placeholder row (HasItem=false, item fields
|
||||
// empty/zero) so it is never silently dropped from the list.
|
||||
type ReturnItemRowDTO struct {
|
||||
Key string `json:"key"`
|
||||
PackageID uint64 `json:"packageId"`
|
||||
ItemID uint64 `json:"itemId"`
|
||||
OrderSN string `json:"orderSn"`
|
||||
Image string `json:"image"`
|
||||
ItemName string `json:"itemName"`
|
||||
VariationName string `json:"variationName"`
|
||||
ShopeeItemID string `json:"shopeeItemId"`
|
||||
VariationID string `json:"variationId"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ShopName string `json:"shopName"`
|
||||
WareHouse string `json:"wareHouse"`
|
||||
WareCode string `json:"wareCode"`
|
||||
ClaimStatus string `json:"claimStatus"`
|
||||
StatusUnrecognized bool `json:"statusUnrecognized"`
|
||||
ClaimTime *string `json:"claimTime"`
|
||||
DestroyDeadLine *string `json:"destroyDeadLine"`
|
||||
LastSyncedAt string `json:"lastSyncedAt"`
|
||||
HasItem bool `json:"hasItem"`
|
||||
}
|
||||
|
||||
func formatTimePtr(t *time.Time) *string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
s := t.UTC().Format("2006-01-02T15:04:05Z")
|
||||
return &s
|
||||
}
|
||||
|
||||
func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO {
|
||||
return ReturnPackageDTO{
|
||||
ID: p.ID, OrderSN: p.OrderSN, TrackingNo: p.TrackingNo, ShopID: p.ShopID, ShopName: p.ShopName,
|
||||
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName, ClaimStatus: p.ClaimStatus,
|
||||
StatusUnrecognized: p.StatusUnrecognized, ItemCount: itemCount,
|
||||
ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime),
|
||||
UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine),
|
||||
LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO {
|
||||
return ReturnItemDTO{
|
||||
ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName,
|
||||
VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity,
|
||||
}
|
||||
}
|
||||
|
||||
// itemRow mirrors one row of the LEFT JOIN query in ListReturnItemRows: every
|
||||
// package column plus every (nullable) item column.
|
||||
type itemRow struct {
|
||||
PackageID uint64
|
||||
OrderSN string
|
||||
ShopName string
|
||||
WareHouse string
|
||||
WareCode string
|
||||
ClaimStatus string
|
||||
StatusUnrecognized bool
|
||||
ClaimTime *time.Time
|
||||
DestroyDeadLine *time.Time
|
||||
PackageLastSynced time.Time
|
||||
|
||||
ItemID *uint64
|
||||
ItemExternalItem *string
|
||||
ItemVariationID *string
|
||||
ItemName *string
|
||||
ItemVariationName *string
|
||||
ItemImage *string
|
||||
ItemQuantity *int64
|
||||
ItemLastSynced *time.Time
|
||||
}
|
||||
|
||||
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
|
||||
dto := ReturnItemRowDTO{
|
||||
PackageID: r.PackageID, OrderSN: r.OrderSN, ShopName: r.ShopName,
|
||||
WareHouse: r.WareHouse, WareCode: r.WareCode, ClaimStatus: r.ClaimStatus,
|
||||
StatusUnrecognized: r.StatusUnrecognized,
|
||||
ClaimTime: formatTimePtr(r.ClaimTime),
|
||||
DestroyDeadLine: formatTimePtr(r.DestroyDeadLine),
|
||||
LastSyncedAt: r.PackageLastSynced.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if r.ItemID == nil || *r.ItemID == 0 {
|
||||
dto.Key = "p" + strconv.FormatUint(r.PackageID, 10)
|
||||
dto.HasItem = false
|
||||
return dto
|
||||
}
|
||||
dto.Key = strconv.FormatUint(*r.ItemID, 10)
|
||||
dto.ItemID = *r.ItemID
|
||||
dto.HasItem = true
|
||||
if r.ItemExternalItem != nil {
|
||||
dto.ShopeeItemID = *r.ItemExternalItem
|
||||
}
|
||||
if r.ItemVariationID != nil {
|
||||
dto.VariationID = *r.ItemVariationID
|
||||
}
|
||||
if r.ItemName != nil {
|
||||
dto.ItemName = *r.ItemName
|
||||
}
|
||||
if r.ItemVariationName != nil {
|
||||
dto.VariationName = *r.ItemVariationName
|
||||
}
|
||||
if r.ItemImage != nil {
|
||||
dto.Image = *r.ItemImage
|
||||
}
|
||||
if r.ItemQuantity != nil {
|
||||
dto.Quantity = *r.ItemQuantity
|
||||
}
|
||||
if r.ItemLastSynced != nil {
|
||||
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// ListReturnPackages returns a paginated, filtered list of return packages.
|
||||
// Filters: order sn, tracking no, shop (id or name, partial match), itemId /
|
||||
// variationId (matched against yeeke_return_item, requires a join), claim
|
||||
// status (including the literal "unknown" pseudo-value for
|
||||
// StatusUnrecognized rows), and createTime range. Visible to admin and
|
||||
// purchaser alike, same gating as ListSyncRuns.
|
||||
func (h Handler) ListReturnPackages(c *gin.Context) {
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
base := db.Table("yeeke_return_package p").
|
||||
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id")
|
||||
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
|
||||
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("trackingNo")); v != "" {
|
||||
base = base.Where("p.tracking_no LIKE ?", "%"+v+"%")
|
||||
}
|
||||
// #337 #1: 店铺 filter switched from free-text partial match to an exact
|
||||
// match against p.shop_name, matching the dropdown surfaced by /shops.
|
||||
if v := strings.TrimSpace(c.Query("shop")); v != "" {
|
||||
base = base.Where("p.shop_name = ?", v)
|
||||
}
|
||||
claimStatus := strings.TrimSpace(c.Query("claimStatus"))
|
||||
switch claimStatus {
|
||||
case "":
|
||||
// no filter
|
||||
case "unknown":
|
||||
base = base.Where("p.status_unrecognized = ?", true)
|
||||
default:
|
||||
base = base.Where("p.claim_status = ? AND p.status_unrecognized = ?", claimStatus, false)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("createTimeFrom")); v != "" {
|
||||
t, perr := parseFilterTime(v)
|
||||
if perr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeFrom 格式无效"})
|
||||
return
|
||||
}
|
||||
base = base.Where("p.create_time >= ?", t)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("createTimeTo")); v != "" {
|
||||
t, perr := parseFilterTime(v)
|
||||
if perr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeTo 格式无效"})
|
||||
return
|
||||
}
|
||||
base = base.Where("p.create_time <= ?", t)
|
||||
}
|
||||
// itemId/variationId kept for backward compatibility (#337): now filter
|
||||
// the item-level join directly instead of a package sub-select.
|
||||
if v := strings.TrimSpace(c.Query("itemId")); v != "" {
|
||||
base = base.Where("i.item_id = ?", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
|
||||
base = base.Where("i.variation_id = ?", v)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []itemRow
|
||||
q := base.Session(&gorm.Session{}).Select(
|
||||
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
|
||||
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
|
||||
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
|
||||
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced",
|
||||
).Order("p.create_time desc, i.id asc").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize)
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]ReturnItemRowDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, toItemRowDTO(r))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||
"items": items, "total": total, "page": page, "pageSize": pageSize,
|
||||
}})
|
||||
}
|
||||
|
||||
// ListReturnShops returns the distinct, non-empty shop names known from
|
||||
// yeeke_return_package, sorted ascending, for the #337 shop-name dropdown
|
||||
// filter. It must be registered before the /:packageId wildcard route so it
|
||||
// is never swallowed by it.
|
||||
func (h Handler) ListReturnShops(c *gin.Context) {
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var shops []string
|
||||
if err := db.Model(&models.YeekeReturnPackage{}).
|
||||
Where("shop_name <> ''").
|
||||
Distinct("shop_name").
|
||||
Order("shop_name asc").
|
||||
Pluck("shop_name", &shops).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
sort.Strings(shops)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"shops": shops}})
|
||||
}
|
||||
|
||||
// ReturnItemDetail returns one item, its parent package, and the sibling
|
||||
// items of the same package (excluding itself), for the #337 "退货商品详情"
|
||||
// drawer. It must be registered before the /:packageId wildcard route so
|
||||
// /yeeke-returns/items/:itemId is never swallowed by it.
|
||||
func (h Handler) ReturnItemDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("itemId"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "itemId 无效"})
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item models.YeekeReturnItem
|
||||
if err := db.First(&item, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货商品不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if err := db.First(&pkg, item.PackageID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "所属退货包裹不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
var siblingRows []models.YeekeReturnItem
|
||||
if err := db.Where("package_id = ? AND id <> ?", item.PackageID, item.ID).Order("id asc").Find(&siblingRows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
siblings := make([]ReturnItemDTO, 0, len(siblingRows))
|
||||
for _, s := range siblingRows {
|
||||
siblings = append(siblings, toItemDTO(s))
|
||||
}
|
||||
pkgDTO := toPackageDTO(pkg, len(siblingRows)+1)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
||||
"item": toItemDTO(item), "package": pkgDTO, "siblings": siblings,
|
||||
}})
|
||||
}
|
||||
|
||||
// ReturnPackageDetail returns one package with its items.
|
||||
func (h Handler) ReturnPackageDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("packageId"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "packageId 无效"})
|
||||
return
|
||||
}
|
||||
db, ok := h.db(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var row models.YeekeReturnPackage
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货包裹不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
var itemRows []models.YeekeReturnItem
|
||||
if err := db.Where("package_id = ?", row.ID).Order("id asc").Find(&itemRows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
dto := toPackageDTO(row, len(itemRows))
|
||||
dto.Items = make([]ReturnItemDTO, 0, len(itemRows))
|
||||
for _, i := range itemRows {
|
||||
dto.Items = append(dto.Items, toItemDTO(i))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": dto}})
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := testDB(t)
|
||||
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
older := now.Add(-48 * time.Hour)
|
||||
if err := db.Create(&models.YeekeReturnPackage{
|
||||
ExternalID: "p1", OrderSN: "ORDER-1", TrackingNo: "TRACK-1", ShopID: "s1", ShopName: "店铺一",
|
||||
ClaimStatus: "claimed", StatusUnrecognized: false, CreateTime: &now, LastSyncedAt: now, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.YeekeReturnPackage{
|
||||
ExternalID: "p2", OrderSN: "ORDER-2", TrackingNo: "TRACK-2", ShopID: "s2", ShopName: "店铺二",
|
||||
ClaimStatus: "weird_status", StatusUnrecognized: true, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// p3 has no items at all: must still produce exactly one placeholder row.
|
||||
if err := db.Create(&models.YeekeReturnPackage{
|
||||
ExternalID: "p3", OrderSN: "ORDER-3", TrackingNo: "TRACK-3", ShopID: "s3", ShopName: "店铺三",
|
||||
ClaimStatus: "pending", StatusUnrecognized: false, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pkg1, pkg2 models.YeekeReturnPackage
|
||||
db.Where("external_id = ?", "p1").First(&pkg1)
|
||||
db.Where("external_id = ?", "p2").First(&pkg2)
|
||||
|
||||
if err := db.Create(&models.YeekeReturnItem{
|
||||
PackageID: pkg1.ID, ExternalKey: "k1", ItemID: "item1", VariationID: "v1", ItemName: "商品1", VariationName: "红色",
|
||||
Image: "https://cdn.example.com/a.jpg", Quantity: 2, LastSyncedAt: now, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.YeekeReturnItem{
|
||||
PackageID: pkg1.ID, ExternalKey: "k2", ItemID: "item1", VariationID: "v2", ItemName: "商品1", VariationName: "蓝色",
|
||||
Image: "https://cdn.example.com/b.jpg", Quantity: 1, LastSyncedAt: now, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.YeekeReturnItem{
|
||||
PackageID: pkg2.ID, ExternalKey: "k3", ItemID: "item2", VariationID: "v3", ItemName: "商品2", VariationName: "默认",
|
||||
Image: "https://cdn.example.com/c.jpg", Quantity: 3, LastSyncedAt: older, SyncStatus: "ok",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.YeekeSyncRun{
|
||||
Status: "succeeded", Trigger: "manual", StartedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.YeekeSyncRun{
|
||||
Status: "failed", Trigger: "scheduled", StartedAt: older,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := Handler{DB: db}
|
||||
engine := gin.New()
|
||||
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
|
||||
engine.GET("/api/admin/v1/yeeke-returns/shops", handler.ListReturnShops)
|
||||
engine.GET("/api/admin/v1/yeeke-returns/items/:itemId", handler.ReturnItemDetail)
|
||||
engine.GET("/api/admin/v1/yeeke-returns/:packageId", handler.ReturnPackageDetail)
|
||||
engine.GET("/api/admin/v1/yeeke-returns/sync-runs", handler.ListSyncRuns)
|
||||
|
||||
do := func(req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
return engine, do
|
||||
}
|
||||
|
||||
type itemRowListResp struct {
|
||||
Data struct {
|
||||
Items []ReturnItemRowDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func TestListReturnItemsFiltersByOrderSn(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 2 || len(resp.Data.Items) != 2 {
|
||||
t.Fatalf("expected 2 item rows for a 2-item package, got: %+v", resp.Data)
|
||||
}
|
||||
for _, row := range resp.Data.Items {
|
||||
if row.OrderSN != "ORDER-1" || !row.HasItem {
|
||||
t.Fatalf("unexpected row: %+v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsPlaceholderRowForItemlessPackage(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-3", nil))
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 {
|
||||
t.Fatalf("expected exactly one placeholder row, got: %+v", resp.Data)
|
||||
}
|
||||
row := resp.Data.Items[0]
|
||||
if row.HasItem {
|
||||
t.Fatalf("expected hasItem=false for item-less package, got: %+v", row)
|
||||
}
|
||||
if row.ItemID != 0 || row.ItemName != "" {
|
||||
t.Fatalf("expected empty/zero item fields on placeholder row, got: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsPaginationAndCountOverItemRows(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
// total rows across all packages: 2 (p1) + 1 (p2) + 1 placeholder (p3) = 4
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?pageSize=2&page=1", nil))
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 4 {
|
||||
t.Fatalf("expected total=4 item-level rows, got %d", resp.Data.Total)
|
||||
}
|
||||
if len(resp.Data.Items) != 2 {
|
||||
t.Fatalf("expected page size 2 applied to item-level rows, got %d", len(resp.Data.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsShopFilterIsExactMatch(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97", nil)) // partial "店" must not match
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 0 {
|
||||
t.Fatalf("expected no matches for a partial shop name (exact match required), got %d", resp.Data.Total)
|
||||
}
|
||||
|
||||
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97%E9%93%BA%E4%B8%80", nil)) // "店铺一"
|
||||
var resp2 itemRowListResp
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp2.Data.Total != 2 {
|
||||
t.Fatalf("expected exact shop match to find package p1's 2 item rows, got %d", resp2.Data.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsUnknownClaimStatusFilter(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?claimStatus=unknown", nil))
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-2" {
|
||||
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||
}
|
||||
if !resp.Data.Items[0].StatusUnrecognized {
|
||||
t.Fatal("expected StatusUnrecognized=true, unknown status must be shown verbatim, not bucketed")
|
||||
}
|
||||
if resp.Data.Items[0].ClaimStatus != "weird_status" {
|
||||
t.Fatalf("unknown claim status must be shown verbatim, got %q", resp.Data.Items[0].ClaimStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsFiltersByItemAndVariationBackwardCompat(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?itemId=item1&variationId=v2", nil))
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" || resp.Data.Items[0].VariationID != "v2" {
|
||||
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnItemsFiltersByCreateTimeRange(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?createTimeFrom=2026-09-19&createTimeTo=2026-09-21", nil))
|
||||
var resp itemRowListResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 2 || resp.Data.Items[0].OrderSN != "ORDER-1" {
|
||||
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnShopsReturnsDistinctSortedNames(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Shops []string `json:"shops"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"店铺一", "店铺三", "店铺二"}
|
||||
if len(resp.Data.Shops) != len(want) {
|
||||
t.Fatalf("expected %d distinct shops, got %+v", len(want), resp.Data.Shops)
|
||||
}
|
||||
for i := range want {
|
||||
if resp.Data.Shops[i] != want[i] {
|
||||
t.Fatalf("expected sorted shops %+v, got %+v", want, resp.Data.Shops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnItemDetailReturnsSiblingsExcludingSelf(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||
var listResp itemRowListResp
|
||||
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listResp.Data.Items) != 2 {
|
||||
t.Fatalf("expected 2 item rows to pick from, got %+v", listResp.Data.Items)
|
||||
}
|
||||
target := listResp.Data.Items[0]
|
||||
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(target.ItemID), nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Item ReturnItemDTO `json:"item"`
|
||||
Package ReturnPackageDTO `json:"package"`
|
||||
Siblings []ReturnItemDTO `json:"siblings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Item.ID != target.ItemID {
|
||||
t.Fatalf("expected item id %d, got %d", target.ItemID, resp.Data.Item.ID)
|
||||
}
|
||||
if resp.Data.Package.OrderSN != "ORDER-1" {
|
||||
t.Fatalf("expected parent package ORDER-1, got %+v", resp.Data.Package)
|
||||
}
|
||||
if len(resp.Data.Siblings) != 1 {
|
||||
t.Fatalf("expected exactly 1 sibling (the other item in the same package), got %+v", resp.Data.Siblings)
|
||||
}
|
||||
if resp.Data.Siblings[0].ID == resp.Data.Item.ID {
|
||||
t.Fatal("siblings must exclude the current item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnItemDetailNotFound(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/999999", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestYeekeReturnsRoutesDoNotCollide(t *testing.T) {
|
||||
// Routing test proving /yeeke-returns/shops and /yeeke-returns/items/:id
|
||||
// are not swallowed by the /yeeke-returns/:packageId wildcard route.
|
||||
_, do := setupListTestDB(t)
|
||||
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/shops should hit ListReturnShops, got status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var shopsResp struct {
|
||||
Data struct {
|
||||
Shops []string `json:"shops"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &shopsResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shopsResp.Data.Shops) == 0 {
|
||||
t.Fatal("/shops must hit ListReturnShops, not ReturnPackageDetail (which would 400/404 on a non-numeric id)")
|
||||
}
|
||||
|
||||
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||
var listResp itemRowListResp
|
||||
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
itemID := listResp.Data.Items[0].ItemID
|
||||
|
||||
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(itemID), nil))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("/items/:itemId should hit ReturnItemDetail, got status=%d body=%s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var itemResp struct {
|
||||
Data struct {
|
||||
Item ReturnItemDTO `json:"item"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &itemResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if itemResp.Data.Item.ID != itemID {
|
||||
t.Fatalf("/items/:itemId must hit ReturnItemDetail, got item=%+v", itemResp.Data.Item)
|
||||
}
|
||||
|
||||
// The plain numeric package-id route must still work unchanged.
|
||||
w3 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
|
||||
if w3.Code != http.StatusNotFound {
|
||||
t.Fatalf("/:packageId route must still work for a numeric id, got status=%d", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnPackageDetailIncludesSeparateItemRows(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
|
||||
var listResp itemRowListResp
|
||||
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := listResp.Data.Items[0].PackageID
|
||||
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/"+itoa(id), nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Item ReturnPackageDTO `json:"item"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Data.Item.Items) != 2 {
|
||||
t.Fatalf("expected 2 separate item rows for multiple variations, got %d", len(resp.Data.Item.Items))
|
||||
}
|
||||
variations := map[string]bool{}
|
||||
for _, item := range resp.Data.Item.Items {
|
||||
variations[item.VariationID] = true
|
||||
}
|
||||
if !variations["v1"] || !variations["v2"] {
|
||||
t.Fatalf("expected both variations present as separate rows: %+v", resp.Data.Item.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnPackageDetailNotFound(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSyncRunsFiltersByTriggerStatusAndStartedRange(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?trigger=manual&status=succeeded", nil))
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Items []SyncRunDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 1 || resp.Data.Items[0].Trigger != "manual" || resp.Data.Items[0].Status != "succeeded" {
|
||||
t.Fatalf("unexpected result: %+v", resp.Data)
|
||||
}
|
||||
|
||||
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?startedFrom=2026-09-19&startedTo=2026-09-21", nil))
|
||||
var resp2 struct {
|
||||
Data struct {
|
||||
Items []SyncRunDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp2.Data.Total != 1 || resp2.Data.Items[0].Trigger != "manual" {
|
||||
t.Fatalf("unexpected started-range result: %+v", resp2.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSyncRunsWithNoFiltersStaysBackwardCompatible(t *testing.T) {
|
||||
_, do := setupListTestDB(t)
|
||||
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs", nil))
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Items []SyncRunDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Data.Total != 2 {
|
||||
t.Fatalf("expected both runs with no filters, got total=%d", resp.Data.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v uint64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for v > 0 {
|
||||
digits = append([]byte{byte('0' + v%10)}, digits...)
|
||||
v /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// InitRouter mounts the yeeke read-only admin surface (#336): manual sync
|
||||
// trigger plus sync run history/summary. Every route here only reads from or
|
||||
// writes to GoAuto's own database and, for the trigger, starts a read-only
|
||||
// yeeke sync — no yeeke write endpoint is ever called.
|
||||
//
|
||||
// `[必须]` These routes are authenticated but intentionally not Casbin-gated
|
||||
// (middleware.AuthCheckRole), same as sybimport's /sync-runs: #336 requires
|
||||
// both admin and purchaser to see the summary, and TriggerSync does its own
|
||||
// admin/purchaser role check inline (mirroring sybimport.Handler.Import).
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
group := engine.Group("/api/admin/v1/yeeke-returns").Use(auth.MiddlewareFunc())
|
||||
group.GET("", handler.ListReturnPackages)
|
||||
// #337: static-prefixed routes must be registered before the
|
||||
// /:packageId wildcard route below so they are never swallowed by it.
|
||||
group.GET("/shops", handler.ListReturnShops)
|
||||
group.GET("/items/:itemId", handler.ReturnItemDetail)
|
||||
group.GET("/:packageId", handler.ReturnPackageDetail)
|
||||
group.GET("/sync-runs", handler.ListSyncRuns)
|
||||
group.GET("/sync-runs/:runId", handler.SyncRunDetail)
|
||||
group.POST("/sync", handler.TriggerSync)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
"go-admin/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// syncTimeout bounds one sync run. It exists so a stalled yeeke response
|
||||
// cannot pin a background goroutine forever.
|
||||
const syncTimeout = 30 * time.Minute
|
||||
|
||||
// syncGate makes sync runs mutually exclusive within this process, same
|
||||
// reasoning as sybimport.importGate: manual trigger and the scheduled job
|
||||
// must never run concurrently (#336 requirement #5). The database-level
|
||||
// unique active_slot column on yeeke_sync_run is the durable backstop (it
|
||||
// also covers the unlikely case of two processes sharing one database); this
|
||||
// in-memory gate exists to fail fast with a clear message before even
|
||||
// touching the network or the OCR service.
|
||||
var syncGate = struct {
|
||||
sync.Mutex
|
||||
running bool
|
||||
}{}
|
||||
|
||||
// ErrAlreadyRunning is returned by StartSync when a sync is already in
|
||||
// progress, whether it was started manually or by the scheduled job.
|
||||
var ErrAlreadyRunning = errors.New("已有 yeeke 退货包裹同步正在执行,请等它结束后再试")
|
||||
|
||||
// OCR is satisfied by sybclient.OcrClient; yeeke reuses the same self-hosted
|
||||
// captcha OCR service already approved for SYB (#336).
|
||||
type OCR = yeekeclient.OCR
|
||||
|
||||
// StartResult reports what StartSync did without exposing any run internals
|
||||
// that should not cross the HTTP boundary.
|
||||
type StartResult struct {
|
||||
RunID uint64
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// StartSync is the single entry point shared by the authenticated admin
|
||||
// handler and the scheduled job, so a scheduled run can never bypass the same
|
||||
// concurrency guard as a manual run.
|
||||
//
|
||||
// `[必须]` This only ever reads from yeeke. Credentials are resolved by the
|
||||
// caller (config.ExtConfig.Yeeke) and never logged here.
|
||||
func StartSync(ctx context.Context, db *gorm.DB, ocr OCR, trigger string, skipIfRunning bool) (StartResult, error) {
|
||||
settings := config.ExtConfig.Yeeke.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
return StartResult{}, fmt.Errorf("yeeke 账号未配置:请设置 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,或在 config.yaml 的 yeeke 段填写 username/password,然后重启服务端")
|
||||
}
|
||||
if ocr == nil {
|
||||
if strings.TrimSpace(settings.OcrURL) == "" {
|
||||
return StartResult{}, fmt.Errorf("yeeke 验证码识别服务未配置:请设置 extend.yeeke.ocrurl")
|
||||
}
|
||||
// Reuse SYB's OcrClient implementation as-is (#336): independent
|
||||
// http.Client, no shared cookie jar, captcha bytes stay in memory. See
|
||||
// sybclient/ocr.go's package comment for why that isolation matters.
|
||||
client, err := sybclient.NewOcrClient(settings.OcrURL, 0)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
ocr = client
|
||||
}
|
||||
|
||||
syncGate.Lock()
|
||||
if syncGate.running {
|
||||
syncGate.Unlock()
|
||||
if skipIfRunning {
|
||||
return StartResult{Skipped: true}, nil
|
||||
}
|
||||
return StartResult{}, ErrAlreadyRunning
|
||||
}
|
||||
syncGate.running = true
|
||||
syncGate.Unlock()
|
||||
release := func() {
|
||||
syncGate.Lock()
|
||||
syncGate.running = false
|
||||
syncGate.Unlock()
|
||||
}
|
||||
|
||||
client, err := yeekeclient.Connect(ctx, yeekeclient.NewSessionStore(db), yeekeclient.Credentials{
|
||||
Username: settings.Username, Password: settings.Password,
|
||||
}, settings.BaseURL, ocr, settings.OcrMaxAttempts)
|
||||
if err != nil {
|
||||
release()
|
||||
return StartResult{}, err
|
||||
}
|
||||
|
||||
svc := NewService(db, client, Config{PageSize: settings.PageSize, MaxPages: settings.MaxPages, Retry: settings.Retry})
|
||||
// SyncAsync acquires the DB lease synchronously (so the caller gets a run
|
||||
// id right away and the active_slot lease is held before this function
|
||||
// returns) then walks pages in the background, bound to its own timeout
|
||||
// independent of the HTTP request context. syncGate is released once that
|
||||
// background walk finishes, not when this function returns.
|
||||
runID, err := svc.SyncAsync(ctx, trigger, release)
|
||||
if err != nil {
|
||||
release()
|
||||
return StartResult{}, err
|
||||
}
|
||||
return StartResult{RunID: runID}, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
// knownClaimStatuses lists the status values the sync code currently
|
||||
// understands. The list surface (POST .../relation/list) is queried with
|
||||
// status=1, so "1" is the only value observed in practice; anything else is
|
||||
// flagged rather than silently accepted or rejected (#336).
|
||||
var knownClaimStatuses = map[string]bool{"1": true}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// itemKey builds the stable per-item identity used to upsert
|
||||
// models.YeekeReturnItem without duplicating rows across syncs. It prefers
|
||||
// the yeeke-issued identifiers (i.ID, then i.ItemID/i.VariationID) because
|
||||
// those stay the same regardless of the order the API returns items in
|
||||
// within a package; the positional index n is only used as a last resort
|
||||
// when none of those identifiers are present, since in that case the index
|
||||
// is the sole thing distinguishing items of the same package (#336).
|
||||
func itemKey(p yeekeclient.ReturnPackage, i yeekeclient.ReturnItem, n int) string {
|
||||
id, itemID, variationID := external(i.ID), external(i.ItemID), external(i.VariationID)
|
||||
if isEmptyExternal(id) && isEmptyExternal(itemID) && isEmptyExternal(variationID) {
|
||||
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID + "/" + strconv.Itoa(n)
|
||||
}
|
||||
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID
|
||||
}
|
||||
|
||||
// isEmptyExternal reports whether external() produced a value that carries
|
||||
// no real identity: either the field was unset (formatted as "<nil>" by
|
||||
// fmt.Sprint on a nil/zero value) or it was an explicit empty string.
|
||||
func isEmptyExternal(v string) bool { return v == "" || v == "<nil>" }
|
||||
|
||||
// takeoverStaleLease reclaims a run whose lease has expired, e.g. because the
|
||||
// process crashed or was restarted mid-sync. It matches the
|
||||
// lease-with-expiry-takeover idiom used by
|
||||
// app/goauto/purchase/order_writeback_worker.go: a single conditional UPDATE
|
||||
// guarded by "status = running AND lease_expires_at <= now" flips the stale
|
||||
// row to a terminal status and frees active_slot in one statement, so it is
|
||||
// atomic without a separate row lock. The stale row is never deleted — it is
|
||||
// left in place with status "failed" and an error_message explaining why, so
|
||||
// history stays auditable. If two callers race this same UPDATE, only the
|
||||
// first to reach the database actually changes any row; the second's WHERE
|
||||
// clause no longer matches (status is no longer "running") and it affects
|
||||
// zero rows, which is a harmless no-op. Whichever caller then wins the
|
||||
// subsequent Create (see acquire) is arbitrated by the ux_yeeke_sync_run_active_slot
|
||||
// unique index, exactly as it already is for two brand-new concurrent runs.
|
||||
func (s *Service) takeoverStaleLease(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
return s.db.WithContext(ctx).Model(&models.YeekeSyncRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?", "running", 1, now).
|
||||
Updates(map[string]any{
|
||||
"status": "failed",
|
||||
"active_slot": nil,
|
||||
"lease_owner": "",
|
||||
"error_message": "lease expired: run interrupted, likely a process restart mid-sync (stale lease takeover)",
|
||||
"lease_expires_at": nil,
|
||||
"finished_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Service) acquire(ctx context.Context, trigger string) (*models.YeekeSyncRun, error) {
|
||||
if e := s.takeoverStaleLease(ctx); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
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()}
|
||||
}
|
||||
|
||||
// Sync acquires the shared lease, runs the page walk synchronously and
|
||||
// returns the final report. Tests use this directly; StartSync (start.go)
|
||||
// uses SyncAsync instead so an HTTP request does not block for the whole
|
||||
// run.
|
||||
func (s *Service) Sync(ctx context.Context, trigger string) (Report, error) {
|
||||
r, e := s.acquire(ctx, trigger)
|
||||
if e != nil {
|
||||
return Report{}, e
|
||||
}
|
||||
return s.run(ctx, r)
|
||||
}
|
||||
|
||||
// SyncAsync acquires the lease synchronously (so the caller gets a run id
|
||||
// immediately, and the unique active_slot lease is held before returning)
|
||||
// and continues the page walk in a background goroutine bound to its own
|
||||
// timeout, independent of the caller's request context.
|
||||
// onDone, when non-nil, runs after the background page walk finishes
|
||||
// (success or failure) — StartSync uses it to release the in-memory
|
||||
// concurrency gate at the right time instead of when this function returns.
|
||||
func (s *Service) SyncAsync(ctx context.Context, trigger string, onDone func()) (uint64, error) {
|
||||
r, e := s.acquire(ctx, trigger)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
go func() {
|
||||
bg, cancel := context.WithTimeout(context.Background(), syncTimeout)
|
||||
defer cancel()
|
||||
_, _ = s.run(bg, r)
|
||||
if onDone != nil {
|
||||
onDone()
|
||||
}
|
||||
}()
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, error) {
|
||||
rep := Report{RunID: r.ID, Status: "failed"}
|
||||
var errMsg string
|
||||
var runErr error
|
||||
defer func() {
|
||||
now := time.Now().UTC()
|
||||
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, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
|
||||
if rep.Status == "succeeded" {
|
||||
updates["last_success_at"] = now
|
||||
}
|
||||
s.db.Model(r).Updates(updates)
|
||||
}()
|
||||
seen := map[string]bool{}
|
||||
var firstWriteErr error
|
||||
for page := 1; page <= s.cfg.MaxPages; page++ {
|
||||
var p yeekeclient.ReturnPage
|
||||
var e error
|
||||
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():
|
||||
runErr = ctx.Err()
|
||||
errMsg = truncateRunError(runErr.Error())
|
||||
return rep, runErr
|
||||
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
// A failed page never overwrites what earlier pages already wrote
|
||||
// (#336): the run simply stops here and everything upserted so far
|
||||
// stays as-is, reported through Read/Created/Updated above.
|
||||
runErr = e
|
||||
errMsg = truncateRunError(e.Error())
|
||||
return rep, runErr
|
||||
}
|
||||
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++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
}
|
||||
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"
|
||||
if rep.Failed > 0 && firstWriteErr != nil {
|
||||
// Surface why records failed instead of a bare counter.
|
||||
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
|
||||
if rep.Read == 0 {
|
||||
rep.Status = "failed"
|
||||
runErr = errors.New(errMsg)
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// truncateRunError keeps error_message inside the column's size limit. It
|
||||
// never includes request bodies or headers, so it cannot leak a captcha,
|
||||
// token or credential: every error path above passes only Go error text from
|
||||
// HTTP status/timeout/JSON-decoding failures.
|
||||
func truncateRunError(value string) string {
|
||||
const limit = 1000
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
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)
|
||||
status := external(p.Status)
|
||||
var isNew bool
|
||||
// One transaction per package: a failing item never leaves a package row
|
||||
// behind without its items. Rows are always inserted fully populated —
|
||||
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
|
||||
// empty placeholder insert would carry, which failed every record.
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row models.YeekeReturnPackage
|
||||
e := tx.Where("external_id = ?", key).First(&row).Error
|
||||
isNew = errors.Is(e, gorm.ErrRecordNotFound)
|
||||
if e != nil && !isNew {
|
||||
return e
|
||||
}
|
||||
fields := models.YeekeReturnPackage{
|
||||
ExternalID: key, OrderSN: p.Ordersn, TrackingNo: p.TrackingNo,
|
||||
ShopID: external(p.ShopID), ShopName: p.ShopName,
|
||||
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName,
|
||||
ClaimStatus: status, StatusUnrecognized: !knownClaimStatuses[status],
|
||||
ClaimTime: stamp(p.ClaimTime), CreateTime: stamp(p.CreateTime),
|
||||
UpdateTime: stamp(p.UpdateTime), DestroyDeadLine: stamp(p.DestroyDeadLine),
|
||||
LastSyncedAt: now, SyncStatus: "ok",
|
||||
}
|
||||
if isNew {
|
||||
row = fields
|
||||
if e = tx.Create(&row).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
} else {
|
||||
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"}
|
||||
if e = tx.Model(&row).Updates(vals).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
for n, i := range p.Items {
|
||||
ik := itemKey(p, i, n)
|
||||
item := models.YeekeReturnItem{
|
||||
PackageID: row.ID, ExternalKey: ik,
|
||||
ItemID: external(i.ItemID), VariationID: external(i.VariationID),
|
||||
ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image,
|
||||
Quantity: int64(i.Quantity), LastSyncedAt: now, SyncStatus: "ok",
|
||||
}
|
||||
var existing models.YeekeReturnItem
|
||||
ie := tx.Where("package_id = ? AND external_key = ?", row.ID, ik).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(ie, gorm.ErrRecordNotFound):
|
||||
if e = tx.Create(&item).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
case ie != nil:
|
||||
return ie
|
||||
default:
|
||||
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok"}
|
||||
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return isNew, !isNew, nil
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type stubOCR struct{ codes []string }
|
||||
|
||||
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
|
||||
if len(s.codes) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
c := s.codes[0]
|
||||
s.codes = s.codes[1:]
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func record(id, itemID, variationID string, status any) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"id": id, "ordersn": "o-" + id, "trackingNo": "t-" + id, "status": status,
|
||||
"items": []map[string]any{{
|
||||
"id": id + "-i1", "itemId": itemID, "variationId": variationID,
|
||||
"itemName": "n", "variationName": "v", "variationQuantityPurchased": 1,
|
||||
}},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func page(records []string, total, pages int) string {
|
||||
return fmt.Sprintf(`{"success":true,"result":{"records":[%s],"total":%d,"pages":%d}}`, strings.Join(records, ","), total, pages)
|
||||
}
|
||||
|
||||
// TestPagingSurvivesTotalChangingMidRun: page 1 reports one total/pages, page
|
||||
// 2 reports a different total/pages (the underlying data changed between the
|
||||
// two requests). The walk must still finish using what each page returned.
|
||||
func TestPagingSurvivesTotalChangingMidRun(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch n {
|
||||
case 1:
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 100, 2))
|
||||
case 2:
|
||||
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 50, 1))
|
||||
default:
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" || rep.Read != 2 {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagingSkipsARepeatedDuplicatePage: the server returns the exact same
|
||||
// page twice in a row (e.g. a retried request landed after all). The second
|
||||
// occurrence must be recognized as a duplicate and stop the walk instead of
|
||||
// looping or double counting.
|
||||
func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var calls int32
|
||||
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if n <= 2 {
|
||||
fmt.Fprint(w, body) // identical page served twice
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, page(nil, 10, 5))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("packages=%d, want 1 (duplicate page must not double-insert)", n)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("calls=%d, want exactly 2 (stop right after recognizing the duplicate)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagingStopsOnEmptyPage confirms an empty page ends the walk cleanly.
|
||||
func TestPagingStopsOnEmptyPage(t *testing.T) {
|
||||
db := testDB(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagingTimeoutFailsRunButKeepsEarlierPages: page 1 succeeds and is
|
||||
// persisted; page 2 always times out. The run must end as "failed" (after
|
||||
// retrying up to cfg.Retry times) but page 1's row must remain intact — a
|
||||
// failed page must never roll back or overwrite valid prior data.
|
||||
func TestPagingTimeoutFailsRunButKeepsEarlierPages(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 5))
|
||||
return
|
||||
}
|
||||
// Simulate a slow/timed-out request: this response is deliberately
|
||||
// slower than the client's own context deadline below, so the client
|
||||
// side must give up on its own rather than waiting for us.
|
||||
time.Sleep(2 * time.Second)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1, Retry: 1})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
rep, err := s.Sync(ctx, "manual")
|
||||
if err == nil {
|
||||
t.Fatal("expected the timed-out page to surface an error")
|
||||
}
|
||||
if rep.Status != "failed" || rep.RunID == 0 {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
var row models.YeekeReturnPackage
|
||||
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
|
||||
t.Fatalf("page 1's row must survive a later page's failure: %v", e)
|
||||
}
|
||||
if row.ClaimStatus != "1" {
|
||||
t.Fatalf("page 1's row must be unmodified: %+v", row)
|
||||
}
|
||||
var run models.YeekeSyncRun
|
||||
if e := db.First(&run, rep.RunID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if run.Status != "failed" || run.ErrorMessage == "" {
|
||||
t.Fatalf("run=%+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncResumeAfterSimulatedRestart: a first run fails partway through
|
||||
// (simulating the process being interrupted after committing page 1). A
|
||||
// second, full run afterwards must succeed and must not duplicate the row
|
||||
// page 1 already wrote — it converges to exactly one package row.
|
||||
func TestSyncResumeAfterSimulatedRestart(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if n == 1 {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
|
||||
return
|
||||
}
|
||||
if n == 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Full second run: both pages succeed this time.
|
||||
if n == 3 {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, page(nil, 10, 2))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
||||
|
||||
if _, err := s.Sync(context.Background(), "manual"); err == nil {
|
||||
t.Fatal("expected the first ('interrupted') run to fail")
|
||||
}
|
||||
rep2, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep2.Status != "succeeded" {
|
||||
t.Fatalf("rep2=%+v", rep2)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("packages=%d, want 1 (resume must not duplicate p1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdempotentStatusUpdateInPlace: syncing the same package twice with a
|
||||
// different status the second time updates the existing row rather than
|
||||
// creating a second one.
|
||||
func TestIdempotentStatusUpdateInPlace(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var status int32 = 1
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", atomic.LoadInt32(&status))}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
atomic.StoreInt32(&status, 9) // an unrecognized status the second time
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("packages=%d, want 1 (status change must update in place)", n)
|
||||
}
|
||||
var row models.YeekeReturnPackage
|
||||
db.Where("external_id = ?", "p1").First(&row)
|
||||
if row.ClaimStatus != "9" || !row.StatusUnrecognized {
|
||||
t.Fatalf("row=%+v, want claim_status=9 flagged unrecognized", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownStatusIsPreservedVerbatimAndFlagged: an unrecognized status value
|
||||
// is kept as-is (never remapped into a known bucket) and the row is flagged.
|
||||
func TestUnknownStatusIsPreservedVerbatimAndFlagged(t *testing.T) {
|
||||
db := testDB(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 7)}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row models.YeekeReturnPackage
|
||||
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if row.ClaimStatus != "7" {
|
||||
t.Fatalf("claim_status=%q, want the raw value 7 preserved verbatim", row.ClaimStatus)
|
||||
}
|
||||
if !row.StatusUnrecognized {
|
||||
t.Fatal("an unknown status must be flagged, not silently accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSlotLeaseRejectsConcurrentRuns exercises the same DB-level
|
||||
// uniqueness the manual trigger and the scheduled job both rely on
|
||||
// (models.YeekeSyncRun.ActiveSlot): two Sync calls racing against the same
|
||||
// database must not both hold the lease at once.
|
||||
func TestActiveSlotLeaseRejectsConcurrentRuns(t *testing.T) {
|
||||
db := testDB(t)
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
|
||||
started := make(chan struct{})
|
||||
var firstErr, secondErr error
|
||||
go func() {
|
||||
close(started)
|
||||
_, firstErr = s.Sync(context.Background(), "manual")
|
||||
}()
|
||||
<-started
|
||||
time.Sleep(50 * time.Millisecond) // let the first Sync acquire its lease row
|
||||
_, secondErr = s.Sync(context.Background(), "scheduled")
|
||||
close(release)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if secondErr == nil {
|
||||
t.Fatal("a second concurrent Sync must be rejected by the active_slot lease")
|
||||
}
|
||||
_ = firstErr
|
||||
}
|
||||
|
||||
// TestStartSyncGateRejectsConcurrentTriggers exercises StartSync itself (the
|
||||
// entry point manual and scheduled triggers actually share): a second call
|
||||
// while one is in flight is rejected, and with skipIfRunning it is reported
|
||||
// as skipped instead of erroring — this is what the scheduled job uses so a
|
||||
// tick landing during a manual run does not surface as a failure.
|
||||
func TestStartSyncGateRejectsConcurrentTriggers(t *testing.T) {
|
||||
db := testDB(t)
|
||||
restore := setTestYeekeConfig(t, "op", "secret-pw")
|
||||
defer restore()
|
||||
|
||||
release := make(chan struct{})
|
||||
var loginCalls, listCalls int32
|
||||
srv := fakeYeekeServer(t, &loginCalls, &listCalls, release)
|
||||
defer srv.Close()
|
||||
restoreURL := setTestYeekeBaseURL(t, srv.URL)
|
||||
defer restoreURL()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var firstErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, firstErr = StartSync(context.Background(), db, &stubOCR{codes: []string{"abcd"}}, "manual", false)
|
||||
}()
|
||||
// Give the first call time to acquire syncGate and the DB lease before
|
||||
// the second one is attempted.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
_, err := StartSync(context.Background(), db, &stubOCR{}, "scheduled", true)
|
||||
if err != nil {
|
||||
t.Fatalf("skipIfRunning=true must not error, got %v", err)
|
||||
}
|
||||
_, err2 := StartSync(context.Background(), db, &stubOCR{}, "manual", false)
|
||||
if err2 == nil || err2 != ErrAlreadyRunning {
|
||||
t.Fatalf("skipIfRunning=false must report ErrAlreadyRunning, got %v", err2)
|
||||
}
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
t.Fatalf("first StartSync should have completed cleanly: %v", firstErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha: whatever StartSync
|
||||
// or the underlying client return as an error, the credential, password and
|
||||
// recognized captcha text must never appear in it, since that text ends up
|
||||
// in server logs and (truncated) in yeeke_sync_run.error_message.
|
||||
func TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha(t *testing.T) {
|
||||
db := testDB(t)
|
||||
const secretPassword = "S3cr3t-Do-Not-Leak"
|
||||
const secretCaptcha = "zZqQ9x"
|
||||
restore := setTestYeekeConfig(t, "leak-user", secretPassword)
|
||||
defer restore()
|
||||
|
||||
srv := 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": false, "code": 1, "message": "验证码错误"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
restoreURL := setTestYeekeBaseURL(t, srv.URL)
|
||||
defer restoreURL()
|
||||
|
||||
_, err := StartSync(context.Background(), db, &stubOCR{codes: []string{secretCaptcha}}, "manual", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected a login failure")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, secretPassword) || strings.Contains(msg, secretCaptcha) || strings.Contains(msg, "leak-user") {
|
||||
t.Fatalf("error message leaked a credential or captcha text: %q", msg)
|
||||
}
|
||||
|
||||
var run models.YeekeSyncRun
|
||||
// StartSync failed before acquiring a run row here (Connect failed first),
|
||||
// so there should be no run row at all to check — that is itself part of
|
||||
// the guarantee: a failed login never gets far enough to write a summary
|
||||
// row that could carry sensitive text.
|
||||
if e := db.Order("id desc").First(&run).Error; e == nil {
|
||||
if strings.Contains(run.ErrorMessage, secretPassword) || strings.Contains(run.ErrorMessage, secretCaptcha) {
|
||||
t.Fatalf("run.ErrorMessage leaked a credential or captcha text: %q", run.ErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeYeekeServer serves a minimal login+list surface. It blocks the *list*
|
||||
// call on release, so a test can hold StartSync's background goroutine open
|
||||
// long enough to exercise the concurrency gate.
|
||||
func fakeYeekeServer(t *testing.T, loginCalls, listCalls *int32, release chan struct{}) *httptest.Server {
|
||||
t.Helper()
|
||||
return 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":
|
||||
atomic.AddInt32(loginCalls, 1)
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok", "userInfo": map[string]any{"id": "u1", "username": "op"}}})
|
||||
case "/agent-foreign/packageClaimRec/relation/list":
|
||||
atomic.AddInt32(listCalls, 1)
|
||||
<-release
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// The next two helpers isolate StartSync's config.ExtConfig.Yeeke dependency
|
||||
// for tests, restoring it afterwards so other tests are unaffected.
|
||||
func setTestYeekeConfig(t *testing.T, username, password string) func() {
|
||||
t.Helper()
|
||||
before := config.ExtConfig.Yeeke
|
||||
config.ExtConfig.Yeeke.Username = username
|
||||
config.ExtConfig.Yeeke.Password = password
|
||||
config.ExtConfig.Yeeke.OcrURL = "http://unused.invalid/ocr"
|
||||
return func() { config.ExtConfig.Yeeke = before }
|
||||
}
|
||||
|
||||
func setTestYeekeBaseURL(t *testing.T, url string) func() {
|
||||
t.Helper()
|
||||
before := config.ExtConfig.Yeeke
|
||||
config.ExtConfig.Yeeke.BaseURL = url
|
||||
return func() { config.ExtConfig.Yeeke = before }
|
||||
}
|
||||
|
||||
// packageWithItems builds a raw list-page record for one package carrying an
|
||||
// arbitrary, caller-ordered set of items, so tests can reorder items between
|
||||
// two syncs of the same package.
|
||||
func packageWithItems(pkgID string, items ...map[string]any) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"id": pkgID, "ordersn": "o-" + pkgID, "trackingNo": "t-" + pkgID, "status": 1,
|
||||
"items": items,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func item(id, itemID, variationID string) map[string]any {
|
||||
return map[string]any{"id": id, "itemId": itemID, "variationId": variationID, "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
|
||||
}
|
||||
|
||||
// itemNoIDs builds an item carrying no yeeke-issued identifiers at all
|
||||
// (id/itemId/variationId all empty), the case itemKey's positional-index
|
||||
// fallback exists for.
|
||||
func itemNoIDs() map[string]any {
|
||||
return map[string]any{"id": "", "itemId": "", "variationId": "", "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
|
||||
}
|
||||
|
||||
// --- Defect 1 (#336): stale lease takeover -------------------------------
|
||||
|
||||
// TestStaleLeaseIsTakenOverOnNextAcquire simulates a crash: a "running" row
|
||||
// is left behind with an active_slot and a lease that has already expired
|
||||
// (as if the process died mid-sync, long before the lease's normal
|
||||
// duration). The very next Sync call — scheduled or manual — must reclaim
|
||||
// the slot rather than being permanently blocked, and the abandoned row must
|
||||
// end up in a clear terminal state (not silently deleted) recording why.
|
||||
func TestStaleLeaseIsTakenOverOnNextAcquire(t *testing.T) {
|
||||
db := testDB(t)
|
||||
slot := uint8(1)
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
stale := models.YeekeSyncRun{
|
||||
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
|
||||
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
|
||||
}
|
||||
if e := db.Create(&stale).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("resume after a stale lease must succeed, got err=%v", err)
|
||||
}
|
||||
if rep.Status != "succeeded" {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
|
||||
var reclaimed models.YeekeSyncRun
|
||||
if e := db.First(&reclaimed, stale.ID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if reclaimed.Status != "failed" {
|
||||
t.Fatalf("stale run status=%q, want a terminal status (not silently left running or deleted)", reclaimed.Status)
|
||||
}
|
||||
if reclaimed.ErrorMessage == "" {
|
||||
t.Fatal("stale run must record why it was taken over")
|
||||
}
|
||||
if reclaimed.ActiveSlot != nil {
|
||||
t.Fatal("stale run must release active_slot on takeover")
|
||||
}
|
||||
|
||||
// The new run's own row clears active_slot on completion just like any
|
||||
// other successful run (see run()'s defer), so what proves the takeover
|
||||
// happened is that a second, distinct run row now exists alongside the
|
||||
// reclaimed stale one.
|
||||
var totalRuns int64
|
||||
db.Model(&models.YeekeSyncRun{}).Count(&totalRuns)
|
||||
if totalRuns != 2 {
|
||||
t.Fatalf("expected the stale row plus exactly one new run after takeover, got %d run rows", totalRuns)
|
||||
}
|
||||
if rep.RunID == stale.ID {
|
||||
t.Fatal("the new run must not reuse the stale run's row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSlotLeaseRejectsConcurrentRuns above must still pass unmodified:
|
||||
// a lease that has NOT expired must keep blocking a second run. This test
|
||||
// pins that same guarantee at the acquire() level directly.
|
||||
func TestValidLeaseIsNotTakenOver(t *testing.T) {
|
||||
db := testDB(t)
|
||||
slot := uint8(1)
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
holding := models.YeekeSyncRun{
|
||||
Status: "running", Trigger: "manual", StartedAt: time.Now().UTC(),
|
||||
ActiveSlot: &slot, LeaseOwner: "still-alive", LeaseExpiresAt: &future,
|
||||
}
|
||||
if e := db.Create(&holding).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c, _ := yeekeclient.New("http://unused.invalid")
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
if _, e := s.acquire(context.Background(), "scheduled"); e == nil {
|
||||
t.Fatal("a still-valid lease must not be taken over or bypassed")
|
||||
}
|
||||
var row models.YeekeSyncRun
|
||||
if e := db.First(&row, holding.ID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if row.Status != "running" || row.ActiveSlot == nil {
|
||||
t.Fatalf("holder must be untouched: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentTakeoverExactlyOneWins races two acquire() calls against the
|
||||
// same stale, expired-lease row. Both attempt the takeover UPDATE and then a
|
||||
// Create; the takeover UPDATE is idempotent (the loser affects zero rows
|
||||
// since the row's status is no longer "running" by the time it runs), and
|
||||
// the ux_yeeke_sync_run_active_slot unique index arbitrates the Create race
|
||||
// the same way it already does for two brand-new concurrent runs. Exactly
|
||||
// one goroutine must come away holding the slot.
|
||||
func TestConcurrentTakeoverExactlyOneWins(t *testing.T) {
|
||||
db := testDB(t)
|
||||
slot := uint8(1)
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
stale := models.YeekeSyncRun{
|
||||
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
|
||||
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
|
||||
}
|
||||
if e := db.Create(&stale).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// SQLite only allows one writer at a time; serialize connections through
|
||||
// the Go pool (same pattern as app/goauto/purchase/order_backfill_test.go
|
||||
// and friends) so the race is decided by acquire()'s own logic rather
|
||||
// than by spurious "database is locked" errors.
|
||||
if sqlDB, e := db.DB(); e == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
c, _ := yeekeclient.New("http://unused.invalid")
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
oks := make([]bool, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, e := s.acquire(context.Background(), "manual")
|
||||
oks[i] = e == nil
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners := 0
|
||||
for _, ok := range oks {
|
||||
if ok {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("winners=%d, want exactly 1 (active_slot must arbitrate concurrent takeover attempts)", winners)
|
||||
}
|
||||
var holders int64
|
||||
db.Model(&models.YeekeSyncRun{}).Where("active_slot = ?", 1).Count(&holders)
|
||||
if holders != 1 {
|
||||
t.Fatalf("holders=%d, want exactly 1 row holding active_slot after the race", holders)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Defect 2 (#336): itemKey must not depend on item order --------------
|
||||
|
||||
// TestItemKeyStableAcrossReorder syncs the same package twice with its two
|
||||
// items in reversed order the second time. Reordering must not create new
|
||||
// rows: each item's identity must key off its own IDs, not its position.
|
||||
func TestItemKeyStableAcrossReorder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
var call int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
n := atomic.AddInt32(&call, 1)
|
||||
if n == 1 {
|
||||
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-a"), item("i2", "item-b", "var-b"))}, 1, 1))
|
||||
return
|
||||
}
|
||||
if n == 2 {
|
||||
// Same package, items reordered.
|
||||
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i2", "item-b", "var-b"), item("i1", "item-a", "var-a"))}, 1, 1))
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var pkg models.YeekeReturnPackage
|
||||
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||
if n != 2 {
|
||||
t.Fatalf("items=%d, want 2 (reordering the same items must not duplicate rows)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemKeyIndexFallbackForItemsLackingAllIDs covers a package whose items
|
||||
// carry no yeeke-issued identifiers at all: the positional index is the only
|
||||
// thing that can distinguish them, so the fallback must still apply and keep
|
||||
// them as separate rows.
|
||||
func TestItemKeyIndexFallbackForItemsLackingAllIDs(t *testing.T) {
|
||||
db := testDB(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{packageWithItems("p1", itemNoIDs(), itemNoIDs())}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||
if n != 2 {
|
||||
t.Fatalf("items=%d, want 2 (items lacking all IDs must still be distinguished by position)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemKeyDistinctVariationsOfSameItemID pins existing behavior: two
|
||||
// items sharing the same itemID but different variationIDs are, and must
|
||||
// remain, two distinct rows.
|
||||
func TestItemKeyDistinctVariationsOfSameItemID(t *testing.T) {
|
||||
db := testDB(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-1"), item("i2", "item-a", "var-2"))}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
|
||||
if n != 2 {
|
||||
t.Fatalf("items=%d, want 2 (same itemID with different variationID must stay distinct)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResyncUpdatesClaimTimeAndDestroyDeadLine: yeeke may correct a package's
|
||||
// claim time or destroy deadline between two syncs of the same package. The
|
||||
// second sync must overwrite the stored values, not just refresh
|
||||
// last_synced_at.
|
||||
func TestResyncUpdatesClaimTimeAndDestroyDeadLine(t *testing.T) {
|
||||
db := testDB(t)
|
||||
recordWithTimes := func(claimTime, destroyDeadLine string) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"id": "p1", "ordersn": "o-p1", "trackingNo": "t-p1", "status": "1",
|
||||
"claimTime": claimTime, "destroyDeadLine": destroyDeadLine,
|
||||
"items": []map[string]any{{
|
||||
"id": "p1-i1", "itemId": "i", "variationId": "v1",
|
||||
"itemName": "n", "variationName": "v", "variationQuantityPurchased": "1",
|
||||
}},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
var body string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 20})
|
||||
|
||||
body = page([]string{recordWithTimes("2026-09-01 10:00:00", "2026-10-01 10:00:00")}, 1, 1)
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatalf("first sync: %v", err)
|
||||
}
|
||||
var row models.YeekeReturnPackage
|
||||
db.Where("external_id = ?", "p1").First(&row)
|
||||
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-01" {
|
||||
t.Fatalf("claim_time after first sync = %s", got)
|
||||
}
|
||||
|
||||
body = page([]string{recordWithTimes("2026-09-15 08:00:00", "2026-10-15 08:00:00")}, 1, 1)
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
db.Where("external_id = ?", "p1").First(&row)
|
||||
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-15" {
|
||||
t.Fatalf("claim_time after resync = %s, want updated value", got)
|
||||
}
|
||||
if got := row.DestroyDeadLine.Format("2006-01-02"); got != "2026-10-15" {
|
||||
t.Fatalf("destroy_dead_line after resync = %s, want updated value", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rejectZeroLastSyncedAt emulates MySQL strict mode (NO_ZERO_DATE) in SQLite:
|
||||
// an INSERT carrying a zero last_synced_at is refused, exactly as production
|
||||
// MySQL refused the old placeholder insert and failed every record.
|
||||
func rejectZeroLastSyncedAt(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
err := db.Callback().Create().Before("gorm:create").Register("test:strict_no_zero_date", func(tx *gorm.DB) {
|
||||
switch v := tx.Statement.Dest.(type) {
|
||||
case *models.YeekeReturnPackage:
|
||||
if v.LastSyncedAt.IsZero() {
|
||||
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
|
||||
}
|
||||
case *models.YeekeReturnItem:
|
||||
if v.LastSyncedAt.IsZero() {
|
||||
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncWritesUnderStrictDatetimeMode(t *testing.T) {
|
||||
db := testDB(t)
|
||||
rejectZeroLastSyncedAt(t, db)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1"), record("p2", "i", "v2", "1")}, 2, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
rep, err := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("sync: %v (rep=%+v)", err, rep)
|
||||
}
|
||||
if rep.Failed != 0 || rep.Created != 2 {
|
||||
t.Fatalf("rep=%+v, want 2 created and 0 failed", rep)
|
||||
}
|
||||
var pkgs, items int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
|
||||
db.Model(&models.YeekeReturnItem{}).Count(&items)
|
||||
if pkgs != 2 || items != 2 {
|
||||
t.Fatalf("stored packages=%d items=%d", pkgs, items)
|
||||
}
|
||||
var p models.YeekeReturnPackage
|
||||
db.First(&p)
|
||||
if p.LastSyncedAt.IsZero() || time.Since(p.LastSyncedAt) > time.Minute {
|
||||
t.Fatalf("last_synced_at not set: %v", p.LastSyncedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// When records fail to write, the run must say why and must not claim success
|
||||
// when nothing was stored.
|
||||
func TestSyncReportsWriteFailureReason(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:fail_items", func(tx *gorm.DB) {
|
||||
if _, ok := tx.Statement.Dest.(*models.YeekeReturnItem); ok {
|
||||
_ = tx.AddError(errors.New("simulated item write failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1")}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
rep, _ := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
|
||||
if rep.Status != "failed" || rep.Failed != 1 {
|
||||
t.Fatalf("rep=%+v, want failed with 1 failure", rep)
|
||||
}
|
||||
var run models.YeekeSyncRun
|
||||
db.Order("id DESC").First(&run)
|
||||
if !strings.Contains(run.ErrorMessage, "1 条写入失败") || !strings.Contains(run.ErrorMessage, "simulated item write failure") {
|
||||
t.Fatalf("error_message = %q", run.ErrorMessage)
|
||||
}
|
||||
// The package insert shares the transaction, so no half-written package.
|
||||
var pkgs int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
|
||||
if pkgs != 0 {
|
||||
t.Fatalf("package rows = %d, want 0 after rolled-back item failure", pkgs)
|
||||
}
|
||||
}
|
||||
@@ -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,385 @@
|
||||
package yeekeclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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")
|
||||
// yeeke is driven through its web front end; a Go default user agent is
|
||||
// treated differently from the browser (see demo/yeeke_demo.py and the HAR).
|
||||
req.Header.Set("User-Agent", browserUserAgent)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// The yeeke web client authenticates with the X-Access-Token header (HAR);
|
||||
// keeping the token out of the URL also keeps it out of any access log.
|
||||
if c.token != "" {
|
||||
req.Header.Set("X-Access-Token", c.token)
|
||||
}
|
||||
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 {
|
||||
return nil, &APIError{Code: env.Code, Message: env.Message}
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
||||
// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in.
|
||||
const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
// APIError is a yeeke business failure (success=false). Message is yeeke's own
|
||||
// short text (e.g. "验证码错误"); it never contains our credentials or token.
|
||||
type APIError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message))
|
||||
}
|
||||
|
||||
// Is keeps the historical contract: an expired or missing login is reported as
|
||||
// ErrSessionInvalid so callers can re-login.
|
||||
func (e *APIError) Is(target error) bool {
|
||||
if target != ErrSessionInvalid {
|
||||
return false
|
||||
}
|
||||
return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录") || (strings.Contains(e.Message, "登录") && strings.Contains(e.Message, "失效"))
|
||||
}
|
||||
|
||||
// isCaptchaRejected reports a wrong captcha, the only login failure worth
|
||||
// retrying with a fresh image.
|
||||
func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") }
|
||||
|
||||
func safeMessage(m string) string {
|
||||
r := []rune(strings.TrimSpace(m))
|
||||
if len(r) > 60 {
|
||||
r = r[:60]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
|
||||
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
|
||||
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
// The live randomImage response carries the captcha key as "key" (seen in
|
||||
// the HAR evidence for #336); "checkKey" is only the name the login body
|
||||
// uses. Accept both so a future rename on either side still works.
|
||||
var p struct {
|
||||
Image string `json:"image"`
|
||||
Key string `json:"key"`
|
||||
CheckKey string `json:"checkKey"`
|
||||
}
|
||||
if e = json.Unmarshal(raw, &p); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
key := p.Key
|
||||
if key == "" {
|
||||
key = p.CheckKey
|
||||
}
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("yeeke captcha response missing key")
|
||||
}
|
||||
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: key, 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
|
||||
}
|
||||
emptyOCR, captchaRejected := 0, 0
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
cap, e := c.FetchCaptcha(ctx)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
|
||||
}
|
||||
code, e := ocr.Recognize(ctx, cap.Image)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("yeeke OCR unavailable")
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
emptyOCR++
|
||||
continue
|
||||
}
|
||||
out, e := c.Login(ctx, username, password, code, cap.CheckKey)
|
||||
if e == nil {
|
||||
return out, nil
|
||||
}
|
||||
var apiErr *APIError
|
||||
if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() {
|
||||
captchaRejected++
|
||||
continue
|
||||
}
|
||||
// Anything else (wrong account/password, locked account, network) will
|
||||
// not be fixed by another captcha; stop instead of burning attempts.
|
||||
if errors.As(e, &apiErr) {
|
||||
return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message))
|
||||
}
|
||||
return nil, fmt.Errorf("yeeke 登录请求失败: %w", e)
|
||||
}
|
||||
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR)
|
||||
}
|
||||
|
||||
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]any{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt", "remember_me": true}, 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 {
|
||||
// /shopee/user/info is what the web client itself calls after login (HAR);
|
||||
// the earlier /sys/userInfo path does not exist and answered HTTP 500.
|
||||
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
|
||||
_, e := c.do(ctx, http.MethodGet, "/agent-foreign/shopee/user/info", nil, q)
|
||||
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"} {
|
||||
// yeeke returns naive Beijing wall-clock times ("2026-09-21 14:13:29");
|
||||
// reading them as UTC would shift every time by eight hours.
|
||||
if x, e := time.ParseInLocation(f, s, beijing); 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 FlexInt `json:"variationQuantityPurchased"`
|
||||
}
|
||||
|
||||
// beijing is yeeke's server wall clock (fixed UTC+8, no DST).
|
||||
var beijing = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// FlexInt accepts a JSON number or a numeric string; yeeke sends
|
||||
// variationQuantityPurchased as a string (e.g. "2", see HAR). Empty, null or
|
||||
// non-numeric values decode as 0 instead of failing the whole page.
|
||||
type FlexInt int64
|
||||
|
||||
func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
if s == "" || s == "null" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
*f = FlexInt(n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
|
||||
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "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,260 @@
|
||||
package yeekeclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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=", "key": "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.Header.Get("X-Access-Token") != "opaque" {
|
||||
t.Errorf("X-Access-Token header missing")
|
||||
}
|
||||
if r.URL.Query().Get("token") != "" {
|
||||
t.Errorf("token must not be sent in the URL")
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["column"] != "createTime" || body["claimFlag"] != "1" || body["status"] != "1" || body["relationFlag"] != "1" || body["order"] != "desc" {
|
||||
t.Errorf("list body shape differs from the web client: %v", body)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The live randomImage response names the captcha key "key" (HAR evidence for
|
||||
// #336). A client that only read "checkKey" sent an empty checkKey, never
|
||||
// reached the login endpoint, and every OCR attempt "failed". This pins the
|
||||
// real response shape end to end: the key from randomImage must arrive as the
|
||||
// login body's checkKey, together with remember_me.
|
||||
func TestLoginWithOCRSendsCaptchaKeyFromRandomImage(t *testing.T) {
|
||||
var loginBody map[string]any
|
||||
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, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "0123456789abcdef0123456789abcdef"}})
|
||||
case "/agent-foreign/sys/login":
|
||||
_ = json.NewDecoder(r.Body).Decode(&loginBody)
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
|
||||
t.Fatalf("login: %v", e)
|
||||
}
|
||||
if loginBody == nil {
|
||||
t.Fatal("login endpoint was never called")
|
||||
}
|
||||
if loginBody["checkKey"] != "0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("checkKey = %v, want the randomImage key", loginBody["checkKey"])
|
||||
}
|
||||
if loginBody["remember_me"] != true || loginBody["captcha"] != "abcd" || loginBody["agentCode"] != "mmt" {
|
||||
t.Fatalf("unexpected login body shape: %v", keysOnly(loginBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCaptchaRejectsResponseWithoutKey(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk="}})
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
if _, e := c.FetchCaptcha(context.Background()); e == nil {
|
||||
t.Fatal("expected an explicit error when the captcha key is missing")
|
||||
}
|
||||
}
|
||||
|
||||
type fixedOCR string
|
||||
|
||||
func (f fixedOCR) Recognize(context.Context, []byte) (string, error) { return string(f), nil }
|
||||
|
||||
func keysOnly(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and
|
||||
// a _t cache-buster on randomImage.
|
||||
func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) {
|
||||
var captchaQuery, captchaUA, loginUA string
|
||||
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":
|
||||
captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent()
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||
case "/agent-foreign/sys/login":
|
||||
loginUA = r.UserAgent()
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}})
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !strings.HasPrefix(captchaQuery, "_t=") {
|
||||
t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery)
|
||||
}
|
||||
for _, ua := range []string{captchaUA, loginUA} {
|
||||
if !strings.Contains(ua, "Chrome/") {
|
||||
t.Fatalf("user agent = %q, want browser UA", ua)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong captcha is retried with a fresh image; a wrong password is not.
|
||||
func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
responses []map[string]any
|
||||
wantOK bool
|
||||
wantLogins int
|
||||
wantErrSubstr string
|
||||
}{
|
||||
{
|
||||
name: "captcha wrong then ok",
|
||||
responses: []map[string]any{
|
||||
{"success": false, "code": 500, "message": "验证码错误"},
|
||||
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
|
||||
},
|
||||
wantOK: true, wantLogins: 2,
|
||||
},
|
||||
{
|
||||
name: "wrong password stops immediately",
|
||||
responses: []map[string]any{
|
||||
{"success": false, "code": 500, "message": "用户名或密码错误"},
|
||||
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
|
||||
},
|
||||
wantLogins: 1, wantErrSubstr: "用户名或密码错误",
|
||||
},
|
||||
{
|
||||
name: "captcha always wrong",
|
||||
responses: []map[string]any{
|
||||
{"success": false, "code": 500, "message": "验证码错误"},
|
||||
{"success": false, "code": 500, "message": "验证码错误"},
|
||||
{"success": false, "code": 500, "message": "验证码错误"},
|
||||
},
|
||||
wantLogins: 3, wantErrSubstr: "captcha_rejected=3",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logins := 0
|
||||
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, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
||||
case "/agent-foreign/sys/login":
|
||||
resp := tc.responses[logins]
|
||||
logins++
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
_, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3)
|
||||
if (e == nil) != tc.wantOK {
|
||||
t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK)
|
||||
}
|
||||
if logins != tc.wantLogins {
|
||||
t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins)
|
||||
}
|
||||
if e != nil {
|
||||
if !strings.Contains(e.Error(), tc.wantErrSubstr) {
|
||||
t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr)
|
||||
}
|
||||
if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") {
|
||||
t.Fatalf("error leaks credentials or captcha text: %q", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) {
|
||||
for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "登录已失效": true, "验证码错误": false, "用户名或密码错误": false} {
|
||||
if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want {
|
||||
t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) {
|
||||
t.Fatal("code 401 must map to ErrSessionInvalid")
|
||||
}
|
||||
}
|
||||
|
||||
// Field types taken from the HAR list response (values are fake): quantity is
|
||||
// a numeric string, ids are strings, many fields are null, and times are
|
||||
// naive Beijing wall-clock strings.
|
||||
func TestListDecodesWebClientResponseShape(t *testing.T) {
|
||||
const page = `{"success":true,"code":200,"message":"操作成功!","result":{
|
||||
"records":[{"id":"1000000000000000001","ordersn":"FAKE-ORDER-1","trackingNo":"FAKE-TRACK-1",
|
||||
"shopId":"123","shopName":"示例店铺","wareCode":"W1","wareHouse":"A-01","wareName":"示例仓",
|
||||
"status":"1","claimTime":"2026-09-21 14:13:29","createTime":"2026-09-21 14:13:29",
|
||||
"updateTime":"2026-09-21 14:13:29","destroyDeadLine":"2026-10-21 14:13:29",
|
||||
"airwayBill":null,"secondSaleOrdersn":null,"photoList":null,
|
||||
"items":[{"id":null,"itemId":"48800000000","variationId":"435000000000","itemName":"示例商品",
|
||||
"variationName":"黑色,L","image":"https://example.invalid/a.jpg","variationQuantityPurchased":"2",
|
||||
"aneroidMarkup":false,"num":null},
|
||||
{"id":null,"itemId":"48800000000","variationId":"435000000001","itemName":"示例商品",
|
||||
"variationName":"黑色,XL","image":"","variationQuantityPurchased":null}]}],
|
||||
"total":1,"size":20,"current":1,"pages":1,"searchCount":true}}`
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
c.SetToken("opaque")
|
||||
p, e := c.List(context.Background(), 1, 20)
|
||||
if e != nil {
|
||||
t.Fatalf("list: %v", e)
|
||||
}
|
||||
if len(p.Records) != 1 || len(p.Records[0].Items) != 2 || p.Total != 1 || p.Pages != 1 {
|
||||
t.Fatalf("unexpected page: %+v", p)
|
||||
}
|
||||
if p.Records[0].Items[0].Quantity != 2 || p.Records[0].Items[1].Quantity != 0 {
|
||||
t.Fatalf("quantities = %d, %d", p.Records[0].Items[0].Quantity, p.Records[0].Items[1].Quantity)
|
||||
}
|
||||
got := p.Records[0].CreateTime.Time.UTC().Format(time.RFC3339)
|
||||
if got != "2026-09-21T06:13:29Z" {
|
||||
t.Fatalf("createTime = %s, want Beijing 14:13:29 = 06:13:29Z", got)
|
||||
}
|
||||
}
|
||||
@@ -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,178 @@
|
||||
package yeekeclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.YeekeSession{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
type stubOCR struct {
|
||||
codes []string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
|
||||
if s.calls >= len(s.codes) {
|
||||
return "", nil
|
||||
}
|
||||
c := s.codes[s.calls]
|
||||
s.calls++
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// server builds a fake yeeke backend. loginOK controls whether /login accepts
|
||||
// the submitted captcha; sessionValid controls whether /userInfo (used by
|
||||
// CheckSession) reports the cached token as still good.
|
||||
func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid func(token string) bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return 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=", "key": "k"}})
|
||||
case "/agent-foreign/sys/login":
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
captcha, _ := body["captcha"].(string)
|
||||
if loginOK(captcha) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + captcha, "userInfo": map[string]any{"id": "u1", "username": "u"}}})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
|
||||
case "/agent-foreign/shopee/user/info":
|
||||
token := r.Header.Get("X-Access-Token")
|
||||
if sessionValid(token) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{}})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 401, "message": "登录已失效"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// TestConnectLoginsOnceThenReusesSession: a fresh Connect performs exactly one
|
||||
// login, and a second Connect call with the cached session valid performs no
|
||||
// login at all (token reuse, no re-login when session valid).
|
||||
func TestConnectLoginsOnceThenReusesSession(t *testing.T) {
|
||||
db := newDB(t)
|
||||
loginCalls := 0
|
||||
srv := fakeServer(t,
|
||||
func(captcha string) bool { loginCalls++; return captcha == "abcd" },
|
||||
func(token string) bool { return token == "tok-abcd" },
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
store := NewSessionStore(db)
|
||||
ocr := &stubOCR{codes: []string{"abcd"}}
|
||||
client, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.Token() != "tok-abcd" {
|
||||
t.Fatalf("token=%q", client.Token())
|
||||
}
|
||||
if loginCalls != 1 {
|
||||
t.Fatalf("loginCalls=%d, want 1", loginCalls)
|
||||
}
|
||||
|
||||
// Second connect: session is cached and still valid, so this must not
|
||||
// touch OCR or /login again.
|
||||
ocr2 := &stubOCR{codes: []string{"should-not-be-used"}}
|
||||
client2, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr2, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client2.Token() != "tok-abcd" {
|
||||
t.Fatalf("reused token=%q", client2.Token())
|
||||
}
|
||||
if loginCalls != 1 {
|
||||
t.Fatalf("loginCalls after reuse=%d, want still 1", loginCalls)
|
||||
}
|
||||
if ocr2.calls != 0 {
|
||||
t.Fatalf("OCR must not be called when the cached session is valid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectReLoginsAfterSessionExpiredAndIsBounded: when the cached session
|
||||
// is explicitly rejected (ErrSessionInvalid), Connect re-logs in — but only
|
||||
// up to maxLogin captcha attempts, never looping forever.
|
||||
func TestConnectReLoginsAfterSessionExpiredAndIsBounded(t *testing.T) {
|
||||
db := newDB(t)
|
||||
store := NewSessionStore(db)
|
||||
// Seed an already-cached, not-yet-expired session so Connect's Load finds
|
||||
// it and only CheckSession decides it is dead.
|
||||
if err := store.Save(context.Background(), Session{
|
||||
Username: "u", Token: "stale", CookiesJSON: `[]`, UserID: "u1",
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loginAttempts := 0
|
||||
srv := fakeServer(t,
|
||||
func(captcha string) bool { loginAttempts++; return false }, // every captcha rejected
|
||||
func(token string) bool { return false }, // cached session always invalid
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
ocr := &stubOCR{codes: []string{"1", "2", "3", "4", "5", "6"}} // more codes than maxLogin allows
|
||||
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
||||
if err == nil {
|
||||
t.Fatal("expected login failure")
|
||||
}
|
||||
if loginAttempts != 3 {
|
||||
t.Fatalf("loginAttempts=%d, want exactly maxLogin=3 (bounded, not endless)", loginAttempts)
|
||||
}
|
||||
|
||||
// The rejected cached session must have been deleted, not left in place.
|
||||
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != ErrNoSession {
|
||||
t.Fatalf("expired/invalid session should have been deleted: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectKeepsCachedSessionOnTimeoutOrServerError: a network-level error
|
||||
// checking the session (not an explicit "invalid") must not discard a
|
||||
// possibly-still-good cached session.
|
||||
func TestConnectKeepsCachedSessionOnTimeoutOrServerError(t *testing.T) {
|
||||
db := newDB(t)
|
||||
store := NewSessionStore(db)
|
||||
if err := store.Save(context.Background(), Session{
|
||||
Username: "u", Token: "tok", CookiesJSON: `[]`, UserID: "u1",
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, &stubOCR{}, 3)
|
||||
if err == nil {
|
||||
t.Fatal("expected a propagated 5xx error")
|
||||
}
|
||||
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != nil {
|
||||
t.Fatalf("a 5xx must not discard the cached session: %v", loadErr)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
"go-admin/app/goauto/yeeke"
|
||||
)
|
||||
|
||||
// InitJob
|
||||
@@ -17,6 +18,7 @@ func InitJob() {
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
|
||||
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
||||
yeeke.ReturnSyncInvokeTarget: yeeke.ReturnSyncJob{},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/yeeke"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateYeekeReturnSyncJob)
|
||||
}
|
||||
|
||||
// migrateYeekeReturnSyncJob seeds the scheduled yeeke return sync job row,
|
||||
// disabled by default (#336 requirement: 默认关闭定时任务,管理员手动开启).
|
||||
// It follows 1786701600000_syb_hourly_sync_job.go exactly: Status 2 keeps the
|
||||
// row out of the running cron set (see app/jobs/service/sys_job.go, which
|
||||
// only adds Status == 1 jobs to the cron at startup).
|
||||
func migrateYeekeReturnSyncJob(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensureYeekeReturnSyncJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureYeekeReturnSyncJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", yeeke.ReturnSyncInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "yeeke 退货包裹同步", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 20 * * * *", InvokeTarget: yeeke.ReturnSyncInvokeTarget,
|
||||
Args: "",
|
||||
MisfirePolicy: 1, Concurrent: 1, Status: 2,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateYeekeAdminMenu)
|
||||
}
|
||||
|
||||
// migrateYeekeAdminMenu adds the 「yeeke 退货包裹」 and 「yeeke 同步记录」 menu
|
||||
// entries for #337. #336 registered access.ModuleYeekeReturns and shipped the
|
||||
// sync backend, but never wrote its sys_menu row or web page, so Admin had no
|
||||
// entry point; #337 also splits the sync-run history into its own module
|
||||
// (access.ModuleYeekeSyncRuns), matching the existing SYB 商品 / SYB 同步记录
|
||||
// split.
|
||||
//
|
||||
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
|
||||
// page reachable. Menus are written by the versioned migration that first
|
||||
// created them, and that version is already recorded in sys_migration on every
|
||||
// existing database — it never runs again. Startup only reconciles the casbin
|
||||
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
|
||||
// migration the pages exist, the APIs answer and the routes resolve, yet
|
||||
// nobody can navigate to them on an already-migrated database. This mirrors
|
||||
// 1789113000000_syb_product_filter_menu.go exactly.
|
||||
func migrateYeekeAdminMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// Both pages belong beside the SYB modules under 采集采购, where the
|
||||
// admin already manages inbound collection/purchase data sources.
|
||||
var parent migrationmodels.SysMenu
|
||||
if err := tx.Where("menu_name = ?", "GoAutoCollectionPurchase").First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range []string{access.ModuleYeekeReturns, access.ModuleYeekeSyncRuns} {
|
||||
module, ok := goAutoModuleByKey(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", key)
|
||||
}
|
||||
|
||||
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
|
||||
MenuName: module.RouteName,
|
||||
Title: module.Title,
|
||||
Icon: module.Icon,
|
||||
Path: module.Path + "/index",
|
||||
MenuType: "C",
|
||||
Action: "无",
|
||||
ParentId: parent.MenuId,
|
||||
Component: module.Component,
|
||||
Sort: module.Sort,
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind to both roles the module declares. 采购员 needs it because the
|
||||
// module is PurchaserDefault; 管理员 needs it so it can always see and
|
||||
// manually trigger the read-only sync surface.
|
||||
roleKeys := []string{"admin"}
|
||||
if module.PurchaserDefault {
|
||||
roleKeys = append(roleKeys, access.RolePurchaser)
|
||||
}
|
||||
for _, roleKey := range roleKeys {
|
||||
var role migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+69
-2
@@ -18,8 +18,9 @@ var ExtConfig Extend
|
||||
//
|
||||
// 使用方法: config.ExtConfig......即可!!
|
||||
type Extend struct {
|
||||
AMap AMap // 这里配置对应配置文件的结构即可
|
||||
SYB SYB
|
||||
AMap AMap // 这里配置对应配置文件的结构即可
|
||||
SYB SYB
|
||||
Yeeke Yeeke
|
||||
}
|
||||
|
||||
type AMap struct {
|
||||
@@ -89,6 +90,65 @@ func (s SYB) HasCredentials() bool {
|
||||
return strings.TrimSpace(s.Username) != "" && s.Password != ""
|
||||
}
|
||||
|
||||
// Yeeke holds the mmt.yeeke.com 退货包裹只读对接 connection settings (#336).
|
||||
//
|
||||
// `[必须]` Username and Password are NOT read from settings.yml — same rule as
|
||||
// SYB above — they come from GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD (or
|
||||
// config.yaml's yeeke: section) so no credential ever lands in a tracked file.
|
||||
type Yeeke struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
PageSize int
|
||||
MaxPages int
|
||||
Retry int
|
||||
// OcrURL is the captcha recognition service shared with SYB (#336, approved
|
||||
// 2026-09-23). Empty disables OCR; there is no manual-entry fallback here
|
||||
// because this is a server-side scheduled/triggered flow, not an interactive
|
||||
// login form, so a disabled OCR simply makes sync fail with a clear error.
|
||||
OcrURL string
|
||||
OcrMaxAttempts int
|
||||
}
|
||||
|
||||
// YeekeDefaults are the values used when settings.yml leaves a field blank.
|
||||
const (
|
||||
DefaultYeekeBaseURL = "https://mmt.yeeke.com"
|
||||
DefaultYeekePageSize = 100
|
||||
DefaultYeekeMaxPages = 10000
|
||||
DefaultYeekeRetry = 2
|
||||
DefaultYeekeOcrMaxAttempts = 5
|
||||
)
|
||||
|
||||
// Resolved returns the Yeeke settings with blanks replaced by defaults. It
|
||||
// never defaults Username or Password: missing credentials must surface as an
|
||||
// error at the call site, not as an attempt to log in as nobody.
|
||||
func (y Yeeke) Resolved() Yeeke {
|
||||
if strings.TrimSpace(y.BaseURL) == "" {
|
||||
y.BaseURL = DefaultYeekeBaseURL
|
||||
}
|
||||
if y.PageSize <= 0 {
|
||||
y.PageSize = DefaultYeekePageSize
|
||||
}
|
||||
if y.MaxPages <= 0 {
|
||||
y.MaxPages = DefaultYeekeMaxPages
|
||||
}
|
||||
if y.Retry < 0 {
|
||||
y.Retry = DefaultYeekeRetry
|
||||
}
|
||||
if y.OcrMaxAttempts <= 0 {
|
||||
y.OcrMaxAttempts = DefaultYeekeOcrMaxAttempts
|
||||
}
|
||||
y.Username = strings.TrimSpace(y.Username)
|
||||
y.BaseURL = strings.TrimRight(strings.TrimSpace(y.BaseURL), "/")
|
||||
y.OcrURL = strings.TrimSpace(y.OcrURL)
|
||||
return y
|
||||
}
|
||||
|
||||
// HasCredentials reports whether both account fields were supplied.
|
||||
func (y Yeeke) HasCredentials() bool {
|
||||
return strings.TrimSpace(y.Username) != "" && y.Password != ""
|
||||
}
|
||||
|
||||
// ApplyEnvironment replaces tracked defaults with process-local runtime values.
|
||||
// Credentials stay outside tracked configuration files. GOAUTO_DB_DRIVER
|
||||
// defaults to mysql when GOAUTO_DB_DSN is present.
|
||||
@@ -104,6 +164,13 @@ func ApplyEnvironment() {
|
||||
if password := os.Getenv("GOAUTO_SYB_PASSWORD"); password != "" {
|
||||
ExtConfig.SYB.Password = password
|
||||
}
|
||||
if username := strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")); username != "" {
|
||||
ExtConfig.Yeeke.Username = username
|
||||
}
|
||||
// `[必须]` Taken verbatim, same reasoning as GOAUTO_SYB_PASSWORD above.
|
||||
if password := os.Getenv("GOAUTO_YEEKE_PASSWORD"); password != "" {
|
||||
ExtConfig.Yeeke.Password = password
|
||||
}
|
||||
|
||||
dsn := strings.TrimSpace(os.Getenv("GOAUTO_DB_DSN"))
|
||||
if dsn == "" {
|
||||
|
||||
@@ -62,5 +62,45 @@ func TestSettingsFilesCarryNoSYBCredentials(t *testing.T) {
|
||||
if file.Settings.Extend.SYB.Username != "" || file.Settings.Extend.SYB.Password != "" {
|
||||
t.Fatalf("%s 里出现了顺云宝凭据,凭据必须走环境变量", name)
|
||||
}
|
||||
if file.Settings.Extend.Yeeke.Username != "" || file.Settings.Extend.Yeeke.Password != "" {
|
||||
t.Fatalf("%s 里出现了 yeeke 凭据,凭据必须走环境变量 (#336)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settings.yml 里的 extend.yeeke 必须真的能绑进 ExtConfig.Yeeke,同样的道理
|
||||
// 见上面 TestSYBSettingsInRepoBindToExtendStruct 的注释 (#336)。
|
||||
func TestYeekeSettingsInRepoBindToExtendStruct(t *testing.T) {
|
||||
raw, err := os.ReadFile("settings.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("读取 settings.yml 失败: %v", err)
|
||||
}
|
||||
var file struct {
|
||||
Settings struct {
|
||||
Extend Extend `yaml:"extend"`
|
||||
} `yaml:"settings"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &file); err != nil {
|
||||
t.Fatalf("解析 settings.yml 失败: %v", err)
|
||||
}
|
||||
|
||||
yeeke := file.Settings.Extend.Yeeke
|
||||
if yeeke.BaseURL != "https://mmt.yeeke.com" {
|
||||
t.Fatalf("baseurl 没有绑定成功: %q", yeeke.BaseURL)
|
||||
}
|
||||
if yeeke.PageSize != 100 {
|
||||
t.Fatalf("pagesize 没有绑定成功: %d", yeeke.PageSize)
|
||||
}
|
||||
if yeeke.MaxPages != 10000 {
|
||||
t.Fatalf("maxpages 没有绑定成功: %d", yeeke.MaxPages)
|
||||
}
|
||||
if yeeke.Retry != 2 {
|
||||
t.Fatalf("retry 没有绑定成功: %d", yeeke.Retry)
|
||||
}
|
||||
if yeeke.OcrURL == "" {
|
||||
t.Fatal("ocrurl 没有绑定成功")
|
||||
}
|
||||
if yeeke.OcrMaxAttempts != 5 {
|
||||
t.Fatalf("ocrmaxattempts 没有绑定成功: %d", yeeke.OcrMaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,61 @@ func TestApplyEnvironmentLoadsSYBCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同上,yeeke 凭据也只能来自环境变量 (#336)。
|
||||
func TestApplyEnvironmentLoadsYeekeCredentials(t *testing.T) {
|
||||
original := ExtConfig.Yeeke
|
||||
t.Cleanup(func() { ExtConfig.Yeeke = original })
|
||||
|
||||
t.Setenv("GOAUTO_YEEKE_USERNAME", " operator ")
|
||||
t.Setenv("GOAUTO_YEEKE_PASSWORD", " se cret ")
|
||||
ApplyEnvironment()
|
||||
|
||||
if ExtConfig.Yeeke.Username != "operator" {
|
||||
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.Yeeke.Username)
|
||||
}
|
||||
if ExtConfig.Yeeke.Password != " se cret " {
|
||||
t.Fatalf("密码不应被修改: %q", ExtConfig.Yeeke.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYeekeResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
||||
resolved := Yeeke{}.Resolved()
|
||||
|
||||
if resolved.BaseURL != DefaultYeekeBaseURL {
|
||||
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
|
||||
}
|
||||
if resolved.PageSize != DefaultYeekePageSize || resolved.MaxPages != DefaultYeekeMaxPages {
|
||||
t.Fatalf("分页默认值不对: %+v", resolved)
|
||||
}
|
||||
if resolved.OcrMaxAttempts != DefaultYeekeOcrMaxAttempts {
|
||||
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
|
||||
}
|
||||
if resolved.Username != "" || resolved.Password != "" {
|
||||
t.Fatal("Resolved 不得给账号密码编造默认值")
|
||||
}
|
||||
if resolved.OcrURL != "" {
|
||||
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYeekeHasCredentialsRequiresBothFields(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
yeeke Yeeke
|
||||
want bool
|
||||
}{
|
||||
{"都有", Yeeke{Username: "a", Password: "b"}, true},
|
||||
{"缺密码", Yeeke{Username: "a"}, false},
|
||||
{"缺账号", Yeeke{Password: "b"}, false},
|
||||
{"账号只有空白", Yeeke{Username: " ", Password: "b"}, false},
|
||||
{"都没有", Yeeke{}, false},
|
||||
} {
|
||||
if got := c.yeeke.HasCredentials(); got != c.want {
|
||||
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
||||
resolved := SYB{}.Resolved()
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ type localFile struct {
|
||||
Database map[string]any `yaml:"database"`
|
||||
Ports map[string]any `yaml:"ports"`
|
||||
SYB map[string]any `yaml:"syb"`
|
||||
Yeeke map[string]any `yaml:"yeeke"`
|
||||
}
|
||||
|
||||
// ApplyLocalConfig loads config.yaml, if one is present, over the values
|
||||
@@ -96,6 +97,7 @@ func ApplyLocalConfig() {
|
||||
applyLocalDatabase(file.Database)
|
||||
applyLocalPorts(file.Ports)
|
||||
ApplyLocalSYB(file.SYB)
|
||||
ApplyLocalYeeke(file.Yeeke)
|
||||
logInfo("本地配置:已加载 %s", path)
|
||||
}
|
||||
|
||||
@@ -115,6 +117,17 @@ func LogEffectiveConfig() {
|
||||
}
|
||||
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||||
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
|
||||
|
||||
yeeke := ExtConfig.Yeeke.Resolved()
|
||||
yeekeSource := "未配置"
|
||||
switch {
|
||||
case strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")) != "":
|
||||
yeekeSource = "环境变量"
|
||||
case yeeke.HasCredentials():
|
||||
yeekeSource = LocalConfigName
|
||||
}
|
||||
logInfo("yeeke 配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||||
yeeke.HasCredentials(), yeekeSource, yeeke.BaseURL, yeeke.OcrURL != "")
|
||||
}
|
||||
|
||||
func applyLocalDatabase(database map[string]any) {
|
||||
@@ -177,6 +190,35 @@ func ApplyLocalSYB(syb map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyLocalYeeke folds a config.yaml `yeeke:` section into ExtConfig, mirroring
|
||||
// ApplyLocalSYB above (#336).
|
||||
func ApplyLocalYeeke(yeeke map[string]any) {
|
||||
if username := strings.TrimSpace(scalar(yeeke, "username")); username != "" {
|
||||
ExtConfig.Yeeke.Username = username
|
||||
}
|
||||
if password := scalar(yeeke, "password"); password != "" {
|
||||
ExtConfig.Yeeke.Password = password
|
||||
}
|
||||
if baseURL := strings.TrimSpace(scalar(yeeke, "base_url")); baseURL != "" {
|
||||
ExtConfig.Yeeke.BaseURL = baseURL
|
||||
}
|
||||
if ocrURL := strings.TrimSpace(scalar(yeeke, "ocr_url")); ocrURL != "" {
|
||||
ExtConfig.Yeeke.OcrURL = ocrURL
|
||||
}
|
||||
if pageSize, err := strconv.Atoi(scalar(yeeke, "page_size")); err == nil && pageSize > 0 {
|
||||
ExtConfig.Yeeke.PageSize = pageSize
|
||||
}
|
||||
if maxPages, err := strconv.Atoi(scalar(yeeke, "max_pages")); err == nil && maxPages > 0 {
|
||||
ExtConfig.Yeeke.MaxPages = maxPages
|
||||
}
|
||||
if retry, err := strconv.Atoi(scalar(yeeke, "retry")); err == nil && retry >= 0 {
|
||||
ExtConfig.Yeeke.Retry = retry
|
||||
}
|
||||
if attempts, err := strconv.Atoi(scalar(yeeke, "ocr_max_attempts")); err == nil && attempts > 0 {
|
||||
ExtConfig.Yeeke.OcrMaxAttempts = attempts
|
||||
}
|
||||
}
|
||||
|
||||
// logInfo writes an informational startup line to stdout.
|
||||
//
|
||||
// `[必须]` Not stderr. The development launcher pipes the server through
|
||||
|
||||
@@ -61,6 +61,17 @@ settings:
|
||||
# `[必须]` 验证码图片会被发送到这个地址;换成别人运营的服务前要重新评估。
|
||||
ocrurl: https://ocr.ilapage.cn/ocr
|
||||
ocrmaxattempts: 5
|
||||
# yeeke(mmt.yeeke.com)退货包裹只读对接,见 #336。
|
||||
# `[必须]` 账号密码不在这里,走环境变量 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,
|
||||
# 以免凭据进 Git。
|
||||
yeeke:
|
||||
baseurl: https://mmt.yeeke.com
|
||||
pagesize: 100
|
||||
maxpages: 10000
|
||||
retry: 2
|
||||
# 验证码识别服务,与 SYB 共用(#336 已批准)。
|
||||
ocrurl: https://ocr.ilapage.cn/ocr
|
||||
ocrmaxattempts: 5
|
||||
cache:
|
||||
# redis:
|
||||
# addr: 127.0.0.1:6379
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listYeekeReturnPackages(params, options = {}) {
|
||||
return request({ url: '/api/admin/v1/yeeke-returns', method: 'get', params, ...options })
|
||||
}
|
||||
|
||||
export function getYeekeReturnPackage(packageId) {
|
||||
return request({ url: `/api/admin/v1/yeeke-returns/${packageId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function listYeekeReturnShops(options = {}) {
|
||||
return request({ url: '/api/admin/v1/yeeke-returns/shops', method: 'get', ...options })
|
||||
}
|
||||
|
||||
export function getYeekeReturnItem(itemId) {
|
||||
return request({ url: `/api/admin/v1/yeeke-returns/items/${itemId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function listYeekeSyncRuns(params, options = {}) {
|
||||
return request({ url: '/api/admin/v1/yeeke-returns/sync-runs', method: 'get', params, ...options })
|
||||
}
|
||||
|
||||
export function getYeekeSyncRun(runId) {
|
||||
return request({ url: `/api/admin/v1/yeeke-returns/sync-runs/${runId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function triggerYeekeSync() {
|
||||
return request({ url: '/api/admin/v1/yeeke-returns/sync', method: 'post' })
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-alert v-if="summaryError" :title="summaryError" type="warning" show-icon :closable="false" class="notice" />
|
||||
<div v-else class="summary-bar" aria-live="polite">
|
||||
<span>最后成功同步:<strong>{{ formatTime(summary.lastSuccessAt) }}</strong></span>
|
||||
<span v-if="summary.lastRun">读取 {{ summary.lastRun.readCount }} / 新增 {{ summary.lastRun.createdCount }} / 更新 {{ summary.lastRun.updatedCount }} / 跳过 {{ summary.lastRun.skippedCount }} / 失败 {{ summary.lastRun.failedCount }}</span>
|
||||
<span v-if="summary.lastRun"><el-tag :type="statusMeta(summary.lastRun.status).type" size="small">{{ statusMeta(summary.lastRun.status).label }}</el-tag></span>
|
||||
</div>
|
||||
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderSn" clearable placeholder="订单号" style="width:150px" /></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-select v-model="query.shop" clearable filterable placeholder="全部店铺" style="width:150px">
|
||||
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="认领状态">
|
||||
<el-select v-model="query.claimStatus" clearable placeholder="全部" style="width:130px">
|
||||
<el-option v-for="item in claimStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
|
||||
<el-table v-loading="loading" :data="items" border stripe row-key="key" empty-text="暂无退货商品">
|
||||
<el-table-column label="订单号" prop="orderSn" min-width="130" />
|
||||
<el-table-column label="商品参考图" width="76">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.hasItem && row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported />
|
||||
<div v-else-if="row.hasItem" class="thumb placeholder">无图</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品标题" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.hasItem">{{ row.itemName || '—' }}</template>
|
||||
<span v-else class="muted">(无商品明细)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="蝦皮id / sku_id" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.hasItem">
|
||||
{{ row.shopeeItemId || '—' }}
|
||||
<div class="muted">{{ row.variationId || '—' }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="70">
|
||||
<template #default="{ row }">{{ row.hasItem ? row.quantity : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="规格数据" min-width="180"><template #default="{ row }">{{ row.variationName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="认领状态" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.statusUnrecognized" type="danger" effect="plain">未知:{{ row.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
|
||||
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
|
||||
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="detail.open" title="退货商品详情" size="720px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.placeholder && detail.pkg">
|
||||
<el-alert title="该包裹无商品明细" type="info" show-icon :closable="false" class="notice" />
|
||||
<h3 class="section-title">所属退货包裹</h3>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认领状态">
|
||||
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<template v-else-if="detail.item">
|
||||
<div class="item-hero">
|
||||
<el-image v-if="detail.item.image" :src="detail.item.image" fit="cover" class="thumb-lg" :preview-src-list="[detail.item.image]" preview-teleported />
|
||||
<div v-else class="thumb-lg placeholder">无图</div>
|
||||
<div class="item-hero-info">
|
||||
<div class="item-title">{{ detail.item.itemName || '—' }}</div>
|
||||
<div class="muted">{{ detail.item.variationName || '—' }}</div>
|
||||
<div class="muted">商品ID:{{ detail.item.itemId || '—' }} / 规格ID:{{ detail.item.variationId || '—' }}</div>
|
||||
<div class="muted">退货数量:{{ detail.item.quantity }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="section-title">所属退货包裹</h3>
|
||||
<el-descriptions v-if="detail.pkg" :column="2" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认领状态">
|
||||
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">同包裹其他商品</h3>
|
||||
<el-table :data="detail.siblings" border size="small" empty-text="无其他商品">
|
||||
<el-table-column label="标题/规格" min-width="220"><template #default="{ row }">{{ row.itemName || '—' }}<div class="muted">{{ row.variationName || '—' }}</div></template></el-table-column>
|
||||
<el-table-column label="数量" prop="quantity" width="80" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listYeekeReturnPackages, getYeekeReturnPackage, getYeekeReturnItem, listYeekeReturnShops, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoYeekeReturns',
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
|
||||
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
|
||||
shopOptions: [],
|
||||
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' },
|
||||
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
|
||||
// yeeke's only confirmed claim-status value is the raw string "1"
|
||||
// (HAR evidence, #336 requirement doc). Any other value the sync sees
|
||||
// is preserved verbatim and flagged status_unrecognized — never
|
||||
// guessed into a label here. "unknown" is a server-side pseudo-filter
|
||||
// matching status_unrecognized=true, not a real yeeke value.
|
||||
claimStatusOptions: [
|
||||
{ label: '已认领', value: '1' },
|
||||
{ label: '未知(原样展示)', value: 'unknown' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() { this.load(); this.loadSummary(); this.loadShops() },
|
||||
methods: {
|
||||
// "1" is yeeke's only confirmed known status; anything else reaching
|
||||
// this function (status_unrecognized=false from the server) is an
|
||||
// unmapped value we have not seen yet — show it as-is rather than a
|
||||
// bare digit or a guessed Chinese label.
|
||||
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
const params = { ...this.query }
|
||||
const r = await listYeekeReturnPackages(params, { suppressNetworkError: true })
|
||||
this.items = r.data.items
|
||||
this.total = r.data.total
|
||||
} catch (error) {
|
||||
this.loadError = error?.response?.data?.message || error?.message || '退货商品加载失败'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadShops() {
|
||||
try {
|
||||
const r = await listYeekeReturnShops({ suppressNetworkError: true, suppressErrorMessage: true })
|
||||
this.shopOptions = r.data.shops || []
|
||||
} catch (error) {
|
||||
// 店铺下拉加载失败时不阻塞列表,筛选项保持为空。
|
||||
this.shopOptions = []
|
||||
}
|
||||
},
|
||||
async loadSummary() {
|
||||
this.summaryError = ''
|
||||
try {
|
||||
const r = await listYeekeSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
|
||||
this.summary = { lastSuccessAt: r.data.lastSuccessAt, lastRun: r.data.items[0] || null }
|
||||
} catch (error) {
|
||||
// 同步失败时仍显示上次已加载的商品列表,只在摘要条提示。
|
||||
this.summaryError = '同步状态摘要加载失败,仍显示已有退货商品数据'
|
||||
}
|
||||
},
|
||||
search() {
|
||||
this.query.page = 1
|
||||
const [from, to] = this.createTimeRange || ['', '']
|
||||
this.query.createTimeFrom = from
|
||||
this.query.createTimeTo = to
|
||||
this.load()
|
||||
},
|
||||
reset() {
|
||||
this.createTimeRange = null
|
||||
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }
|
||||
this.load()
|
||||
},
|
||||
async openDetail(row) {
|
||||
this.detail = { open: true, loading: true, placeholder: !row.hasItem, item: null, pkg: null, siblings: [] }
|
||||
try {
|
||||
if (!row.hasItem) {
|
||||
const r = await getYeekeReturnPackage(row.packageId)
|
||||
this.detail.pkg = r.data.item
|
||||
} else {
|
||||
const r = await getYeekeReturnItem(row.itemId)
|
||||
this.detail.item = r.data.item
|
||||
this.detail.pkg = r.data.package
|
||||
this.detail.siblings = r.data.siblings || []
|
||||
}
|
||||
} finally {
|
||||
this.detail.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}
|
||||
.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
|
||||
.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}
|
||||
.page-heading p{margin:0;color:#606266;line-height:1.5}
|
||||
.heading-actions{display:flex;gap:8px;flex-shrink:0}
|
||||
.summary-bar{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:10px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc;color:#334155}
|
||||
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.notice{margin-bottom:16px}
|
||||
.drawer-body{padding:0 4px 24px}
|
||||
.section-title{margin:24px 0 12px}
|
||||
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}
|
||||
.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.thumb-lg{width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||
.thumb-lg.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:12px}
|
||||
.item-hero{display:flex;gap:16px}
|
||||
.item-hero-info{flex:1;min-width:0}
|
||||
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||
.muted{font-size:12px;color:#909399}
|
||||
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item label="触发方式">
|
||||
<el-select v-model="query.trigger" clearable placeholder="全部" style="width:120px">
|
||||
<el-option label="手动" value="manual" />
|
||||
<el-option label="定时" value="scheduled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" clearable placeholder="全部" style="width:140px">
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间"><el-date-picker v-model="startedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item class="sync-action">
|
||||
<el-button
|
||||
v-if="canSync"
|
||||
type="primary"
|
||||
:loading="manualSyncStarting"
|
||||
:disabled="manualSyncStarting"
|
||||
@click="startManualSync"
|
||||
>手动同步</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="loading" :data="items" border stripe height="100%" empty-text="暂无同步记录">
|
||||
<el-table-column label="运行ID" prop="id" width="90" />
|
||||
<el-table-column label="触发方式" width="100"><template #default="{ row }">{{ triggerLabel(row.trigger) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="开始时间" min-width="160"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
|
||||
<el-table-column label="结束时间" min-width="160"><template #default="{ row }">{{ formatTime(row.finishedAt) }}</template></el-table-column>
|
||||
<el-table-column label="页数" prop="totalPages" width="70" />
|
||||
<el-table-column label="读取" prop="readCount" width="70" />
|
||||
<el-table-column label="新增" prop="createdCount" width="70" />
|
||||
<el-table-column label="更新" prop="updatedCount" width="70" />
|
||||
<el-table-column label="跳过" prop="skippedCount" width="70" />
|
||||
<el-table-column label="失败" prop="failedCount" width="70" />
|
||||
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listYeekeSyncRuns, triggerYeekeSync } from '@/api/goauto/yeeke-returns'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoYeekeSyncRuns',
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, loadError: '', items: [], total: 0, startedRange: null, manualSyncStarting: false,
|
||||
query: { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' },
|
||||
statusOptions: [
|
||||
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
|
||||
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
triggerLabel(trigger) { return { manual: '手动', scheduled: '定时' }[trigger] || trigger || '—' },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
const r = await listYeekeSyncRuns(this.query, { suppressNetworkError: true })
|
||||
this.items = r.data.items
|
||||
this.total = r.data.total
|
||||
} catch (error) {
|
||||
this.loadError = error?.response?.data?.message || error?.message || '同步记录加载失败'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
search() {
|
||||
this.query.page = 1
|
||||
const [from, to] = this.startedRange || ['', '']
|
||||
this.query.startedFrom = from
|
||||
this.query.startedTo = to
|
||||
this.load()
|
||||
},
|
||||
reset() {
|
||||
this.startedRange = null
|
||||
this.query = { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' }
|
||||
this.load()
|
||||
},
|
||||
async startManualSync() {
|
||||
if (!this.canSync || this.manualSyncStarting || this.hasRunningSync) return
|
||||
this.manualSyncStarting = true
|
||||
try {
|
||||
await triggerYeekeSync()
|
||||
ElMessage.success('已开始同步')
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.message || error?.message || '同步启动失败')
|
||||
} finally {
|
||||
this.manualSyncStarting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// The card fills the viewport and the table takes all remaining height, so
|
||||
// the pagination sits at the bottom instead of floating mid-page.
|
||||
.page-card{height:calc(100vh - 124px);display:flex;flex-direction:column}
|
||||
.page-card :deep(.el-card__body){flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.search-form{display:flex;flex-wrap:wrap;align-items:center;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.sync-action{margin-left:auto}
|
||||
.notice{margin-bottom:16px}
|
||||
.table-wrap{flex:1;min-height:240px}
|
||||
</style>
|
||||
Reference in New Issue
Block a user