diff --git a/backend/internal/database/migrations/025_rock_token_restock_priority.sql b/backend/internal/database/migrations/025_rock_token_restock_priority.sql new file mode 100644 index 0000000..0a0f904 --- /dev/null +++ b/backend/internal/database/migrations/025_rock_token_restock_priority.sql @@ -0,0 +1,6 @@ +-- 025: Rock token restock priority +-- restocked_at 非空表示该 Token 是通过「释放回库存」回到 available 的,分配时优先出库(最早回库优先)。 +ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS restocked_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_rock_tokens_available_restock + ON rock_tokens (status, restocked_at); diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index 8b60ad8..a92a774 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -100,7 +100,8 @@ type RockToken struct { RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"` Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"` ClaimedAt *time.Time `json:"claimed_at"` - UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点 + UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点 + RestockedAt *time.Time `gorm:"index" json:"restocked_at,omitempty"` // 人工释放回库存的时间,分配时优先(FIFO) BoundAt *time.Time `json:"bound_at"` RedeemedAt *time.Time `json:"redeemed_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index 0a37c19..fa1b95e 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -366,6 +366,7 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder "using_at": nil, "redeemed_at": nil, "upstream_fulfill_id": 0, + "restocked_at": nil, // 订单取消回滚不属于「释放回库存」,不产生优先权 }).Error; updateErr == nil { writeRockTokenStatusChange(p.rockService.db.WithContext(ctx), &token, fromStatus, model.RockStatusAvailable, rockTokenSourceOrderCancel, order.OrderNo, false, nil) diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go index 3a42f5a..d61c97d 100644 --- a/backend/internal/service/rock_assign.go +++ b/backend/internal/service/rock_assign.go @@ -114,6 +114,8 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). + // 人工释放回库存的 Token 优先出库(最早回库优先),其余按入库先后。 + Order("restocked_at ASC NULLS LAST"). Order("id ASC"). First(&token).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -133,6 +135,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected token.MismatchAt = nil token.MismatchReason = "" token.InitialBoundSnapshot = "" + token.RestockedAt = nil // 释放带来的优先权在分配时消耗掉 token.Status = model.RockStatusUsing token.AssignedOrderNo = orderNo token.ExpectedUID = strings.TrimSpace(expectedUID) diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go index 9810a07..9c77749 100644 --- a/backend/internal/service/rock_package.go +++ b/backend/internal/service/rock_package.go @@ -126,6 +126,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka existing.Status != model.RockStatusAvailable if canRefreshUpstream { updates["status"] = status + updates["restocked_at"] = nil // 上游包同步回库不属于「释放回库存」,不产生优先权 if status == model.RockStatusUsing && existing.UsingAt == nil { if claimedAt != nil { updates["using_at"] = claimedAt diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go index 6de2098..6a0b8c1 100644 --- a/backend/internal/service/rock_recover.go +++ b/backend/internal/service/rock_recover.go @@ -128,6 +128,7 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint, acto "using_at": nil, "redeemed_at": nil, "upstream_fulfill_id": 0, + "restocked_at": nil, // 解冻重置不属于「释放回库存」,不产生优先权 "updated_at": time.Now(), }).Error; err != nil { return err @@ -163,6 +164,9 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch: updates["status"] = trimmedStatus token.Status = trimmedStatus + if trimmedStatus != oldStatus { + updates["restocked_at"] = nil // 人工改状态不属于「释放回库存」,不产生优先权 + } if trimmedStatus == model.RockStatusAvailable { updates["using_at"] = nil } diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go index fc0c8a6..15494c1 100644 --- a/backend/internal/service/rock_redeem.go +++ b/backend/internal/service/rock_redeem.go @@ -224,6 +224,7 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint return fmt.Errorf("仅扫码使用中的链接可以释放回库存") } orderNo = token.AssignedOrderNo + now := time.Now() if err := tx.Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusAvailable, "assigned_order_no": "", @@ -238,7 +239,8 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint "using_at": nil, "redeemed_at": nil, "upstream_fulfill_id": 0, - "updated_at": time.Now(), + "restocked_at": now, // 人工释放回库存:下次分配优先使用 + "updated_at": now, }).Error; err != nil { return err } diff --git a/backend/internal/service/rock_restock_priority_test.go b/backend/internal/service/rock_restock_priority_test.go new file mode 100644 index 0000000..e4ac4ef --- /dev/null +++ b/backend/internal/service/rock_restock_priority_test.go @@ -0,0 +1,339 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +// newRockRestockMockServer 模拟上游:刷新返回空对象(视为未绑定),提货详情返回固定绑定链接。 +func newRockRestockMockServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/api/redeem/"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 151153, + "redeem_code": "CODE-RESTOCK", + "bind_url": "https://gp.qq.com/cp/mock_bind", + }) + case strings.HasSuffix(r.URL.Path, "/refresh"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +// mustCreateRestockToken 创建一条测试 Token,状态与上游编号可指定。 +func mustCreateRestockToken(t *testing.T, db *gorm.DB, upstreamID, redeemCode, status string) model.RockToken { + t.Helper() + token := model.RockToken{ + UpstreamTokenID: upstreamID, + Token: "jwt-" + upstreamID, + RedeemCode: redeemCode, + Color: model.RockColorBlack, + Status: status, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建测试 Token 失败: %v", err) + } + return token +} + +// TestReleaseUnboundUsingToken_NextAssignPrioritizesRestocked 验证人工释放回库存的 Token +// 会写入 restocked_at,并在下一次分配时优先于更早入库的普通 Token。 +func TestReleaseUnboundUsingToken_NextAssignPrioritizesRestocked(t *testing.T) { + db := newRockTestDB(t) + server := newRockRestockMockServer(t) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + ctx := context.Background() + + // 先入库存的普通 Token(id 更小),正常情况下会先被分配。 + mustCreateRestockToken(t, db, "restock-normal", "CODE-RESTOCK-NORMAL", model.RockStatusAvailable) + + usingAt := time.Now().Add(-time.Hour) + releasedToken := model.RockToken{ + UpstreamTokenID: "restock-released", + Token: "jwt-restock-released", + RedeemCode: "CODE-RESTOCK-RELEASED", + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: "ORDER-RESTOCK-OLD", + ExpectedUID: "11111111", + UsingAt: &usingAt, + } + if err := db.Create(&releasedToken).Error; err != nil { + t.Fatalf("创建使用中 Token 失败: %v", err) + } + + if err := svc.ReleaseUnboundUsingToken(ctx, releasedToken.ID, false, nil); err != nil { + t.Fatalf("释放回库存失败: %v", err) + } + + var released model.RockToken + if err := db.First(&released, releasedToken.ID).Error; err != nil { + t.Fatalf("读取释放后的 Token 失败: %v", err) + } + if released.Status != model.RockStatusAvailable { + t.Fatalf("释放后应为 available,实际 %s", released.Status) + } + if released.RestockedAt == nil { + t.Fatalf("人工释放回库存必须写入 restocked_at") + } + + assigned, _, err := svc.AssignTokenForOrder(ctx, "ORDER-RESTOCK-NEW", "22222222") + if err != nil { + t.Fatalf("分配 Token 失败: %v", err) + } + if assigned.ID != releasedToken.ID { + t.Fatalf("应优先分配人工释放回库存的 Token(id=%d),实际选中 id=%d", releasedToken.ID, assigned.ID) + } + if assigned.RestockedAt != nil { + t.Fatalf("分配后必须清空 restocked_at,实际 %v", assigned.RestockedAt) + } + + var stored model.RockToken + if err := db.First(&stored, assigned.ID).Error; err != nil { + t.Fatalf("读取分配结果失败: %v", err) + } + if stored.Status != model.RockStatusUsing || stored.RestockedAt != nil { + t.Fatalf("数据库分配结果不符: status=%s restocked_at=%v", stored.Status, stored.RestockedAt) + } +} + +// TestReleaseUnboundUsingToken_ForceAlsoMarksRestocked 验证强制释放同样产生优先权。 +func TestReleaseUnboundUsingToken_ForceAlsoMarksRestocked(t *testing.T) { + db := newRockTestDB(t) + server := newBoundReleaseServer(t) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + ctx := context.Background() + + usingAt := time.Now().Add(-30 * time.Minute) + token := model.RockToken{ + UpstreamTokenID: "restock-force", + Token: "jwt-restock-force", + RedeemCode: "CODE-RESTOCK-FORCE", + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: "ORDER-RESTOCK-FORCE", + ExpectedUID: "33333333", + UsingAt: &usingAt, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建使用中 Token 失败: %v", err) + } + + if err := svc.ReleaseUnboundUsingToken(ctx, token.ID, true, nil); err != nil { + t.Fatalf("强制释放失败: %v", err) + } + + var released model.RockToken + if err := db.First(&released, token.ID).Error; err != nil { + t.Fatalf("读取释放后的 Token 失败: %v", err) + } + if released.Status != model.RockStatusAvailable || released.RestockedAt == nil { + t.Fatalf("强制释放后应回到 available 且写入 restocked_at,实际 status=%s restocked_at=%v", + released.Status, released.RestockedAt) + } +} + +// TestAssignTokenForOrder_RestockedTokensFollowFIFO 验证多个释放 Token 按最早回库优先排序。 +func TestAssignTokenForOrder_RestockedTokensFollowFIFO(t *testing.T) { + db := newRockTestDB(t) + server := newRockRestockMockServer(t) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + ctx := context.Background() + + // 普通库存 id 最小,但没有任何优先权,应排到最后。 + plain := mustCreateRestockToken(t, db, "fifo-plain", "CODE-FIFO-PLAIN", model.RockStatusAvailable) + later := mustCreateRestockToken(t, db, "fifo-later", "CODE-FIFO-LATER", model.RockStatusAvailable) + earlier := mustCreateRestockToken(t, db, "fifo-earlier", "CODE-FIFO-EARLIER", model.RockStatusAvailable) + + if err := db.Model(&model.RockToken{}).Where("id = ?", later.ID). + Update("restocked_at", time.Now().Add(-time.Hour)).Error; err != nil { + t.Fatalf("写入较晚回库时间失败: %v", err) + } + if err := db.Model(&model.RockToken{}).Where("id = ?", earlier.ID). + Update("restocked_at", time.Now().Add(-2*time.Hour)).Error; err != nil { + t.Fatalf("写入较早回库时间失败: %v", err) + } + + wantOrder := []uint{earlier.ID, later.ID, plain.ID} + for i, wantID := range wantOrder { + orderNo := fmt.Sprintf("ORDER-FIFO-%d", i) + assigned, _, err := svc.AssignTokenForOrder(ctx, orderNo, fmt.Sprintf("9000000%d", i)) + if err != nil { + t.Fatalf("第 %d 次分配失败: %v", i+1, err) + } + if assigned.ID != wantID { + t.Fatalf("第 %d 次分配应选中 id=%d,实际 id=%d", i+1, wantID, assigned.ID) + } + } +} + +// TestResetMismatchToken_ClearsRestockPriority 验证解冻重置不产生优先权。 +func TestResetMismatchToken_ClearsRestockPriority(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + stale := time.Now().Add(-time.Hour) + token := model.RockToken{ + UpstreamTokenID: "reset-restock", + Token: "jwt-reset-restock", + RedeemCode: "CODE-RESET-RESTOCK", + Color: model.RockColorBlack, + Status: model.RockStatusMismatch, + AssignedOrderNo: "ORDER-RESET-RESTOCK", + MismatchAt: &stale, + MismatchReason: "历史不一致", + RestockedAt: &stale, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 mismatch Token 失败: %v", err) + } + + if err := svc.ResetMismatchToken(ctx, token.ID, nil); err != nil { + t.Fatalf("解冻重置失败: %v", err) + } + + var reset model.RockToken + if err := db.First(&reset, token.ID).Error; err != nil { + t.Fatalf("读取重置结果失败: %v", err) + } + if reset.Status != model.RockStatusAvailable { + t.Fatalf("重置后应为 available,实际 %s", reset.Status) + } + if reset.RestockedAt != nil { + t.Fatalf("解冻重置不应产生优先权,restocked_at=%v", reset.RestockedAt) + } +} + +// TestUpdateTokenRemark_ManualAvailableClearsRestockPriority 验证人工备注改回可用不产生优先权。 +func TestUpdateTokenRemark_ManualAvailableClearsRestockPriority(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + stale := time.Now().Add(-time.Hour) + token := model.RockToken{ + UpstreamTokenID: "remark-restock", + Token: "jwt-remark-restock", + RedeemCode: "CODE-REMARK-RESTOCK", + Color: model.RockColorBlack, + Status: model.RockStatusAbnormal, + RestockedAt: &stale, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 abnormal Token 失败: %v", err) + } + + if _, err := svc.UpdateTokenRemark(ctx, token.ID, "人工改回可用", model.RockStatusAvailable, nil); err != nil { + t.Fatalf("人工改回可用失败: %v", err) + } + + var updated model.RockToken + if err := db.First(&updated, token.ID).Error; err != nil { + t.Fatalf("读取更新结果失败: %v", err) + } + if updated.Status != model.RockStatusAvailable { + t.Fatalf("人工更新后应为 available,实际 %s", updated.Status) + } + if updated.RestockedAt != nil { + t.Fatalf("人工改回可用不应产生优先权,restocked_at=%v", updated.RestockedAt) + } +} + +// TestRockProviderCancel_ClearsRestockPriority 验证订单取消回滚不产生优先权。 +func TestRockProviderCancel_ClearsRestockPriority(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + provider := NewRockProvider(svc) + ctx := context.Background() + + stale := time.Now().Add(-time.Hour) + token := model.RockToken{ + UpstreamTokenID: "cancel-restock", + Token: "jwt-cancel-restock", + RedeemCode: "CODE-CANCEL-RESTOCK", + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: "ORDER-CANCEL-RESTOCK", + UsingAt: &stale, + RestockedAt: &stale, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建使用中 Token 失败: %v", err) + } + + if err := provider.Cancel(ctx, &model.FulfillmentOrder{OrderNo: "ORDER-CANCEL-RESTOCK"}); err != nil { + t.Fatalf("取消订单回滚失败: %v", err) + } + + var canceled model.RockToken + if err := db.First(&canceled, token.ID).Error; err != nil { + t.Fatalf("读取取消结果失败: %v", err) + } + if canceled.Status != model.RockStatusAvailable { + t.Fatalf("取消后应为 available,实际 %s", canceled.Status) + } + if canceled.RestockedAt != nil { + t.Fatalf("订单取消回滚不应产生优先权,restocked_at=%v", canceled.RestockedAt) + } +} + +// TestRestoreRockTokens_ClearsRestockPriority 验证回收站恢复不产生优先权。 +func TestRestoreRockTokens_ClearsRestockPriority(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + stale := time.Now().Add(-time.Hour) + token := mustCreateRestockToken(t, db, "restore-restock", "CODE-RESTORE-RESTOCK", model.RockStatusAvailable) + if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID). + Update("restocked_at", stale).Error; err != nil { + t.Fatalf("写入历史优先权失败: %v", err) + } + if err := db.Delete(&token).Error; err != nil { + t.Fatalf("软删除 Token 失败: %v", err) + } + + result, err := svc.RestoreRockTokens(ctx, []uint{token.ID}, 42) + if err != nil { + t.Fatalf("恢复回收站 Token 失败: %v", err) + } + if result.Restored != 1 { + t.Fatalf("期望恢复 1 条,实际 %d 条(跳过 %+v)", result.Restored, result.SkippedItems) + } + + var restored model.RockToken + if err := db.First(&restored, token.ID).Error; err != nil { + t.Fatalf("读取恢复结果失败: %v", err) + } + if restored.DeletedAt.Valid { + t.Fatalf("恢复后 deleted_at 必须为空") + } + if restored.RestockedAt != nil { + t.Fatalf("回收站恢复不应产生优先权,restocked_at=%v", restored.RestockedAt) + } +} diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go index 0e063ae..8831e33 100644 --- a/backend/internal/service/rock_token_restore.go +++ b/backend/internal/service/rock_token_restore.go @@ -202,7 +202,8 @@ func (s *RockService) RestoreRockTokens(ctx context.Context, ids []uint, actorUs } if err := tx.Unscoped().Model(&model.RockToken{}). Where("id IN ?", restoreIDs). - Updates(map[string]interface{}{"deleted_at": nil}).Error; err != nil { + // 回收站恢复不属于「释放回库存」,同时清掉历史优先标记。 + Updates(map[string]interface{}{"deleted_at": nil, "restocked_at": nil}).Error; err != nil { return err } result.Restored = len(restorable)