Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0b86092a6 | ||
|
|
5faf870d0f | ||
|
|
6773c88a64 | ||
|
|
248eae01ee | ||
|
|
ac5d8336ec | ||
|
|
8ba3470728 | ||
|
|
b1629b900a | ||
|
|
c33e83823a | ||
|
|
faac8bb155 | ||
|
|
e845651933 | ||
|
|
3bc4784402 | ||
|
|
ab292d6a17 |
@@ -17,6 +17,7 @@ const (
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleYeekeReturns = "yeeke_returns"
|
||||
ModuleYeekeSyncRuns = "yeeke_sync_runs"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -69,6 +70,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
ModuleCollectionTasks,
|
||||
ModulePurchaseTasks,
|
||||
ModuleYeekeReturns,
|
||||
ModuleYeekeSyncRuns,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,7 +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: "time", Sort: 63, PurchaserDefault: 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
|
||||
@@ -164,6 +167,10 @@ 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:
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 14 {
|
||||
t.Fatalf("got %d modules, want 14", 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 != 63 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..63", 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, ModuleYeekeReturns},
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
|
||||
@@ -124,9 +124,13 @@ 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/sync-runs", "GET", true},
|
||||
{"查看 yeeke 退货同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 退货同步", "/api/admin/v1/yeeke-returns/sync", "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},
|
||||
|
||||
@@ -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) != 14 {
|
||||
if len(s.Modules) != 15 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
@@ -74,9 +76,12 @@ func toDTO(r models.YeekeSyncRun) SyncRunDTO {
|
||||
return dto
|
||||
}
|
||||
|
||||
// ListSyncRuns returns the most recent sync runs, newest first. Visible to
|
||||
// admin and purchaser alike (#336 requirement #7); it is mounted without
|
||||
// Casbin role gating, mirroring sybimport's /sync-runs.
|
||||
// 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 {
|
||||
@@ -90,13 +95,38 @@ func (h Handler) ListSyncRuns(c *gin.Context) {
|
||||
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 := db.Model(&models.YeekeSyncRun{}).Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
var rows []models.YeekeSyncRun
|
||||
if err := db.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
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
|
||||
}
|
||||
@@ -167,3 +197,13 @@ func (h Handler) TriggerSync(c *gin.Context) {
|
||||
}
|
||||
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,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)
|
||||
}
|
||||
@@ -17,6 +17,12 @@ 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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
@@ -183,6 +184,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
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
|
||||
@@ -221,6 +223,9 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
created, updated, err := s.upsert(ctx, x)
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
@@ -240,6 +245,14 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -263,39 +276,67 @@ func pageFingerprint(p yeekeclient.ReturnPage) string {
|
||||
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
key := packageKey(p)
|
||||
var row models.YeekeReturnPackage
|
||||
e := s.db.WithContext(ctx).Where("external_id = ?", key).First(&row).Error
|
||||
isNew := e == gorm.ErrRecordNotFound
|
||||
if e != nil && !isNew {
|
||||
return false, false, e
|
||||
}
|
||||
status := external(p.Status)
|
||||
vals := map[string]any{"external_id": key, "order_sn": p.Ordersn, "tracking_no": p.TrackingNo, "shop_id": external(p.ShopID), "shop_name": p.ShopName, "ware_code": p.WareCode, "ware_house": p.WareHouse, "ware_name": p.WareName, "claim_status": status, "status_unrecognized": !knownClaimStatuses[status], "claim_time": stamp(p.ClaimTime), "create_time": stamp(p.CreateTime), "update_time": stamp(p.UpdateTime), "destroy_dead_line": stamp(p.DestroyDeadLine), "last_synced_at": now, "sync_status": "ok"}
|
||||
if isNew {
|
||||
row = models.YeekeReturnPackage{ExternalID: key}
|
||||
if e = s.db.WithContext(ctx).Create(&row).Error; e != nil {
|
||||
return false, false, e
|
||||
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
|
||||
}
|
||||
}
|
||||
if e = s.db.WithContext(ctx).Model(&row).Updates(vals).Error; e != nil {
|
||||
return false, false, e
|
||||
}
|
||||
for n, i := range p.Items {
|
||||
ik := itemKey(p, i, n)
|
||||
ir := models.YeekeReturnItem{}
|
||||
ie := s.db.WithContext(ctx).Where("package_id = ? AND external_key = ?", row.ID, ik).First(&ir).Error
|
||||
if ie != nil && ie != gorm.ErrRecordNotFound {
|
||||
return false, false, ie
|
||||
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",
|
||||
}
|
||||
iv := map[string]any{"package_id": row.ID, "external_key": ik, "item_id": external(i.ItemID), "variation_id": external(i.VariationID), "item_name": i.ItemName, "variation_name": i.VariationName, "image": i.Image, "quantity": i.Quantity, "last_synced_at": now, "sync_status": "ok"}
|
||||
if ie == gorm.ErrRecordNotFound {
|
||||
if e = s.db.WithContext(ctx).Create(&models.YeekeReturnItem{PackageID: row.ID, ExternalKey: ik}).Error; e != nil {
|
||||
return false, false, e
|
||||
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
|
||||
}
|
||||
}
|
||||
if e = s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("package_id = ? AND external_key = ?", row.ID, ik).Updates(iv).Error; e != nil {
|
||||
return false, false, 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
|
||||
}
|
||||
|
||||
@@ -728,3 +728,52 @@ func TestItemKeyDistinctVariationsOfSameItemID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -103,13 +104,16 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur
|
||||
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 != "" {
|
||||
q := req.URL.Query()
|
||||
q.Set("token", c.token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
req.Header.Set("X-Access-Token", c.token)
|
||||
}
|
||||
resp, e := c.http.Do(req)
|
||||
if e != nil {
|
||||
@@ -136,25 +140,69 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur
|
||||
return nil, e
|
||||
}
|
||||
if !env.Success {
|
||||
if env.Code == 401 || strings.Contains(env.Message, "登录") || strings.Contains(env.Message, "token") {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("yeeke request failed code=%d", env.Code)
|
||||
return 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) {
|
||||
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, nil)
|
||||
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:]
|
||||
@@ -163,7 +211,7 @@ func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &Captcha{Image: img, CheckKey: p.CheckKey, ContentType: "image/jpeg"}, nil
|
||||
return &Captcha{Image: img, CheckKey: key, ContentType: "image/jpeg"}, nil
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
@@ -184,10 +232,11 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s
|
||||
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, e
|
||||
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
|
||||
}
|
||||
code, e := ocr.Recognize(ctx, cap.Image)
|
||||
if e != nil {
|
||||
@@ -195,20 +244,33 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
emptyOCR++
|
||||
continue
|
||||
}
|
||||
if out, e := c.Login(ctx, username, password, code, cap.CheckKey); e == nil {
|
||||
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")
|
||||
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]string{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt"}, nil)
|
||||
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
|
||||
}
|
||||
@@ -229,7 +291,10 @@ func (c *Client) Login(ctx context.Context, username, password, captcha, checkKe
|
||||
return &LoginResult{Token: p.Token, UserID: fmt.Sprint(p.UserInfo.ID), Username: p.UserInfo.Username, ExpiresAt: time.Now().UTC().Add(24 * time.Hour)}, nil
|
||||
}
|
||||
func (c *Client) CheckSession(ctx context.Context) error {
|
||||
_, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/userInfo", nil, nil)
|
||||
// /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
|
||||
}
|
||||
|
||||
@@ -246,7 +311,9 @@ func (t *Timestamp) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if x, e := time.ParseInLocation(f, s, time.UTC); e == nil {
|
||||
// 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
|
||||
}
|
||||
@@ -271,17 +338,41 @@ type ReturnPackage struct {
|
||||
Items []ReturnItem `json:"items"`
|
||||
}
|
||||
type ReturnItem struct {
|
||||
ID any `json:"id"`
|
||||
ItemID any `json:"itemId"`
|
||||
VariationID any `json:"variationId"`
|
||||
ItemName string `json:"itemName"`
|
||||
VariationName string `json:"variationName"`
|
||||
Image string `json:"image"`
|
||||
Quantity int64 `json:"variationQuantityPurchased"`
|
||||
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) {
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": 1, "status": 1, "relationFlag": 1, "orderBy": "createTime", "order": "desc"}
|
||||
// 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
|
||||
|
||||
@@ -3,9 +3,12 @@ package yeekeclient
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
|
||||
@@ -13,12 +16,20 @@ func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
|
||||
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"}})
|
||||
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.URL.Query().Get("token") != "opaque" {
|
||||
t.Errorf("token missing")
|
||||
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:
|
||||
@@ -38,3 +49,212 @@ func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +47,18 @@ func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid fu
|
||||
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"}})
|
||||
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]string
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if loginOK(body["captcha"]) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + body["captcha"], "userInfo": map[string]any{"id": "u1", "username": "u"}}})
|
||||
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/sys/userInfo":
|
||||
token := r.URL.Query().Get("token")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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