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

345 lines
12 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"
"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
}