feat:刷新时上游异常/卡密不存在自动标记已上报失效
This commit is contained in:
@@ -36,6 +36,8 @@ func canRefreshRockBinding(status string) bool {
|
||||
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
|
||||
//
|
||||
// 规则(只同步上游实时状态,不再用本地预期 UID 拦截):
|
||||
// - 上游状态异常(abnormal)→ 本地同步 abnormal(已上报失效);
|
||||
// - 上游明确返回「Token 不存在」(404) → 本地同步 abnormal(已上报失效);
|
||||
// - 上游已发货 → 本地同步 redeemed;
|
||||
// - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记;
|
||||
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
|
||||
@@ -57,6 +59,7 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
||||
}
|
||||
|
||||
ref, err := s.client.RefreshRedeemBinding(ctx, token.RedeemCode)
|
||||
upstreamNotFound := false
|
||||
if err != nil {
|
||||
// 公开端接口不可用时,降级到管理端 refresh(需要 upstream_token_id + Bearer)。
|
||||
var statusErr *RockUpstreamStatusError
|
||||
@@ -66,10 +69,16 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
||||
ref, err = s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err)
|
||||
// 只有上游明确返回「Token 不存在」才判定卡密已删除;超时/500/网关 404 等仍按失败处理。
|
||||
if isRockTokenNotFoundError(err) {
|
||||
upstreamNotFound = true
|
||||
ref = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ref == nil {
|
||||
if !upstreamNotFound && ref == nil {
|
||||
return nil, errors.New("上游未返回刷新结果")
|
||||
}
|
||||
|
||||
@@ -80,8 +89,18 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
||||
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
|
||||
@@ -118,7 +137,6 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
||||
result.Channel = actualChannel
|
||||
}
|
||||
|
||||
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
|
||||
redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt)
|
||||
|
||||
switch {
|
||||
@@ -188,6 +206,65 @@ func displayRockRole(name, roleID string) string {
|
||||
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(),
|
||||
}
|
||||
if strings.TrimSpace(token.Remark) == "" {
|
||||
updates["remark"] = 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 {
|
||||
|
||||
@@ -380,3 +380,174 @@ func TestRefreshTokenBinding_RejectsAvailableToken(t *testing.T) {
|
||||
t.Fatalf("available 状态应被拒绝,实际 err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游 status=abnormal 时直接同步为已上报失效,优先级高于残留的绑定信息。
|
||||
func TestRefreshTokenBinding_UpstreamAbnormalMarksAbnormal(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{
|
||||
"status": "abnormal",
|
||||
"bound_account": "痛写心扉",
|
||||
"bound_account_id": "3132710334",
|
||||
},
|
||||
"binding": map[string]interface{}{"bound": true, "roleId": "3132710334"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9010",
|
||||
RedeemCode: "CODE-ABNORMAL",
|
||||
Status: model.RockStatusUsing,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusAbnormal || !res.Changed {
|
||||
t.Fatalf("期望 abnormal 且 changed=true,实际 %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "已上报失效") {
|
||||
t.Fatalf("提示应说明已标记失效,实际 %q", res.Message)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusAbnormal {
|
||||
t.Fatalf("状态应同步为 abnormal,实际 %s", saved.Status)
|
||||
}
|
||||
if saved.Remark != "上游状态异常(刷新自动同步)" {
|
||||
t.Fatalf("备注应写入系统原因,实际 %q", saved.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游明确返回 Token 不存在(公开端与管理端都 404)时,绑定错误 Token 也同步为已上报失效。
|
||||
func TestRefreshTokenBinding_UpstreamDeletedMarksAbnormal(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"Token 不存在"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9011",
|
||||
RedeemCode: "CODE-DELETED",
|
||||
Status: model.RockStatusMismatch,
|
||||
ExpectedUID: "1755431394",
|
||||
MismatchAt: &now,
|
||||
MismatchReason: "预期UID(1755431394) 与 扫码UID(Nfestx/1920708170) 不一致",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
if res.Status != model.RockStatusAbnormal {
|
||||
t.Fatalf("上游删除时应同步 abnormal,实际 %s", res.Status)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusAbnormal {
|
||||
t.Fatalf("状态应同步为 abnormal,实际 %s", saved.Status)
|
||||
}
|
||||
if saved.Remark != "上游卡密不存在(刷新自动同步)" {
|
||||
t.Fatalf("备注应写入系统原因,实际 %q", saved.Remark)
|
||||
}
|
||||
if saved.MismatchAt == nil {
|
||||
t.Fatalf("绑定错误历史应保留")
|
||||
}
|
||||
}
|
||||
|
||||
// 普通 404(网关/路由)不能被误判为卡密删除。
|
||||
func TestRefreshTokenBinding_Generic404KeepsState(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"404 page not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9012",
|
||||
RedeemCode: "CODE-GATEWAY-404",
|
||||
Status: model.RockStatusUsing,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err == nil {
|
||||
t.Fatalf("普通 404 应返回错误,不能标记失效")
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusUsing || saved.Remark != "" {
|
||||
t.Fatalf("普通 404 不应改动状态或备注,实际 %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
// 已有备注时不被系统原因覆盖。
|
||||
func TestRefreshTokenBinding_AbnormalKeepsManualRemark(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": map[string]interface{}{"status": "abnormal"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "9013",
|
||||
RedeemCode: "CODE-ABNORMAL-REMARK",
|
||||
Status: model.RockStatusBound,
|
||||
Remark: "客服人工备注",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err != nil {
|
||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusAbnormal || saved.Remark != "客服人工备注" {
|
||||
t.Fatalf("应保留人工备注并标记 abnormal,实际 %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,7 +848,7 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("上游 refresh 接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||||
return nil, &RockUpstreamStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var res RockRefreshResponse
|
||||
|
||||
@@ -310,3 +310,55 @@ func TestRefreshStaleTokens_IncludesOverdueWhenEnabled(t *testing.T) {
|
||||
t.Fatalf("自动刷新也应处理使用超时 Token,实际 %s", saved.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 巡检遇到上游「Token 不存在」时,应标记为已上报失效并停止重复扫描。
|
||||
func TestRefreshAllStaleTokens_MarksNotFoundAsAbnormal(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"Token 不存在"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "scan-deleted",
|
||||
RedeemCode: "CODE-SCAN-DELETED",
|
||||
Status: model.RockStatusMismatch,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
|
||||
UpdateColumn("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||
t.Fatalf("调整 updated_at 失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.RefreshAllStaleTokens(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("一键刷新失败: %v", err)
|
||||
}
|
||||
if result.Scanned != 1 || result.Fixed != 1 || result.Failed != 0 {
|
||||
t.Fatalf("上游删除应计为修复,实际 %+v", result)
|
||||
}
|
||||
|
||||
var saved model.RockToken
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusAbnormal {
|
||||
t.Fatalf("应标记为 abnormal,实际 %s", saved.Status)
|
||||
}
|
||||
|
||||
// 已 abnormal 的 Token 不再进入扫描范围。
|
||||
second, err := svc.RefreshAllStaleTokens(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("第二次一键刷新失败: %v", err)
|
||||
}
|
||||
if second.Scanned != 0 {
|
||||
t.Fatalf("已标记失效的 Token 不应再次被扫描,实际 %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user