From f38f11ae9657302c1e5ed6bdaa09ef44895344cb Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 9 Nov 2026 11:18:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=88=B7=E6=96=B0=E6=97=B6=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=BC=82=E5=B8=B8/=E5=8D=A1=E5=AF=86=E4=B8=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E8=87=AA=E5=8A=A8=E6=A0=87=E8=AE=B0=E5=B7=B2?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/rock_binding_refresh.go | 83 ++++++++- .../service/rock_binding_refresh_test.go | 171 ++++++++++++++++++ backend/internal/service/rock_client.go | 2 +- backend/internal/service/rock_overdue_test.go | 52 ++++++ .../pages/platformRock/RockTokenFilterBar.tsx | 2 + 5 files changed, 306 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/rock_binding_refresh.go b/backend/internal/service/rock_binding_refresh.go index 11c1f91..e0b42cd 100644 --- a/backend/internal/service/rock_binding_refresh.go +++ b/backend/internal/service/rock_binding_refresh.go @@ -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 { diff --git a/backend/internal/service/rock_binding_refresh_test.go b/backend/internal/service/rock_binding_refresh_test.go index 3ec3c80..841b1ef 100644 --- a/backend/internal/service/rock_binding_refresh_test.go +++ b/backend/internal/service/rock_binding_refresh_test.go @@ -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) + } +} diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go index 89718c7..db5c547 100644 --- a/backend/internal/service/rock_client.go +++ b/backend/internal/service/rock_client.go @@ -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 diff --git a/backend/internal/service/rock_overdue_test.go b/backend/internal/service/rock_overdue_test.go index e9a0b80..84ffa95 100644 --- a/backend/internal/service/rock_overdue_test.go +++ b/backend/internal/service/rock_overdue_test.go @@ -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) + } +} diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx index 728a5cb..cdbaf47 100644 --- a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx +++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx @@ -120,6 +120,8 @@ export function RockTokenFilterBar({
单次最多 20 条,逐条间隔 1.5 秒,同一 Token 10 分钟内不重复刷新。
+ 上游返回异常或卡密不存在时,会自动标记为「已上报失效」。 +
「启用自动刷新」每 5 分钟按同样规则自动执行。 }