feat: 释放回库存的 Token 优先分配

This commit is contained in:
2026-11-09 18:30:28 +08:00
parent 08884270b7
commit eba3af2054
9 changed files with 361 additions and 3 deletions
@@ -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);
+2 -1
View File
@@ -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)
+3
View File
@@ -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)
+1
View File
@@ -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
+4
View File
@@ -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
}
+3 -1
View File
@@ -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)