diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go
index 892a354..5ba2fe0 100644
--- a/backend/internal/handler/rock.go
+++ b/backend/internal/handler/rock.go
@@ -123,3 +123,17 @@ func (h *RockHandler) ResetToken(c *gin.Context) {
}
response.OK(c, gin.H{"message": "重置成功,Token 已重新释放至可用库存"})
}
+
+// ReleaseToken 把尚未扫码绑定的使用中链接释放回可用库存
+func (h *RockHandler) ReleaseToken(c *gin.Context) {
+ idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ response.BadRequest(c, "无效的 Token ID")
+ return
+ }
+ if err := h.rockSvc.ReleaseUnboundUsingToken(c.Request.Context(), uint(idUint)); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 1ca9ab5..51d7842 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -316,6 +316,7 @@ func Setup(h *Handlers) *gin.Engine {
admin.POST("/platform/rock/purchase", h.Rock.PurchaseTokens)
admin.GET("/platform/rock/tokens", h.Rock.ListTokens)
admin.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
+ admin.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
}
}
diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go
index 13f46ac..a53f728 100644
--- a/backend/internal/service/provider_rock.go
+++ b/backend/internal/service/provider_rock.go
@@ -166,7 +166,7 @@ func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOr
expectedUID = resultDataString(order.ResultData, "game_account")
}
- res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), expectedUID)
+ res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), order.OrderNo, expectedUID)
if err != nil {
return nil, err
}
diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go
index 9f93bff..bc304f4 100644
--- a/backend/internal/service/provider_rock_test.go
+++ b/backend/internal/service/provider_rock_test.go
@@ -497,3 +497,179 @@ func TestRockService_StatsAndPurchase(t *testing.T) {
t.Fatalf("期望按商户单号搜索出 1 条,实际为 total=%d len=%d", searchTotal, len(searchList))
}
}
+
+func TestReleaseUnboundUsingToken(t *testing.T) {
+ db := newRockTestDB(t)
+
+ var boundRoleID string
+ var bindChangedAt string
+ var deliveryTime string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if strings.HasSuffix(r.URL.Path, "/refresh") {
+ var resp RockRefreshResponse
+ if boundRoleID != "" {
+ resp.Token.Status = "bound"
+ resp.Token.BoundAccount = "已扫码角色"
+ resp.Token.BoundAccountID = boundRoleID
+ resp.Token.BindChangedAt = bindChangedAt
+ }
+ if deliveryTime != "" {
+ resp.DeliveryList = []RockDeliveryItem{{
+ Time: deliveryTime,
+ Role: "iOS-微信-已扫码角色",
+ Message: "成功发放",
+ }}
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ client := NewRockClient(server.URL)
+ rockSvc := NewRockService(db, client)
+ provider := NewRockProvider(rockSvc)
+ bearer := "mock_bearer_token"
+ if _, err := rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil {
+ t.Fatalf("update config: %v", err)
+ }
+
+ snap := BindingSnapshot{
+ Bound: true,
+ RoleName: "历史残留",
+ RoleID: "111",
+ BindChangedAt: "2026-10-01 10:00:00",
+ }
+ snapBytes, _ := json.Marshal(snap)
+ token := model.RockToken{
+ UpstreamTokenID: "88001",
+ Token: "jwt",
+ RedeemCode: "redeem-unused",
+ Color: model.RockColorBlack,
+ Status: model.RockStatusUsing,
+ AssignedOrderNo: "FO-RELEASE-1",
+ ExpectedUID: "3688825401",
+ InitialBoundSnapshot: string(snapBytes),
+ }
+ if err := db.Create(&token).Error; err != nil {
+ t.Fatalf("create token: %v", err)
+ }
+ if err := db.Create(&model.FulfillmentOrder{
+ OrderNo: "FO-RELEASE-1",
+ ProductSKU: model.RockSKUBlackTshirt,
+ ProductName: "黑色ROCK长袖T恤",
+ ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d,"rock_redeem_code":"redeem-unused","expected_uid":"3688825401"}`, token.ID),
+ }).Error; err != nil {
+ t.Fatalf("create order: %v", err)
+ }
+
+ // 历史残留与快照一致,视为买家还没扫,允许释放。
+ boundRoleID = "111"
+ bindChangedAt = "2026-10-01 10:00:00"
+ if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), token.ID); err != nil {
+ t.Fatalf("release ghost snapshot: %v", err)
+ }
+ var released model.RockToken
+ if err := db.First(&released, token.ID).Error; err != nil {
+ t.Fatalf("reload token: %v", err)
+ }
+ if released.Status != model.RockStatusAvailable || released.AssignedOrderNo != "" || released.ExpectedUID != "" {
+ t.Fatalf("释放后应回到可用库存,实际 status=%s order=%s uid=%s", released.Status, released.AssignedOrderNo, released.ExpectedUID)
+ }
+ var order model.FulfillmentOrder
+ if err := db.Where("order_no = ?", "FO-RELEASE-1").First(&order).Error; err != nil {
+ t.Fatalf("reload order: %v", err)
+ }
+ if resultDataNumber(order.ResultData, "rock_token_id") != 0 || !strings.Contains(order.ResultData, "rock_token_released") {
+ t.Fatalf("原订单仍指向已释放链接: %s", order.ResultData)
+ }
+
+ // 旧发货页继续轮询时,不能把已释放的链接再改成绑定。
+ boundRoleID = "3688825401"
+ bindChangedAt = "2026-10-05 21:00:00"
+ check, err := provider.CheckBind(context.Background(), &order, fmt.Sprintf("%d", token.ID))
+ if err != nil {
+ t.Fatalf("check bind after release: %v", err)
+ }
+ if check.Bound {
+ t.Fatalf("已释放链接不应再判定为绑定成功")
+ }
+ if err := db.First(&released, token.ID).Error; err != nil {
+ t.Fatalf("reload after check: %v", err)
+ }
+ if released.Status != model.RockStatusAvailable {
+ t.Fatalf("旧页面轮询后状态被改写为 %s", released.Status)
+ }
+
+ // 上游已经出现新扫码时拒绝释放。
+ using := model.RockToken{
+ UpstreamTokenID: "88002",
+ Token: "jwt2",
+ RedeemCode: "redeem-bound",
+ Color: model.RockColorBlack,
+ Status: model.RockStatusUsing,
+ AssignedOrderNo: "FO-RELEASE-2",
+ ExpectedUID: "3688825401",
+ }
+ if err := db.Create(&using).Error; err != nil {
+ t.Fatalf("create using token: %v", err)
+ }
+ boundRoleID = "3688825401"
+ bindChangedAt = "2026-10-05 21:10:00"
+ if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
+ t.Fatalf("已绑定链接必须拒绝释放,实际: %v", err)
+ }
+ var stillUsing model.RockToken
+ if err := db.First(&stillUsing, using.ID).Error; err != nil {
+ t.Fatalf("reload using: %v", err)
+ }
+ if stillUsing.Status != model.RockStatusUsing || stillUsing.AssignedOrderNo != "FO-RELEASE-2" {
+ t.Fatalf("拒绝释放后应保持使用中,实际 status=%s order=%s", stillUsing.Status, stillUsing.AssignedOrderNo)
+ }
+
+ // 上游 status=bound,但发货记录早于本次领取、绑定时间仍为空:历史残留,可以再次使用。
+ claimedAt := time.Date(2026, 10, 5, 20, 32, 17, 0, time.Local)
+ stale := model.RockToken{
+ UpstreamTokenID: "152415",
+ Token: "jwt3",
+ RedeemCode: "dbece05764c701a3be3bb5c689926d07",
+ Color: model.RockColorBlack,
+ Status: model.RockStatusUsing,
+ AssignedOrderNo: "FO-RELEASE-3",
+ ExpectedUID: "3364503067",
+ ClaimedAt: &claimedAt,
+ InitialBoundSnapshot: `{"bound":true,"role_name":"予心纯白","role_id":"1900505843","channel":"iOS-微信","bind_changed_at":""}`,
+ }
+ if err := db.Create(&stale).Error; err != nil {
+ t.Fatalf("create stale token: %v", err)
+ }
+ boundRoleID = "1900505843"
+ bindChangedAt = ""
+ deliveryTime = "2026-09-27T14:38:51.23"
+ if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), stale.ID); err != nil {
+ t.Fatalf("历史残留应允许释放: %v", err)
+ }
+
+ // 领取之后出现新的发货记录,不能释放。
+ fresh := model.RockToken{
+ UpstreamTokenID: "152420",
+ Token: "jwt4",
+ RedeemCode: "redeem-fresh-delivery",
+ Color: model.RockColorBlack,
+ Status: model.RockStatusUsing,
+ AssignedOrderNo: "FO-RELEASE-4",
+ ExpectedUID: "4744920014",
+ ClaimedAt: &claimedAt,
+ }
+ if err := db.Create(&fresh).Error; err != nil {
+ t.Fatalf("create fresh token: %v", err)
+ }
+ boundRoleID = "4744920014"
+ bindChangedAt = "2026-10-05 21:52:30"
+ deliveryTime = "2026-10-05T21:52:40.61"
+ if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
+ t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err)
+ }
+}
diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go
index 5763252..b8fa367 100644
--- a/backend/internal/service/rock_service.go
+++ b/backend/internal/service/rock_service.go
@@ -416,7 +416,7 @@ type CheckBindingResult struct {
}
// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁
-func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, expectedUID string) (*CheckBindingResult, error) {
+func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, orderNo, expectedUID string) (*CheckBindingResult, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
@@ -440,6 +440,20 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
}, nil
}
+ // 已释放回库存、或已改派给其他订单的链接,不能再被旧发货页轮询改写。
+ if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
+ return &CheckBindingResult{
+ Bound: false,
+ Message: "该提货链接已释放回库存,请重新获取二维码",
+ }, nil
+ }
+ if strings.TrimSpace(orderNo) != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != strings.TrimSpace(orderNo) {
+ return &CheckBindingResult{
+ Bound: false,
+ Message: "该提货链接已不属于当前订单",
+ }, nil
+ }
+
// 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控)
var ref *RockRefreshResponse
cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID)
@@ -496,18 +510,12 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
- // 2. Anti-Ghost 历史残留比对
- if token.InitialBoundSnapshot != "" {
- var snap BindingSnapshot
- if jsonErr := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); jsonErr == nil {
- // 若当前绑定的角色、UID 与初始分配时完全一样且变更时间没有更新,说明买家还未扫码,仍是历史旧数据
- if snap.Bound && snap.RoleID == actualRoleID && snap.BindChangedAt == ref.Token.BindChangedAt {
- return &CheckBindingResult{
- Bound: false,
- Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
- }, nil
- }
- }
+ // 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。
+ if !rockBuyerNewlyBound(&token, ref) {
+ return &CheckBindingResult{
+ Bound: false,
+ Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
+ }, nil
}
// 3. 核心门禁:UID 强一致校验
@@ -727,3 +735,187 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
"updated_at": time.Now(),
}).Error
}
+
+// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。
+// 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。
+// 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。
+func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool {
+ if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" {
+ return false
+ }
+ snap, hasSnap := bindingSnapshot(token)
+ if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) {
+ return false
+ }
+ if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) {
+ return true
+ }
+ if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() {
+ return true
+ }
+ if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) {
+ return false
+ }
+ if hasSnap {
+ return false
+ }
+ return true
+}
+
+func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
+ if ref == nil || len(ref.DeliveryList) == 0 {
+ return false
+ }
+ return !deliveryAfterClaim(token, ref)
+}
+
+func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) {
+ if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" {
+ return BindingSnapshot{}, false
+ }
+ var snap BindingSnapshot
+ if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil {
+ return BindingSnapshot{}, false
+ }
+ snap.RoleID = strings.TrimSpace(snap.RoleID)
+ snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt)
+ return snap, true
+}
+
+func rockClaimBaseline(token *model.RockToken) time.Time {
+ if token == nil {
+ return time.Time{}
+ }
+ if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() {
+ return token.ClaimedAt.UTC()
+ }
+ return token.CreatedAt.UTC()
+}
+
+func parseRockTime(raw string) (time.Time, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, false
+ }
+ layouts := []string{
+ time.RFC3339Nano,
+ "2006-01-02T15:04:05.999999999",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ }
+ for _, layout := range layouts {
+ if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
+ return t.UTC(), true
+ }
+ }
+ return time.Time{}, false
+}
+
+func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool {
+ changed, ok := parseRockTime(changedAt)
+ if !ok {
+ return false
+ }
+ baseline := rockClaimBaseline(token)
+ if baseline.IsZero() {
+ return true
+ }
+ return !changed.Before(baseline.Add(-time.Minute))
+}
+
+func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool {
+ if ref == nil {
+ return false
+ }
+ baseline := rockClaimBaseline(token)
+ if baseline.IsZero() {
+ return false
+ }
+ for _, item := range ref.DeliveryList {
+ deliveredAt, ok := parseRockTime(item.Time)
+ if !ok {
+ continue
+ }
+ if !deliveredAt.Before(baseline.Add(-time.Minute)) {
+ return true
+ }
+ }
+ return false
+}
+
+// ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。
+// 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。
+func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint) error {
+ var token model.RockToken
+ if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
+ return err
+ }
+ if token.Status != model.RockStatusUsing {
+ return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存")
+ }
+ if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" {
+ return fmt.Errorf("该链接已扫码绑定,不能释放回库存")
+ }
+
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" {
+ return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定")
+ }
+
+ ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
+ if err != nil {
+ return fmt.Errorf("确认上游绑定状态失败: %w", err)
+ }
+ if rockBuyerNewlyBound(&token, ref) {
+ return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID())
+ }
+
+ s.refreshCache.Delete(token.UpstreamTokenID)
+ orderNo := token.AssignedOrderNo
+ if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
+ "status": model.RockStatusAvailable,
+ "assigned_order_no": "",
+ "expected_uid": "",
+ "actual_role_name": "",
+ "actual_role_id": "",
+ "actual_channel": "",
+ "initial_bound_snapshot": "",
+ "mismatch_at": nil,
+ "mismatch_reason": "",
+ "bound_at": nil,
+ "updated_at": time.Now(),
+ }).Error; err != nil {
+ return err
+ }
+
+ s.detachReleasedRockToken(ctx, orderNo, token.ID)
+ log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo)
+ return nil
+}
+
+// detachReleasedRockToken 清掉原订单上指向这条链接的记录,避免旧发货页继续占用它。
+func (s *RockService) detachReleasedRockToken(ctx context.Context, orderNo string, tokenID uint) {
+ orderNo = strings.TrimSpace(orderNo)
+ if orderNo == "" || tokenID == 0 {
+ return
+ }
+ var order model.FulfillmentOrder
+ if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
+ return
+ }
+ if resultDataNumber(order.ResultData, "rock_token_id") != int64(tokenID) {
+ return
+ }
+ raw := resultDataMap(order.ResultData)
+ delete(raw, "rock_token_id")
+ delete(raw, "rock_redeem_code")
+ raw["rock_token_released"] = true
+ encoded, err := json.Marshal(raw)
+ if err != nil {
+ return
+ }
+ _ = s.db.WithContext(ctx).Model(&order).Update("result_data", string(encoded)).Error
+}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index d8a4ffe..1df179b 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -327,6 +327,7 @@ export const rockApi = {
listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) =>
request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }),
resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
+ releaseToken: (id: number) => request.post(`/platform/rock/tokens/${id}/release`).then((r) => r.data.data),
}
diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx
index d76a79d..25254e1 100644
--- a/frontend/src/pages/PlatformRock.tsx
+++ b/frontend/src/pages/PlatformRock.tsx
@@ -218,6 +218,18 @@ export default function PlatformRock() {
}
}
+ // 买家未扫码绑定时,把占用中的提货链接放回库存
+ const handleReleaseToken = async (id: number) => {
+ try {
+ await rockApi.releaseToken(id)
+ message.success('已释放回可用库存,该提货链接可以再次分配')
+ loadData()
+ loadTokens()
+ } catch (e) {
+ message.error('释放失败: ' + (e instanceof Error ? e.message : '未知错误'))
+ }
+ }
+
// 复制/生成发货凭证图片
const handleCopyVoucher = async (record: RockTokenItem) => {
setCopyingVoucherId(record.id)
@@ -524,7 +536,7 @@ export default function PlatformRock() {
{
title: '操作',
key: 'action',
- width: 120,
+ width: 140,
fixed: 'right',
render: (_, record) => {
if (record.status === 'mismatch') {
@@ -542,6 +554,21 @@ export default function PlatformRock() {
)
}
+ if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
+ return (
+ handleReleaseToken(record.id)}
+ >
+ }>
+ 释放回库存
+
+
+ )
+ }
return -
},
},