diff --git a/backend/internal/service/rock_package_link.go b/backend/internal/service/rock_package_link.go index 4902d82..9595052 100644 --- a/backend/internal/service/rock_package_link.go +++ b/backend/internal/service/rock_package_link.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/url" "strings" @@ -27,15 +28,25 @@ func (s *RockService) TokenPackageLink(ctx context.Context, tokenID uint) (*Toke if token.PackageID == nil || *token.PackageID <= 0 { return nil, errors.New("该 Token 没有关联上游打包记录,无法跳转上游") } - shareCode := strings.TrimSpace(token.PackageShareCode) - if shareCode == "" { - return nil, errors.New("该 Token 缺少上游包分享码(历史包不支持定位),无法跳转上游") - } - cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } + + shareCode := strings.TrimSpace(token.PackageShareCode) + if shareCode == "" { + shareCode, err = s.resolvePackageShareCode(ctx, *token.PackageID, cfg.BearerToken) + if err != nil { + return nil, err + } + // 反查成功后回填同包 Token,后续点击无需再请求上游。 + if updateErr := s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("package_id = ? AND package_share_code = ''", *token.PackageID). + Update("package_share_code", shareCode).Error; updateErr != nil { + log.Printf("[RockService] 回填 package_share_code 失败 package_id=%d: %v", *token.PackageID, updateErr) + } + } + base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") if base == "" { base = model.RockDefaultBaseURL @@ -47,3 +58,45 @@ func (s *RockService) TokenPackageLink(ctx context.Context, tokenID uint) (*Toke URL: fmt.Sprintf("%s/#/package/%s", base, url.PathEscape(shareCode)), }, nil } + +// resolvePackageShareCode 在本地没有分享码时,按 package_id 从上游包列表反查。 +// 历史 Token 只落了 package_id,package_share_code 是后续版本才开始保存的。 +func (s *RockService) resolvePackageShareCode(ctx context.Context, packageID int64, bearerToken string) (string, error) { + if packageID <= 0 { + return "", errors.New("该 Token 没有关联上游打包记录,无法跳转上游") + } + bearer := strings.TrimSpace(bearerToken) + if bearer == "" { + return "", errors.New("该 Token 缺少上游包分享码,且未配置 0666.pro Bearer Token,无法反查上游包") + } + // 最多翻 20 页,防止上游分页异常导致死循环。 + for page := 1; page <= 20; page++ { + resp, err := s.client.ListPackages(ctx, bearer, page, 100) + if err != nil { + return "", fmt.Errorf("反查上游包分享码失败: %w", err) + } + if resp == nil { + break + } + for _, pkg := range resp.Packages { + if pkg.ID != packageID { + continue + } + code := strings.TrimSpace(pkg.ShareCode) + if code == "" { + return "", errors.New("上游该包未返回分享码,无法跳转上游") + } + return code, nil + } + if resp.Pagination.TotalPages > 0 { + if page >= resp.Pagination.TotalPages { + break + } + continue + } + if len(resp.Packages) == 0 { + break + } + } + return "", errors.New("上游未找到该 Token 所属打包记录(可能已被清理),无法跳转上游") +} diff --git a/backend/internal/service/rock_package_link_test.go b/backend/internal/service/rock_package_link_test.go index 620c4e1..e294cf4 100644 --- a/backend/internal/service/rock_package_link_test.go +++ b/backend/internal/service/rock_package_link_test.go @@ -3,6 +3,8 @@ package service import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" @@ -57,7 +59,7 @@ func TestTokenPackageLink_RejectsWithoutPackage(t *testing.T) { } } -func TestTokenPackageLink_RejectsMissingShareCode(t *testing.T) { +func TestTokenPackageLink_RejectsMissingShareCodeWithoutBearer(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) @@ -73,8 +75,93 @@ func TestTokenPackageLink_RejectsMissingShareCode(t *testing.T) { t.Fatalf("创建 Token 失败: %v", err) } - if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "缺少上游包分享码") { - t.Fatalf("缺少分享码应被拒绝,实际 err=%v", err) + if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "无法反查上游包") { + t.Fatalf("缺少 Bearer Token 时应拒绝反查,实际 err=%v", err) + } +} + +func TestTokenPackageLink_ResolvesAndBackfillsShareCode(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") + if r.URL.Path != "/api/user/packages" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "packages": []map[string]interface{}{ + {"id": 1509, "share_code": "share-other"}, + {"id": 1508, "share_code": "share-resolved"}, + }, + "pagination": map[string]interface{}{"page": 1, "pageSize": 100, "total": 2, "totalPages": 1}, + }) + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + packageID := int64(1508) + tokens := []model.RockToken{ + {UpstreamTokenID: "9103", RedeemCode: "CODE-NOSHARE", Color: model.RockColorBlack, Status: model.RockStatusMismatch, PackageID: &packageID}, + {UpstreamTokenID: "9104", RedeemCode: "CODE-NOSHARE-2", Color: model.RockColorBlack, Status: model.RockStatusAvailable, PackageID: &packageID}, + } + for i := range tokens { + if err := db.Create(&tokens[i]).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + } + + res, err := svc.TokenPackageLink(context.Background(), tokens[0].ID) + if err != nil { + t.Fatalf("反查分享码失败: %v", err) + } + if res.ShareCode != "share-resolved" || !strings.HasSuffix(res.URL, "/#/package/share-resolved") { + t.Fatalf("反查结果不符: %+v", res) + } + + var saved []model.RockToken + if err := db.Where("package_id = ?", packageID).Find(&saved).Error; err != nil { + t.Fatalf("查询 Token 失败: %v", err) + } + if len(saved) != len(tokens) { + t.Fatalf("Token 数量不符: %d", len(saved)) + } + for _, row := range saved { + if row.PackageShareCode != "share-resolved" { + t.Fatalf("同包 Token 未回填: id=%d code=%q", row.ID, row.PackageShareCode) + } + } +} + +func TestTokenPackageLink_RejectsWhenUpstreamPackageMissing(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{}{ + "packages": []map[string]interface{}{}, + "pagination": map[string]interface{}{"page": 1, "pageSize": 100, "total": 0, "totalPages": 1}, + }) + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + packageID := int64(1599) + token := model.RockToken{ + UpstreamTokenID: "9105", + RedeemCode: "CODE-GONE", + Color: model.RockColorBlack, + Status: model.RockStatusMismatch, + PackageID: &packageID, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + + if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "上游未找到") { + t.Fatalf("上游无该包时应返回明确错误,实际 err=%v", err) } } diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index 022b784..d05542d 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -451,18 +451,32 @@ export function buildTokenColumns({ 刷新 ) - if (record.status === 'mismatch') { - return ( - - {refreshButton} + const upstreamDisabled = !record.package_id + const upstreamTip = upstreamDisabled + ? '该 Token 未关联上游打包记录,无法跳转上游' + : record.package_share_code + ? '' + : '本地暂无包分享码,点击时会自动向上游反查' + const upstreamButton = ( + + + + + ) + if (record.status === 'mismatch') { + return ( + + {refreshButton} + {upstreamButton}