Files
affiliate_dash/backend/internal/service/rock_binding_refresh.go
T

230 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"affiliate_dash/internal/model"
)
// 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,人工同步一次绑定状态。
//
// 规则:
// - 上游已发货 → 本地同步 redeemed;
// - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch;
// - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变;
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID 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)
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 {
return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err)
}
}
if 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)
}
now := time.Now()
originalStatus := token.Status
originalRoleName := token.ActualRoleName
originalRoleID := token.ActualRoleID
originalChannel := token.ActualChannel
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
expectedUID := strings.TrimSpace(token.ExpectedUID)
updates := map[string]interface{}{
"updated_at": now,
}
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
if ref.IsBound() {
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: ref.IsBound(),
}
if ref.IsBound() {
result.RoleName = actualRoleName
result.RoleID = actualRoleID
result.Channel = actualChannel
}
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt)
switch {
case upstreamStatus == model.RockStatusRedeemed || redeemedAtRaw != "":
redeemedAt, ok := parseRockTime(redeemedAtRaw)
if !ok {
redeemedAt = now
}
updates["status"] = model.RockStatusRedeemed
updates["redeemed_at"] = redeemedAt
if len(ref.DeliveryList) > 0 {
if fulfillID := ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID; fulfillID > 0 {
updates["upstream_fulfill_id"] = fulfillID
}
}
result.Status = model.RockStatusRedeemed
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
result.Message = "上游已发货,本地状态已同步为已交付"
case ref.IsBound() && actualRoleID == "":
result.Warning = true
result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对"
case ref.IsBound() && expectedUID == "":
// 没有预期 UID 无法核验,只同步角色信息。
result.Warning = true
result.Message = fmt.Sprintf(
"上游已绑定「%s」(UID %s),但本地缺少预期 UID,已同步角色信息,请人工核对",
displayRockRole(actualRoleName, actualRoleID), actualRoleID,
)
case ref.IsBound() && token.Status == model.RockStatusMismatch && actualRoleID == expectedUID:
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
if token.BoundAt == nil {
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
result.Message = "上游绑定 UID 与预期一致,已解除绑定错误"
case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref):
result.Warning = true
result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变"
case ref.IsBound() && actualRoleID != expectedUID:
reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)
updates["status"] = model.RockStatusMismatch
updates["mismatch_reason"] = reason
if token.MismatchAt == nil {
updates["mismatch_at"] = now
}
result.Status = model.RockStatusMismatch
result.Warning = true
result.Message = fmt.Sprintf(
"上游绑定「%s」(UID %s) 与预期 UID(%s) 不一致,已标记绑定错误",
displayRockRole(actualRoleName, actualRoleID), actualRoleID, expectedUID,
)
case ref.IsBound():
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
if token.BoundAt == nil {
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
result.Message = "上游已绑定,UID 核验一致"
default:
result.Message = "上游仍未绑定,本地状态保持不变"
}
result.Changed = result.Status != originalStatus ||
(ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) ||
(ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) ||
(ref.IsBound() && actualChannel != "" && actualChannel != originalChannel)
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
return nil, err
}
_ = writeAudit(s.db.WithContext(ctx), nil, nil, 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,
})
return result, nil
}
func displayRockRole(name, roleID string) string {
if strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return strings.TrimSpace(roleID)
}
func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time {
if ref != nil {
if t, ok := parseRockTime(ref.Token.BindChangedAt); ok {
return t
}
}
return fallback
}