feat: 释放回库存的 Token 优先分配
This commit is contained in:
@@ -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);
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user