230 lines
7.5 KiB
Go
230 lines
7.5 KiB
Go
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
|
||
}
|