feat(rock): 实现方案B智能自愈,支持买家手滑填错UID时确认为扫码角色UID自愈绑定

This commit is contained in:
yml2213
2026-10-06 11:04:44 +08:00
parent 717392b195
commit 3741f5ae73
3 changed files with 280 additions and 25 deletions
+38 -7
View File
@@ -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
@@ -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)
}
}
+110 -18
View File
@@ -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
}