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

281 lines
9.3 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,人工同步一次绑定状态。
//
// 规则(只同步上游实时状态,不再用本地预期 UID 拦截):
// - 上游状态异常(abnormal)→ 本地同步 abnormal(已上报失效);
// - 上游明确返回「Token 不存在」(404) → 本地同步 abnormal(已上报失效);
// - 上游已发货 → 本地同步 redeemed;
// - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记;
// - 上游未绑定 → 本地状态保持不变(不自动把 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)
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,已标记为已上报失效")
}
now := time.Now()
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
if upstreamStatus == model.RockStatusAbnormal {
return s.markTokenAbnormalByRefresh(ctx, &token,
"上游状态异常(刷新自动同步)", "上游状态异常,已标记为已上报失效")
}
originalStatus := token.Status
originalRoleName := token.ActualRoleName
originalRoleID := token.ActualRoleID
originalChannel := token.ActualChannel
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
upstreamBound := rockUpstreamBound(ref)
updates := map[string]interface{}{
"updated_at": now,
}
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
if upstreamBound {
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: upstreamBound,
}
if upstreamBound {
result.RoleName = actualRoleName
result.RoleID = actualRoleID
result.Channel = actualChannel
}
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 upstreamBound:
// 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
// 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
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
if actualRoleID == "" {
result.Warning = true
result.Message = "上游已绑定,但未返回角色 UID,已同步为已绑定,请人工核对"
} else {
result.Message = fmt.Sprintf(
"上游已绑定「%s」(UID %s),已同步为已绑定",
displayRockRole(actualRoleName, actualRoleID), actualRoleID,
)
}
default:
result.Message = "上游仍未绑定,本地状态保持不变"
}
result.Changed = result.Status != originalStatus ||
(upstreamBound && actualRoleName != "" && actualRoleName != originalRoleName) ||
(upstreamBound && actualRoleID != "" && actualRoleID != originalRoleID) ||
(upstreamBound && 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)
}
// 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) (*TokenBindingRefreshResult, error) {
if token == nil {
return nil, errors.New("Token 不存在")
}
if token.Status == model.RockStatusRedeemed || token.RedeemedAt != nil {
return nil, errors.New("该 Token 已有发货记录,不能标记为已上报失效")
}
updates := map[string]interface{}{
"status": model.RockStatusAbnormal,
"updated_at": time.Now(),
}
existingRemark := strings.TrimSpace(token.Remark)
switch {
case existingRemark == "":
updates["remark"] = remark
case !strings.Contains(existingRemark, remark):
updates["remark"] = existingRemark + "\n" + remark
}
if err := s.db.WithContext(ctx).Model(token).Updates(updates).Error; err != nil {
return nil, err
}
s.refreshCache.Delete(token.UpstreamTokenID)
result := &TokenBindingRefreshResult{
Status: model.RockStatusAbnormal,
Changed: true,
Warning: true,
Message: message,
}
_ = 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,
"message": result.Message,
})
return result, nil
}
func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time {
if ref != nil {
if t, ok := parseRockTime(ref.Token.BindChangedAt); ok {
return t
}
}
return fallback
}