feat(rock): 实现方案B智能自愈,支持买家手滑填错UID时确认为扫码角色UID自愈绑定
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user