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