feat(#67): add Shopee order snapshots to purchase tasks

This commit is contained in:
QiuSW
2026-08-22 10:41:05 +08:00
parent 0ceb248233
commit 9490bddf8f
17 changed files with 173 additions and 30 deletions
@@ -67,6 +67,9 @@ func TestPurchaseContractTablesAreMigrated(t *testing.T) {
t.Fatalf("missing table %s", table)
}
}
if !db.Migrator().HasColumn(&models.PurchaseTask{}, "ShopeeOrderNoSnapshot") {
t.Fatal("purchase_task missing shopee_order_no_snapshot")
}
}
func TestPurchaseSYBForeignKeyDoesNotUseMySQLIncompatibleCheck(t *testing.T) {
+1
View File
@@ -89,6 +89,7 @@ type PurchaseTask struct {
AccountRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_account,priority:2"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot" gorm:"size:64;not null"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot" gorm:"size:64;not null;default:'';index"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot" gorm:"size:500;not null;default:''"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot" gorm:"size:255;not null;default:''"`
PDDURLSnapshot string `json:"pddUrlSnapshot" gorm:"type:text;not null"`
+2 -1
View File
@@ -352,7 +352,8 @@ type SYBSession struct {
// available sample data, so a global-uniqueness assumption is unverified.
type SYBProduct struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
// OrderCode is the shipment order's `code` (e.g. 260728TB95MJTQ).
// OrderCode is SYB's source `code` and represents the Shopee order number
// shown to procurement users (e.g. 260728TB95MJTQ).
OrderCode string `json:"orderCode" gorm:"size:64;not null;uniqueIndex:ux_syb_product_order_detail,priority:1"`
// DetailID is `details[].id` from SYB, e.g. 145306175.
DetailID uint64 `json:"detailId" gorm:"not null;uniqueIndex:ux_syb_product_order_detail,priority:2"`
+6 -1
View File
@@ -18,6 +18,7 @@ type AdminListRequest struct {
Status string
ExecutionMode string
SYBProductID uint64
ShopeeOrderNo string
PDDOrderNo string
}
@@ -31,6 +32,7 @@ type AdminTaskItem struct {
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
@@ -128,6 +130,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.SYBProductID > 0 {
query = query.Where("syb_product_id = ?", req.SYBProductID)
}
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
}
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
}
@@ -205,7 +210,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr
item := AdminTaskItem{
ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status,
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot,
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
@@ -71,6 +71,27 @@ func TestAdminListAndDetailExposeSafePurchaseViews(t *testing.T) {
}
}
func TestAdminListFiltersByShopeeOrderNumberSnapshot(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatalf("create live task: %v", err)
}
list, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "YB-"})
if err != nil {
t.Fatalf("admin list: %v", err)
}
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != task.ID || list.Items[0].ShopeeOrderNoSnapshot != f.syb.OrderCode {
t.Fatalf("unexpected order query result: %+v", list)
}
empty, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "NOT-FOUND"})
if err != nil || empty.Total != 0 {
t.Fatalf("unexpected empty result: %+v %v", empty, err)
}
}
func TestAdminQueryValidationAndNotFound(t *testing.T) {
s := testService(testDB(t))
if _, err := s.AdminList(context.Background(), AdminListRequest{Status: "unknown"}); code(err) != CodeInvalidRequest {
+2 -1
View File
@@ -51,7 +51,8 @@ func (h Handler) AdminList(c *gin.Context) {
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
})
if err != nil {
writeError(c, err)
+6 -5
View File
@@ -105,11 +105,12 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR
task, replayed, createErr := s.Create(ctx, CreateRequest{
RequestID: candidate.requestID, ExecutionMode: models.PurchaseExecutionModeLive,
SYBProductID: &sybID, DeviceID: candidate.source.DeviceID,
ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent,
MinUnitPriceCent: candidate.preview.MinUnitPriceCent,
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
Currency: candidate.preview.Currency,
RuleSnapshot: purchasecontract.DefaultLiveRule(),
shopeeOrderNoSnapshotOverride: candidate.source.ShopeeOrderNoSnapshot,
ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent,
MinUnitPriceCent: candidate.preview.MinUnitPriceCent,
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
Currency: candidate.preview.Currency,
RuleSnapshot: purchasecontract.DefaultLiveRule(),
})
item := response.Items[candidate.resultIndex]
if createErr != nil {
+6
View File
@@ -42,6 +42,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil {
t.Fatal(err)
}
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
first, err := service.BatchRetry(context.Background(), request)
@@ -64,6 +67,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID {
t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask)
}
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
}
replay, err := service.BatchRetry(context.Background(), request)
if err != nil {
+5 -1
View File
@@ -191,8 +191,12 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
return fail(CodeInvalidRequest, "数量、价格区间或币种无效")
}
required, _ := json.Marshal(purchasecontract.RequiredCapabilities(rule))
shopeeOrderNoSnapshot := syb.OrderCode
if req.shopeeOrderNoSnapshotOverride != "" {
shopeeOrderNoSnapshot = req.shopeeOrderNoSnapshotOverride
}
out = models.PurchaseTask{SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
@@ -113,6 +113,18 @@ func TestRehearsalCannotContainOrderActions(t *testing.T) {
}
}
func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task, err := createLive(t, testService(db), f)
if err != nil {
t.Fatalf("create live task: %v", err)
}
if task.ShopeeOrderNoSnapshot != f.syb.OrderCode {
t.Fatalf("order number snapshot=%q want %q", task.ShopeeOrderNoSnapshot, f.syb.OrderCode)
}
}
func TestCreateRehearsalPreservesParameterizedRuleSnapshot(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
+4
View File
@@ -59,6 +59,10 @@ type CreateRequest struct {
MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"`
Currency string `json:"currency,omitempty"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
// shopeeOrderNoSnapshotOverride is server-only. Batch retry uses the
// immutable source-task value instead of accepting a client-supplied
// identity snapshot.
shopeeOrderNoSnapshotOverride string
}
type ActionRequest struct {
@@ -0,0 +1,43 @@
package version_local
import (
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This additive #67 migration records the Shopee order number that explains
// each purchase task. Existing tasks are backfilled from their immutable SYB
// product relation when it is still available; unresolved legacy rows stay
// empty instead of receiving a fabricated identifier.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseShopeeOrderSnapshot)
}
func migratePurchaseShopeeOrderSnapshot(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := backfillPurchaseShopeeOrderSnapshot(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func backfillPurchaseShopeeOrderSnapshot(db *gorm.DB) error {
return db.Exec(`UPDATE purchase_task
SET shopee_order_no_snapshot = COALESCE((
SELECT syb_product.order_code
FROM syb_product
WHERE syb_product.id = purchase_task.syb_product_id
), '')
WHERE shopee_order_no_snapshot = ''
AND syb_product_id IS NOT NULL`).Error
}
@@ -0,0 +1,38 @@
package version_local
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBackfillPurchaseShopeeOrderSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:purchase-order-snapshot?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE syb_product (id INTEGER PRIMARY KEY, order_code TEXT NOT NULL)`,
`CREATE TABLE purchase_task (id INTEGER PRIMARY KEY, syb_product_id INTEGER, shopee_order_no_snapshot TEXT NOT NULL DEFAULT '')`,
`INSERT INTO syb_product (id, order_code) VALUES (1, '260728TB95MJTQ')`,
`INSERT INTO purchase_task (id, syb_product_id, shopee_order_no_snapshot) VALUES (1, 1, ''), (2, NULL, ''), (3, 1, 'IMMUTABLE')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = backfillPurchaseShopeeOrderSnapshot(db); err != nil {
t.Fatal(err)
}
var rows []struct {
ID uint64
Snapshot string `gorm:"column:shopee_order_no_snapshot"`
}
if err = db.Table("purchase_task").Select("id, shopee_order_no_snapshot").Order("id").Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 3 || rows[0].Snapshot != "260728TB95MJTQ" || rows[1].Snapshot != "" || rows[2].Snapshot != "IMMUTABLE" {
t.Fatalf("unexpected backfill rows: %+v", rows)
}
}