diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index a53f728..97cd3a2 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -56,11 +56,24 @@ func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.Fulfill } if orderNo != "" { - var assignedCount int64 - _ = p.rockService.db.WithContext(ctx).Model(&model.RockToken{}). - Where("assigned_order_no = ? AND status IN (?, ?)", orderNo, model.RockStatusUsing, model.RockStatusBound). - Count(&assignedCount).Error - if assignedCount > 0 { + var assigned model.RockToken + findErr := p.rockService.db.WithContext(ctx). + Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ + model.RockStatusUsing, + model.RockStatusBound, + model.RockStatusMismatch, + model.RockStatusAbnormal, + }). + Order("id DESC"). + First(&assigned).Error + if findErr == nil { + if assigned.Status == model.RockStatusAbnormal { + return &DeliveryOrderState{ + CanShip: false, + CannotShipReason: rockOrderTokenLockedMessage, + Provider: ProviderRock, + }, nil + } hasAssignedToken = true } } @@ -136,13 +149,31 @@ func (p *RockProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, qrURL = qrCodeURL(bindURL) } + isBound := token.Status == model.RockStatusBound + message := "请使用手机微信/QQ扫描上方二维码绑定游戏角色" + if isBound { + message = "账号绑定成功" + if token.ActualRoleName != "" { + patch["role_name"] = token.ActualRoleName + } + if token.ActualChannel != "" { + patch["game_channel"] = token.ActualChannel + } + if token.ActualRoleID != "" { + patch["actual_role_id"] = token.ActualRoleID + } + } + return &ProviderBindResult{ BindUUID: fmt.Sprintf("%d", token.ID), BindURL: bindURL, QRURL: qrURL, - Bound: false, + Bound: isBound, + RoleName: token.ActualRoleName, + GameAccount: token.ActualRoleID, + GameChannel: token.ActualChannel, ExpectedGameAccount: trimmedUID, - Message: "请使用手机微信/QQ扫描上方二维码绑定游戏角色", + Message: message, Provider: ProviderRock, PatchResultData: patch, }, nil diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index bc304f4..57bd466 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -236,6 +236,30 @@ func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) { t.Fatalf("mismatch reason 异常: %s", dbToken.MismatchReason) } + // 3b. 绑定失败后,即使池里还有库存、买家改了 UID,也不能再领新号。 + spare := model.RockToken{ + UpstreamTokenID: "151154", + Token: "spare_jwt_token", + RedeemCode: "spare_redeem_code", + Color: model.RockColorBlack, + Status: model.RockStatusAvailable, + ClaimedAt: &now, + } + db.Create(&spare) + _, err = provider.Bind(context.Background(), order, "1111111111") + if err == nil || !strings.Contains(err.Error(), "联系客服") { + t.Fatalf("mismatch 后必须拒绝更换提货号,实际: %v", err) + } + var spareAfter model.RockToken + db.First(&spareAfter, spare.ID) + if spareAfter.Status != model.RockStatusAvailable || spareAfter.AssignedOrderNo != "" { + t.Fatalf("拒绝换号后备用库存应保持可用,实际 status=%s order=%s", spareAfter.Status, spareAfter.AssignedOrderNo) + } + var assignedCount int64 + db.Model(&model.RockToken{}).Where("assigned_order_no = ?", order.OrderNo).Count(&assignedCount) + if assignedCount != 1 { + t.Fatalf("一个订单只能占用一个提货号,实际 %d", assignedCount) + } // 4. 客服确认买家输错,执行人工解冻重置 err = rockSvc.ResetMismatchToken(context.Background(), token.ID) if err != nil { @@ -673,3 +697,111 @@ func TestReleaseUnboundUsingToken(t *testing.T) { t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err) } } + +func TestRockProvider_SchemeB_UIDSelfHealing(t *testing.T) { + db := newRockTestDB(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "/api/redeem/") { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1001, + "bind_url": "https://igame.qq.com/activity/test?code=redeem_scheme_b", + "redeem_code": "redeem_scheme_b", + }) + return + } + if strings.Contains(r.URL.Path, "refresh") { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": map[string]interface{}{ + "id": 152001, + "status": "bound", + "bind_changed_at": "2026-10-06 10:00:00", + "bound_account": "少年有点忧郁", + "bound_account_id": "3454648872", + "region": "安卓-微信", + }, + "deliveryList": []interface{}{}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := NewRockClient(server.URL) + rockSvc := NewRockService(db, client) + provider := NewRockProvider(rockSvc) + ctx := context.Background() + + now := time.Now() + token := model.RockToken{ + UpstreamTokenID: "152001", + Token: "test_jwt_scheme_b", + RedeemCode: "redeem_scheme_b", + Color: model.RockColorBlack, + Status: model.RockStatusAvailable, + ClaimedAt: &now, + } + db.Create(&token) + + order := &model.FulfillmentOrder{ + OrderNo: "ROCK-ORDER-HEAL", + ProductSKU: model.RockSKUBlackTshirt, + } + db.Create(order) + + // 1. 买家一开始输错 UID 为 3658745636,触发分配 + bindRes1, err := provider.Bind(ctx, order, "3658745636") + if err != nil { + t.Fatalf("首次分配失败: %v", err) + } + if bindRes1.Bound { + t.Fatalf("尚未扫码,bound 应为 false") + } + + // 2. 扫码反查,真实角色是 3454648872,发生 mismatch + check1, err := provider.CheckBind(ctx, order, bindRes1.BindUUID) + if err != nil { + t.Fatalf("check bind failed: %v", err) + } + if !check1.Mismatch { + t.Fatalf("UID 不一致必须标记为 mismatch") + } + if !strings.Contains(check1.Message, "3454648872") { + t.Fatalf("提示信息应包含引导买家确认修改为 3454648872,实际: %s", check1.Message) + } + + // 3. 买家尝试换成第三个账号 9999999999,应坚决被拦截 + _, err = provider.Bind(ctx, order, "9999999999") + if err == nil || !strings.Contains(err.Error(), "3454648872") { + t.Fatalf("更换为非扫码角色应被拦截,实际 err: %v", err) + } + + // 4. 方案 B:买家确认更换为扫码角色 UID 3454648872 + bindRes2, err := provider.Bind(ctx, order, "3454648872") + if err != nil { + t.Fatalf("确认更换为扫码角色 UID 应自愈成功,实际失败: %v", err) + } + if !bindRes2.Bound { + t.Fatalf("自愈后 bound 应为 true") + } + if bindRes2.RoleName != "少年有点忧郁" || bindRes2.GameAccount != "3454648872" { + t.Fatalf("自愈后返回的角色信息不符: %v", bindRes2) + } + + // 5. 校验数据库状态与订单占号唯一性 + var dbToken model.RockToken + db.First(&dbToken, token.ID) + if dbToken.Status != model.RockStatusBound { + t.Fatalf("自愈后 token 状态应为 bound,当前: %s", dbToken.Status) + } + if dbToken.ExpectedUID != "3454648872" { + t.Fatalf("自愈后 expectedUID 应为 3454648872,当前: %s", dbToken.ExpectedUID) + } + var count int64 + db.Model(&model.RockToken{}).Where("assigned_order_no = ?", order.OrderNo).Count(&count) + if count != 1 { + t.Fatalf("整个流程该订单必须严格只占用 1 个 Token,实际占用: %d", count) + } +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index b8fa367..ebc76f9 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -323,7 +323,12 @@ type BindingSnapshot struct { BindChangedAt string `json:"bind_changed_at"` } -// AssignTokenForOrder 为指定订单锁定分配一个可用 Token +// rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。 +const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理" + +// AssignTokenForOrder 为指定订单锁定分配一个可用 Token。 +// 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID; +// mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。 func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) { cfg, err := s.GetConfig(ctx) if err != nil { @@ -335,9 +340,16 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected var token model.RockToken - // 1. 检查订单是否已分配过 Token + // 1. 该订单已经占用过提货号时,禁止再领新号。 err = s.db.WithContext(ctx). - Where("assigned_order_no = ? AND status IN (?, ?)", orderNo, model.RockStatusUsing, model.RockStatusBound). + Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ + model.RockStatusUsing, + model.RockStatusBound, + model.RockStatusMismatch, + model.RockStatusRedeemed, + model.RockStatusAbnormal, + }). + Order("id DESC"). First(&token).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -345,10 +357,50 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected } if err == nil { - trimmedUID := strings.TrimSpace(expectedUID) - if trimmedUID != "" && token.ExpectedUID != trimmedUID { - token.ExpectedUID = trimmedUID - _ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error + switch token.Status { + case model.RockStatusAbnormal: + return nil, nil, errors.New(rockOrderTokenLockedMessage) + case model.RockStatusRedeemed: + return nil, nil, errors.New("该订单已发货,不能再次领取提货号") + case model.RockStatusMismatch: + trimmedUID := strings.TrimSpace(expectedUID) + actualUID := strings.TrimSpace(token.ActualRoleID) + // 方案 B:买家确认纠正为当前扫码角色的 UID + if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID { + now := time.Now() + token.ExpectedUID = trimmedUID + token.Status = model.RockStatusBound + token.MismatchReason = "" + token.MismatchAt = nil + token.BoundAt = &now + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "expected_uid": trimmedUID, + "status": model.RockStatusBound, + "mismatch_reason": "", + "mismatch_at": nil, + "bound_at": now, + "updated_at": now, + }).Error + log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) + } else { + roleDesc := token.ActualRoleName + if roleDesc == "" { + roleDesc = actualUID + } + if actualUID != "" { + return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID) + } + return nil, nil, errors.New(rockOrderTokenLockedMessage) + } + default: + trimmedUID := strings.TrimSpace(expectedUID) + if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID { + return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理") + } + if trimmedUID != "" && token.ExpectedUID != trimmedUID { + token.ExpectedUID = trimmedUID + _ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error + } } } @@ -405,14 +457,14 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected } type CheckBindingResult struct { - Bound bool `json:"bound"` - RoleName string `json:"role_name"` - RoleID string `json:"role_id"` - Channel string `json:"channel"` - ExpectedUID string `json:"expected_uid"` - Mismatch bool `json:"mismatch"` - Message string `json:"message"` - UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"` + Bound bool `json:"bound"` + RoleName string `json:"role_name"` + RoleID string `json:"role_id"` + Channel string `json:"channel"` + ExpectedUID string `json:"expected_uid"` + Mismatch bool `json:"mismatch"` + Message string `json:"message"` + UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"` } // CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁 @@ -427,8 +479,44 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint return nil, err } - // 若已经是 mismatch 状态,直接返回阻断提示 + // 若已经是 mismatch 状态,检查传入的 expectedUID 是否已纠正为扫码角色的 UID if token.Status == model.RockStatusMismatch { + trimmedExpected := strings.TrimSpace(expectedUID) + if trimmedExpected == "" { + trimmedExpected = strings.TrimSpace(token.ExpectedUID) + } + actualUID := strings.TrimSpace(token.ActualRoleID) + if trimmedExpected != "" && actualUID != "" && trimmedExpected == actualUID { + now := time.Now() + token.Status = model.RockStatusBound + token.ExpectedUID = trimmedExpected + token.MismatchReason = "" + token.MismatchAt = nil + token.BoundAt = &now + _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ + "expected_uid": trimmedExpected, + "status": model.RockStatusBound, + "mismatch_reason": "", + "mismatch_at": nil, + "bound_at": now, + "updated_at": now, + }).Error + return &CheckBindingResult{ + Bound: true, + RoleName: token.ActualRoleName, + RoleID: actualUID, + Channel: token.ActualChannel, + ExpectedUID: trimmedExpected, + Mismatch: false, + Message: "扫码授权成功,UID核验一致", + }, nil + } + + roleDesc := token.ActualRoleName + if roleDesc == "" { + roleDesc = actualUID + } + hintMsg := fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualUID, trimmedExpected, actualUID) return &CheckBindingResult{ Bound: false, Mismatch: true, @@ -436,7 +524,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint RoleID: token.ActualRoleID, Channel: token.ActualChannel, ExpectedUID: token.ExpectedUID, - Message: fmt.Sprintf("UID 不匹配阻断: %s", token.MismatchReason), + Message: hintMsg, }, nil } @@ -537,6 +625,10 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint "mismatch_reason": mismatchReason, }).Error + roleDesc := actualRoleName + if roleDesc == "" { + roleDesc = actualRoleID + } return &CheckBindingResult{ Bound: false, Mismatch: true, @@ -544,7 +636,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint RoleID: actualRoleID, Channel: actualChannel, ExpectedUID: trimmedExpected, - Message: fmt.Sprintf("扫码角色UID(%s)与订单填写UID(%s)不一致,严禁发货!", actualRoleID, trimmedExpected), + Message: fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualRoleID, trimmedExpected, actualRoleID), }, nil }