From 717392b195b5082994c54a0722d6614bdc64e5eb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 5 Oct 2026 22:13:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(rock):=20=E6=9C=AA=E6=89=AB=E7=A0=81?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=9A=84=E4=BD=BF=E7=94=A8=E4=B8=AD=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=8F=AF=E9=87=8A=E6=94=BE=E5=9B=9E=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史残留绑定不再占住提货链接,退款后可以把还没扫上的库存分给下一单。 Co-authored-by: Cursor --- backend/internal/handler/rock.go | 14 ++ backend/internal/router/router.go | 1 + backend/internal/service/provider_rock.go | 2 +- .../internal/service/provider_rock_test.go | 176 ++++++++++++++ backend/internal/service/rock_service.go | 218 ++++++++++++++++-- frontend/src/api/index.ts | 1 + frontend/src/pages/PlatformRock.tsx | 29 ++- 7 files changed, 426 insertions(+), 15 deletions(-) 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 - }, },