package service import ( "context" "errors" "fmt" "net/http" "strings" "time" "affiliate_dash/internal/model" "gorm.io/gorm" ) // TokenBindingRefreshResult 是人工“刷新绑定状态”的返回结果。 type TokenBindingRefreshResult struct { Status string `json:"status"` Bound bool `json:"bound"` RoleName string `json:"role_name"` RoleID string `json:"role_id"` Channel string `json:"channel"` Changed bool `json:"changed"` Warning bool `json:"warning"` Message string `json:"message"` RedeemedAt string `json:"redeemed_at,omitempty"` } func canRefreshRockBinding(status string) bool { switch status { case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch: return true default: return false } } // RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。 // // 规则(只同步本轮真实事件,并与本地预期 UID 交叉校验): // - 上游状态异常(abnormal)→ 本地同步 abnormal(已上报失效); // - 上游明确返回「Token 不存在」(404) → 本地同步 abnormal(已上报失效); // - 本轮 redeemed_at / deliveryList → 本地同步 redeemed; // - 本轮 bind_changed_at → 本地同步 bound/mismatch; // - 只有历史残留事件 → 不推进本轮状态,错误的 bound 修复回 using。 func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) { return s.refreshTokenBinding(ctx, tokenID, nil) } // RefreshTokenBindingBy 与 RefreshTokenBinding 相同,额外记录操作人。 func (s *RockService) RefreshTokenBindingBy(ctx context.Context, tokenID uint, actorUserID *uint) (*TokenBindingRefreshResult, error) { return s.refreshTokenBinding(ctx, tokenID, actorUserID) } func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, actorUserID *uint) (*TokenBindingRefreshResult, error) { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err } if !canRefreshRockBinding(token.Status) { return nil, errors.New("仅扫码使用中 / 已授权绑定 / 绑定错误的 Token 可以刷新绑定状态") } if strings.TrimSpace(token.RedeemCode) == "" { return nil, errors.New("该 Token 缺少兑换码,无法刷新绑定状态") } cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } ref, err := s.client.RefreshRedeemBinding(ctx, token.RedeemCode) upstreamNotFound := false if err != nil { // 公开端接口不可用时,降级到管理端 refresh(需要 upstream_token_id + Bearer)。 var statusErr *RockUpstreamStatusError if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) && strings.TrimSpace(token.UpstreamTokenID) != "" { ref, err = s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) } if err != nil { // 只有上游明确返回「Token 不存在」才判定卡密已删除;超时/500/网关 404 等仍按失败处理。 if isRockTokenNotFoundError(err) { upstreamNotFound = true ref = nil } else { return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err) } } } if !upstreamNotFound && ref == nil { return nil, errors.New("上游未返回刷新结果") } // 上游请求期间本地状态可能已被买家轮询改写,落库前重新读取。 if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err } if !canRefreshRockBinding(token.Status) { return nil, fmt.Errorf("Token 状态已变为 %s,无法再刷新绑定状态", token.Status) } if upstreamNotFound { return s.markTokenAbnormalByRefresh(ctx, &token, "上游卡密不存在(刷新自动同步)", "上游未找到该 Token,已标记为已上报失效", actorUserID) } now := time.Now() upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status)) if upstreamStatus == model.RockStatusAbnormal { return s.markTokenAbnormalByRefresh(ctx, &token, "上游状态异常(刷新自动同步)", "上游状态异常,已标记为已上报失效", actorUserID) } originalStatus := token.Status originalRoleName := token.ActualRoleName originalRoleID := token.ActualRoleID originalChannel := token.ActualChannel actualRoleName := ref.GetRoleName() actualRoleID := ref.GetRoleID() actualChannel := ref.GetChannel() currentBound, bindChangedAt := rockCurrentCycleBinding(&token, ref) updates := map[string]interface{}{ "updated_at": now, } // 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。 if currentBound { if actualRoleName != "" { updates["actual_role_name"] = actualRoleName } if actualRoleID != "" { updates["actual_role_id"] = actualRoleID } if actualChannel != "" { updates["actual_channel"] = actualChannel } } result := &TokenBindingRefreshResult{ Status: token.Status, Bound: currentBound, } if currentBound { result.RoleName = actualRoleName result.RoleID = actualRoleID result.Channel = actualChannel } deliveryEvidence := rockCurrentCycleDelivery(&token, ref) baselineUnknown := rockClaimBaseline(&token).IsZero() matchesInitialSnapshot := rockBindingMatchesInitialSnapshot(&token, ref) switch { case deliveryEvidence.Ambiguous: result.Warning = true result.Message = "上游显示已发货,但无法确认是否属于本轮,禁止释放,请人工核对" case deliveryEvidence.Current: redeemedAt := deliveryEvidence.At if redeemedAt.IsZero() { redeemedAt = now } updates["status"] = model.RockStatusRedeemed updates["redeemed_at"] = redeemedAt if deliveryEvidence.FulfillID > 0 { updates["upstream_fulfill_id"] = deliveryEvidence.FulfillID } result.Status = model.RockStatusRedeemed result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05") result.Message = "上游已发货,本地状态已同步为已交付" case baselineUnknown && !matchesInitialSnapshot && (ref.HasBindingSignal() || strings.TrimSpace(rockBindingChangedAt(ref)) != ""): result.Warning = true result.Message = "缺少本轮 using_at,无法判断该绑定是否属于本轮,请人工核对" case currentBound: if rockTokenRedeemPending(&token) { updates["redeem_checked_at"] = now if msg := strings.TrimSpace(ref.Message); msg != "" { updates["redeem_last_message"] = msg } result.Status = model.RockStatusBound result.Message = "上游仍未确认发货,保持发货确认中" break } updates["bound_at"] = bindChangedAt expectedUID := strings.TrimSpace(token.ExpectedUID) if expectedUID != "" && actualRoleID != "" && expectedUID != actualRoleID { reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID) updates["status"] = model.RockStatusMismatch updates["mismatch_at"] = now updates["mismatch_reason"] = reason result.Status = model.RockStatusMismatch result.Warning = true result.Message = reason break } updates["status"] = model.RockStatusBound updates["mismatch_at"] = nil updates["mismatch_reason"] = "" result.Status = model.RockStatusBound if actualRoleID == "" { result.Warning = true result.Message = "上游已绑定,但未返回角色 UID,已同步为已绑定,请人工核对" } else { result.Message = fmt.Sprintf( "上游已绑定「%s」(UID %s),已同步为已绑定", displayRockRole(actualRoleName, actualRoleID), actualRoleID, ) } default: if rockTokenRedeemPending(&token) { updates["redeem_checked_at"] = now result.Status = model.RockStatusBound result.Message = "上游仍未确认发货,保持发货确认中" } else if token.Status == model.RockStatusBound { updates["status"] = model.RockStatusUsing updates["bound_at"] = nil updates["actual_role_name"] = "" updates["actual_role_id"] = "" updates["actual_channel"] = "" updates["mismatch_at"] = nil updates["mismatch_reason"] = "" result.Status = model.RockStatusUsing result.Warning = true result.Message = "未检测到本轮真实绑定事件,已修复为使用中" } else { result.Message = "上游仍未绑定,本地状态保持不变" } } result.Changed = result.Status != originalStatus || (currentBound && actualRoleName != "" && actualRoleName != originalRoleName) || (currentBound && actualRoleID != "" && actualRoleID != originalRoleID) || (currentBound && actualChannel != "" && actualChannel != originalChannel) if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { updateResult := tx.Model(&model.RockToken{}). Where("id = ? AND updated_at = ?", token.ID, token.UpdatedAt). Updates(updates) if updateResult.Error != nil { return updateResult.Error } if updateResult.RowsAffected == 0 { return errors.New("Token 状态已被并发更新,请刷新后重试") } if err := tx.First(&token, token.ID).Error; err != nil { return err } writeRockTokenStatusChange(tx, &token, originalStatus, result.Status, rockTokenSourceRefresh, result.Message, false, actorUserID) return writeAudit(tx, nil, actorUserID, nil, "rock_token.refresh_binding", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ "status": result.Status, "role_id": result.RoleID, "message": result.Message, }) }); err != nil { return nil, err } return result, nil } func displayRockRole(name, roleID string) string { if strings.TrimSpace(name) != "" { return strings.TrimSpace(name) } return strings.TrimSpace(roleID) } // isRockTokenNotFoundError 判断上游是否明确返回「Token 不存在」。 // // 必须同时满足 HTTP 404 且响应体包含 Token 不存在的语义, // 避免把网关、路由缺失等普通 404 误判成卡密被删除。 func isRockTokenNotFoundError(err error) bool { var statusErr *RockUpstreamStatusError if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound { return false } body := strings.ToLower(strings.TrimSpace(statusErr.Body)) if strings.Contains(body, "不存在") { return true } if !strings.Contains(body, "token") { return false } return strings.Contains(body, "not found") || strings.Contains(body, "not exist") || strings.Contains(body, "does not exist") } // markTokenAbnormalByRefresh 把刷新时确认已失效的 Token 标记为「已上报失效」。 // // 备注策略:原备注为空时直接写入;已有备注时用换行追加系统原因,保留人工内容, // 且相同原因不会重复追加。 // 已交付(status=redeemed 或 redeemed_at 非空)的 Token 不会被降级。 func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *model.RockToken, remark, message string, actorUserID *uint) (*TokenBindingRefreshResult, error) { if token == nil { return nil, errors.New("Token 不存在") } if token.Status == model.RockStatusRedeemed || token.RedeemedAt != nil { return nil, errors.New("该 Token 已有发货记录,不能标记为已上报失效") } fromStatus := token.Status updates := map[string]interface{}{ "status": model.RockStatusAbnormal, "updated_at": time.Now(), } existingRemark := strings.TrimSpace(token.Remark) switch { case existingRemark == "": updates["remark"] = remark token.Remark = remark case !strings.Contains(existingRemark, remark): updates["remark"] = existingRemark + "\n" + remark token.Remark = existingRemark + "\n" + remark } if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(token).Updates(updates).Error; err != nil { return err } writeRockTokenStatusChange(tx, token, fromStatus, model.RockStatusAbnormal, rockTokenSourceRefresh, message, false, actorUserID) return writeAudit(tx, nil, actorUserID, nil, "rock_token.refresh_binding", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ "status": model.RockStatusAbnormal, "message": message, }) }); err != nil { return nil, err } s.refreshCache.Delete(token.UpstreamTokenID) return &TokenBindingRefreshResult{ Status: model.RockStatusAbnormal, Changed: true, Warning: true, Message: message, }, nil }