feat(rock): 未扫码绑定的使用中链接可释放回库存

历史残留绑定不再占住提货链接,退款后可以把还没扫上的库存分给下一单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-05 22:13:17 +08:00
co-authored by Cursor
parent 4c68103217
commit 717392b195
7 changed files with 426 additions and 15 deletions
+14
View File
@@ -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": "已释放回可用库存,该提货链接可以再次分配"})
}
+1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
@@ -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)
}
}
+205 -13
View File
@@ -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
}
+1
View File
@@ -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),
}
+28 -1
View File
@@ -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() {
</Popconfirm>
)
}
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
return (
<Popconfirm
title="释放这条未绑定的提货链接?"
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
okText="确认释放"
cancelText="取消"
onConfirm={() => handleReleaseToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />}>
释放回库存
</Button>
</Popconfirm>
)
}
return <Text type="secondary">-</Text>
},
},