diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index e0e5978..1cd72b7 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "affiliate_dash/internal/middleware" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -165,6 +166,22 @@ func (h *RockHandler) GetPurchaseLog(c *gin.Context) { response.OK(c, gin.H{"log": entry, "stages": stages}) } +type importAllPackagesReq struct { + MaxPackages int `json:"max_packages"` +} + +// ImportAllUpstreamPackages 全量导入上游包:已导入的跳过,只导入未绑定的 Token +func (h *RockHandler) ImportAllUpstreamPackages(c *gin.Context) { + var req importAllPackagesReq + _ = c.ShouldBindJSON(&req) + result, err := h.rockSvc.ImportAllUpstreamPackages(c.Request.Context(), req.MaxPackages, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + // ListTokens 分页获取 Token 库存列表 func (h *RockHandler) ListTokens(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e2d4aff..76e96bb 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -291,6 +291,7 @@ func Setup(h *Handlers) *gin.Engine { platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) platform.GET("/platform/rock/purchase-logs", h.Rock.ListPurchaseLogs) platform.GET("/platform/rock/purchase-logs/:id", h.Rock.GetPurchaseLog) + platform.POST("/platform/rock/upstream-packages/import-all", h.Rock.ImportAllUpstreamPackages) platform.GET("/platform/rock/tokens", h.Rock.ListTokens) platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID) platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken) diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go index 85f002a..b2c159a 100644 --- a/backend/internal/service/rock_client.go +++ b/backend/internal/service/rock_client.go @@ -371,6 +371,63 @@ func (r *RockPackageCreateResponse) ResolvePackage() (int64, string) { return 0, "" } +// RockPackageListResponse 是 GET /api/user/packages 的返回结构。 +type RockPackageListResponse struct { + Packages []RockPackage `json:"packages"` + Pagination struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + TotalPages int `json:"totalPages"` + Offset int `json:"offset"` + } `json:"pagination"` + Error string `json:"error,omitempty"` +} + +// ListPackages 分页查询上游打包记录(需 Bearer Token)。 +func (c *RockClient) ListPackages(ctx context.Context, bearerToken string, page, pageSize int) (*RockPackageListResponse, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + reqURL := fmt.Sprintf("%s/api/user/packages?page=%d&pageSize=%d", c.BaseURL(), page, pageSize) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken))) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Origin", c.BaseURL()) + req.Header.Set("Referer", c.BaseURL()+"/") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 packages 列表失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游 packages 列表接口返回错误码 %d: %s", resp.StatusCode, string(body)) + } + var res RockPackageListResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析 packages 列表响应失败: %w", err) + } + if res.Error != "" { + return nil, fmt.Errorf("上游 packages 列表业务错误: %s", res.Error) + } + return &res, nil +} + // CreatePackage 把一批已提取 Token 打包成公开分享包(需 Bearer Token)。 // 注意:上游未返回 share_code 时不会在这里报错,由调用方决定兜底策略。 func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids []string, note string) (*RockPackageCreateResponse, error) { diff --git a/backend/internal/service/rock_package_import.go b/backend/internal/service/rock_package_import.go new file mode 100644 index 0000000..e191a56 --- /dev/null +++ b/backend/internal/service/rock_package_import.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + "strings" + + "affiliate_dash/internal/model" +) + +// PackageImportResult 是单个包的导入结果。 +type PackageImportResult struct { + PackageID int64 `json:"package_id"` + ShareCode string `json:"share_code"` + Inserted int `json:"inserted"` + SkippedBound int `json:"skipped_bound"` + Error string `json:"error,omitempty"` +} + +// ImportAllResult 是全量导入的汇总结果。 +type ImportAllResult struct { + Scanned int `json:"scanned"` + Skipped int `json:"skipped"` // 本地已有该包 Token,整包跳过 + Empty int `json:"empty"` // 包内没有未绑定的 Token + Imported int `json:"imported"` // 成功导入了 Token 的包 + Failed int `json:"failed"` // 拉详情或写库失败 + TokensImported int `json:"tokens_imported"` + TokensSkippedBound int `json:"tokens_skipped_bound"` + Remaining int `json:"remaining"` // 超过单次上限未处理的包 + Results []PackageImportResult `json:"results"` +} + +// isUnboundRockToken 判断上游 Token 是否从未绑定过。 +// 规则(与业务确认):bind_changed_at 与 bind_change_type 同时为空才算未绑定。 +func isUnboundRockToken(token RockPackageToken) bool { + if strings.TrimSpace(token.BindChangedAt) != "" { + return false + } + if strings.TrimSpace(token.BindChangeType) != "" { + return false + } + return true +} + +// filterUnboundTokens 拆出未绑定 Token,并返回被跳过的已绑定数量。 +func filterUnboundTokens(tokens []RockPackageToken) ([]RockPackageToken, int) { + kept := make([]RockPackageToken, 0, len(tokens)) + skipped := 0 + for _, token := range tokens { + if isUnboundRockToken(token) { + kept = append(kept, token) + continue + } + skipped++ + } + return kept, skipped +} + +// ImportAllUpstreamPackages 全量扫描上游包:已导入的跳过,只导入包内未绑定的 Token, +// 且导入后统一标记为 available。 +func (s *RockService) ImportAllUpstreamPackages(ctx context.Context, maxPackages int, actorUserID uint) (*ImportAllResult, error) { + if maxPackages <= 0 { + maxPackages = 50 + } + if maxPackages > 200 { + maxPackages = 200 + } + + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + if strings.TrimSpace(cfg.BearerToken) == "" { + return nil, errors.New("请先配置 0666.pro Bearer Token") + } + + // 1. 分页拉取全部上游包(最多 20 页,防止异常分页导致死循环)。 + all := make([]RockPackage, 0, 64) + for page := 1; page <= 20; page++ { + resp, listErr := s.client.ListPackages(ctx, cfg.BearerToken, page, 100) + if listErr != nil { + return nil, listErr + } + all = append(all, resp.Packages...) + if resp.Pagination.TotalPages > 0 { + if page >= resp.Pagination.TotalPages { + break + } + } else if len(resp.Packages) == 0 { + break + } + if len(resp.Packages) == 0 { + break + } + } + + result := &ImportAllResult{Scanned: len(all)} + if len(all) == 0 { + return result, nil + } + + // 2. 查询本地已导入的 package_id 集合(以本地实际存在的 Token 为准)。 + ids := make([]int64, 0, len(all)) + for _, pkg := range all { + if pkg.ID > 0 { + ids = append(ids, pkg.ID) + } + } + importedIDs := make([]int64, 0, len(ids)) + if len(ids) > 0 { + if err := s.db.WithContext(ctx).Model(&model.RockToken{}). + Where("package_id IN ?", ids). + Group("package_id"). + Pluck("package_id", &importedIDs).Error; err != nil { + log.Printf("[RockService] 查询本地已导入包失败: %v", err) + } + } + importedSet := make(map[int64]struct{}, len(importedIDs)) + for _, id := range importedIDs { + importedSet[id] = struct{}{} + } + + // 3. 过滤出待处理的包,并按上限截断。 + pending := make([]RockPackage, 0, len(all)) + for _, pkg := range all { + if _, ok := importedSet[pkg.ID]; ok { + result.Skipped++ + continue + } + pending = append(pending, pkg) + } + if len(pending) > maxPackages { + result.Remaining = len(pending) - maxPackages + pending = pending[:maxPackages] + } + + // 4. 串行导入,单包失败不影响后续。 + for _, pkg := range pending { + item := s.importAllOnePackage(ctx, pkg, actorUserID) + result.Results = append(result.Results, item) + switch { + case item.Error != "": + result.Failed++ + case item.Inserted == 0: + result.Empty++ + default: + result.Imported++ + result.TokensImported += item.Inserted + } + result.TokensSkippedBound += item.SkippedBound + } + log.Printf("[RockService] 全量导入完成: %s", formatImportAllSummary(result)) + return result, nil +} + +func (s *RockService) importAllOnePackage(ctx context.Context, pkg RockPackage, actorUserID uint) PackageImportResult { + result := PackageImportResult{PackageID: pkg.ID, ShareCode: pkg.ShareCode} + detail, err := s.client.GetPackageDetail(ctx, pkg.ShareCode) + if err != nil { + result.Error = "拉取包详情失败: " + err.Error() + return result + } + if detail == nil || detail.Package.ID == 0 { + result.Error = "上游未返回 package.id" + return result + } + result.PackageID = detail.Package.ID + + kept, skippedBound := filterUnboundTokens(detail.Tokens) + result.SkippedBound = skippedBound + if len(kept) == 0 { + return result + } + + // 只把未绑定 Token 交给导入器,并强制写成 available。 + filtered := *detail + filtered.Tokens = kept + forceIDs := make(map[string]struct{}, len(kept)) + for _, token := range kept { + if token.ID > 0 { + forceIDs[strconv.FormatInt(token.ID, 10)] = struct{}{} + } + } + + inserted, updated, skipped, err := s.importPackageDetail(ctx, &filtered, forceIDs) + if err != nil { + result.Error = "导入失败: " + err.Error() + return result + } + result.Inserted = inserted + + if inserted > 0 { + actor := actorUserID + metadata := map[string]interface{}{ + "package_id": detail.Package.ID, + "share_code": pkg.ShareCode, + "mode": "import_all", + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "skipped_bound": skippedBound, + } + if auditErr := writeAudit(s.db.WithContext(ctx), nil, &actor, nil, "rock_package.import_all", "rock_package", pkg.ShareCode, metadata); auditErr != nil { + log.Printf("[RockService] 写入全量导入审计失败 share_code=%s: %v", pkg.ShareCode, auditErr) + } + } + return result +} + +// formatImportAllSummary 生成便于日志排查的一行摘要。 +func formatImportAllSummary(result *ImportAllResult) string { + if result == nil { + return "" + } + return fmt.Sprintf("扫描=%d 跳过=%d 空包=%d 导入=%d 失败=%d Token=%d 已绑定跳过=%d 剩余=%d", + result.Scanned, result.Skipped, result.Empty, result.Imported, result.Failed, + result.TokensImported, result.TokensSkippedBound, result.Remaining) +} diff --git a/backend/internal/service/rock_package_import_test.go b/backend/internal/service/rock_package_import_test.go new file mode 100644 index 0000000..60ca38b --- /dev/null +++ b/backend/internal/service/rock_package_import_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "affiliate_dash/internal/model" +) + +func newImportAllMock(t *testing.T, packages []map[string]interface{}, details map[string]map[string]interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/user/packages": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "packages": packages, + "pagination": map[string]interface{}{ + "page": 1, "pageSize": 100, "total": len(packages), "totalPages": 1, + }, + }) + case strings.HasPrefix(r.URL.Path, "/api/package/"): + code := strings.TrimPrefix(r.URL.Path, "/api/package/") + detail, ok := details[code] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"package not found"}`)) + return + } + _ = json.NewEncoder(w).Encode(detail) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestIsUnboundRockToken(t *testing.T) { + cases := []struct { + name string + token RockPackageToken + expect bool + }{ + {"两个绑定字段都为空", RockPackageToken{BindChangedAt: "", BindChangeType: ""}, true}, + {"有 bind_changed_at", RockPackageToken{BindChangedAt: "2026-10-30 16:02:19", BindChangeType: ""}, false}, + {"bind 类型", RockPackageToken{BindChangedAt: "", BindChangeType: "bind"}, false}, + {"rebind 类型", RockPackageToken{BindChangedAt: "", BindChangeType: "rebind"}, false}, + {"两者都有", RockPackageToken{BindChangedAt: "2026-10-30 16:02:19", BindChangeType: "rebind"}, false}, + } + for _, tc := range cases { + if got := isUnboundRockToken(tc.token); got != tc.expect { + t.Fatalf("%s: isUnboundRockToken=%v, want %v", tc.name, got, tc.expect) + } + } +} + +func TestImportAllUpstreamPackages_SkipsImported(t *testing.T) { + db := newRockTestDB(t) + packages := []map[string]interface{}{ + {"id": 100, "token_count": 1, "share_code": "share-100", "status": "active", "created_at": "2026-10-20 10:00:00", "note": "a"}, + {"id": 101, "token_count": 1, "share_code": "share-101", "status": "active", "created_at": "2026-10-21 10:00:00", "note": "b"}, + } + details := map[string]map[string]interface{}{ + "share-101": { + "package": map[string]interface{}{"id": 101, "share_code": "share-101", "token_count": 1, "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 95001, "token": "tk-95001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-95001"}, + }, + }, + } + server := newImportAllMock(t, packages, details) + defer server.Close() + + pid := int64(100) + if err := db.Create(&model.RockToken{ + UpstreamTokenID: "90001", + Token: "tk-90001", + RedeemCode: "rc-90001", + Status: model.RockStatusAvailable, + PackageID: &pid, + }).Error; err != nil { + t.Fatalf("写入本地 Token 失败: %v", err) + } + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1) + if err != nil { + t.Fatalf("全量导入失败: %v", err) + } + if result.Scanned != 2 || result.Skipped != 1 || result.Imported != 1 { + t.Fatalf("汇总不符: %+v", result) + } + if result.TokensImported != 1 { + t.Fatalf("应导入 1 个 Token,实际 %d", result.TokensImported) + } + + var token model.RockToken + if err := db.Where("upstream_token_id = ?", "95001").First(&token).Error; err != nil { + t.Fatalf("查询导入 Token 失败: %v", err) + } + if token.Status != model.RockStatusAvailable { + t.Fatalf("导入后应为 available,实际 %s", token.Status) + } +} + +func TestImportAllUpstreamPackages_FiltersBoundTokens(t *testing.T) { + db := newRockTestDB(t) + packages := []map[string]interface{}{ + {"id": 300, "token_count": 3, "share_code": "share-300", "status": "active", "created_at": "2026-10-22 10:00:00", "note": "c"}, + } + details := map[string]map[string]interface{}{ + "share-300": { + "package": map[string]interface{}{"id": 300, "share_code": "share-300", "token_count": 3, "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 96001, "token": "tk-96001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-96001"}, + {"id": 96002, "token": "tk-96002", "color": "black", "region": "Q/V可用", "status": "bound", "redeem_code": "rc-96002", "bind_changed_at": "2026-10-30 16:02:19", "bind_change_type": "bind", "bound_account": "角色A", "bound_account_id": "111"}, + {"id": 96003, "token": "tk-96003", "color": "black", "region": "Q/V可用", "status": "redeemed", "redeem_code": "rc-96003", "bind_changed_at": "2026-10-31 12:45:06", "bind_change_type": "rebind", "bound_account": "角色B", "bound_account_id": "222"}, + }, + }, + } + server := newImportAllMock(t, packages, details) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1) + if err != nil { + t.Fatalf("全量导入失败: %v", err) + } + if result.Imported != 1 || result.TokensImported != 1 { + t.Fatalf("应只导入 1 个未绑定 Token,实际 %+v", result) + } + if result.TokensSkippedBound != 2 { + t.Fatalf("应跳过 2 个已绑定 Token,实际 %d", result.TokensSkippedBound) + } + + var count int64 + db.Model(&model.RockToken{}).Where("upstream_token_id IN ?", []string{"96002", "96003"}).Count(&count) + if count != 0 { + t.Fatalf("已绑定 Token 不应入库,实际 %d 条", count) + } + + var token model.RockToken + if err := db.Where("upstream_token_id = ?", "96001").First(&token).Error; err != nil { + t.Fatalf("查询导入 Token 失败: %v", err) + } + if token.Status != model.RockStatusAvailable || token.UsingAt != nil || token.BoundAt != nil { + t.Fatalf("导入结果不符: status=%s usingAt=%v boundAt=%v", token.Status, token.UsingAt, token.BoundAt) + } +} + +func TestImportAllUpstreamPackages_EmptyWhenAllBound(t *testing.T) { + db := newRockTestDB(t) + packages := []map[string]interface{}{ + {"id": 310, "token_count": 1, "share_code": "share-310", "status": "active", "created_at": "2026-10-22 10:00:00", "note": "d"}, + } + details := map[string]map[string]interface{}{ + "share-310": { + "package": map[string]interface{}{"id": 310, "share_code": "share-310", "token_count": 1, "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 97001, "token": "tk-97001", "color": "black", "region": "Q/V可用", "status": "bound", "redeem_code": "rc-97001", "bind_changed_at": "2026-10-30 16:02:19", "bind_change_type": "bind"}, + }, + }, + } + server := newImportAllMock(t, packages, details) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1) + if err != nil { + t.Fatalf("全量导入失败: %v", err) + } + if result.Empty != 1 || result.Imported != 0 { + t.Fatalf("全部已绑定时应记为 empty,实际 %+v", result) + } +} + +func TestImportAllUpstreamPackages_PartialFailure(t *testing.T) { + db := newRockTestDB(t) + packages := []map[string]interface{}{ + {"id": 400, "token_count": 1, "share_code": "share-400", "status": "active", "created_at": "2026-10-23 10:00:00", "note": "e"}, + {"id": 401, "token_count": 1, "share_code": "share-401", "status": "active", "created_at": "2026-10-24 10:00:00", "note": "f"}, + } + details := map[string]map[string]interface{}{ + "share-401": { + "package": map[string]interface{}{"id": 401, "share_code": "share-401", "token_count": 1, "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 98001, "token": "tk-98001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-98001"}, + }, + }, + } + server := newImportAllMock(t, packages, details) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1) + if err != nil { + t.Fatalf("全量导入调用失败: %v", err) + } + if result.Failed != 1 || result.Imported != 1 { + t.Fatalf("应 1 失败 1 成功,实际 %+v", result) + } + if len(result.Results) != 2 || result.Results[0].Error == "" { + t.Fatalf("失败包应带错误信息: %+v", result.Results) + } +} + +func TestImportAllUpstreamPackages_MaxPackages(t *testing.T) { + db := newRockTestDB(t) + packages := []map[string]interface{}{ + {"id": 500, "token_count": 1, "share_code": "share-500", "status": "active", "created_at": "2026-10-25 10:00:00", "note": "g"}, + {"id": 501, "token_count": 1, "share_code": "share-501", "status": "active", "created_at": "2026-10-26 10:00:00", "note": "h"}, + {"id": 502, "token_count": 1, "share_code": "share-502", "status": "active", "created_at": "2026-10-27 10:00:00", "note": "i"}, + } + details := map[string]map[string]interface{}{ + "share-500": { + "package": map[string]interface{}{"id": 500, "share_code": "share-500", "token_count": 1, "status": "active"}, + "tokens": []map[string]interface{}{ + {"id": 99001, "token": "tk-99001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-99001"}, + }, + }, + } + server := newImportAllMock(t, packages, details) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + result, err := svc.ImportAllUpstreamPackages(context.Background(), 1, 1) + if err != nil { + t.Fatalf("全量导入失败: %v", err) + } + if result.Remaining != 2 { + t.Fatalf("上限 1 时应剩余 2 个包,实际 %d", result.Remaining) + } + if len(result.Results) != 1 { + t.Fatalf("应只处理 1 个包,实际 %d", len(result.Results)) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a581d31..92fbd92 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -710,6 +710,26 @@ export interface RockPurchaseStage { created_at: string; } +export interface PackageImportItem { + package_id: number; + share_code: string; + inserted: number; + skipped_bound: number; + error?: string; +} + +export interface ImportAllResult { + scanned: number; + skipped: number; + empty: number; + imported: number; + failed: number; + tokens_imported: number; + tokens_skipped_bound: number; + remaining: number; + results: PackageImportItem[]; +} + export const rockApi = { getConfig: () => request.get("/platform/rock/config").then((r) => r.data.data as RockConfig), @@ -747,6 +767,12 @@ export const rockApi = { (r) => r.data.data as { log: RockPurchaseLog; stages: RockPurchaseStage[] }, ), + importAllUpstreamPackages: (maxPackages = 50) => + request + .post("/platform/rock/upstream-packages/import-all", { + max_packages: maxPackages, + }) + .then((r) => r.data.data as ImportAllResult), listTokens: (params?: { page?: number; page_size?: number; diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index 1bf95dd..ac9bba1 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { Alert, Button, Form, Modal, Space, message } from 'antd' -import { HistoryOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons' +import { HistoryOutlined, ImportOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api' import { PageHeader } from '../components/PageHeader' import { RockMetricCards } from './platformRock/RockMetricCards' @@ -9,6 +9,7 @@ import { RockTokenFilterBar } from './platformRock/RockTokenFilterBar' import { RockTokenTable } from './platformRock/RockTokenTable' import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal' import { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer' +import { ImportAllPackagesModal } from './platformRock/ImportAllPackagesModal' import { UidEditModal } from './platformRock/UidEditModal' import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal' import type { UidEditState } from './platformRock/types' @@ -35,6 +36,7 @@ export default function PlatformRock() { const [purchaseModalOpen, setPurchaseModalOpen] = useState(false) const [purchaseCount, setPurchaseCount] = useState(50) const [purchaseLogOpen, setPurchaseLogOpen] = useState(false) + const [importPackageOpen, setImportPackageOpen] = useState(false) const [rawDataRecord, setRawDataRecord] = useState(null) // 凭证图片操作状态 @@ -302,6 +304,9 @@ export default function PlatformRock() { + } /> @@ -437,6 +442,15 @@ export default function PlatformRock() { /> setPurchaseLogOpen(false)} /> + + setImportPackageOpen(false)} + onImported={() => { + loadData() + loadTokens() + }} + /> ) } diff --git a/frontend/src/pages/platformRock/ImportAllPackagesModal.tsx b/frontend/src/pages/platformRock/ImportAllPackagesModal.tsx new file mode 100644 index 0000000..b9dd198 --- /dev/null +++ b/frontend/src/pages/platformRock/ImportAllPackagesModal.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react' +import { Alert, Button, Descriptions, Modal, Space, Table, Tag, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { rockApi, type ImportAllResult, type PackageImportItem } from '../../api' + +const { Text } = Typography + +interface ImportAllPackagesModalProps { + open: boolean + onClose: () => void + onImported: () => void +} + +const MAX_PACKAGES = 50 + +export function ImportAllPackagesModal({ open, onClose, onImported }: ImportAllPackagesModalProps) { + const [running, setRunning] = useState(false) + const [result, setResult] = useState(null) + + const handleClose = () => { + if (running) { + return + } + setResult(null) + onClose() + } + + const handleStart = async () => { + setRunning(true) + setResult(null) + try { + const res = await rockApi.importAllUpstreamPackages(MAX_PACKAGES) + setResult(res) + if (res.imported > 0 || res.failed > 0) { + onImported() + } + } catch (e) { + message.error('全量导入失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setRunning(false) + } + } + + const columns: ColumnsType = [ + { title: '包 ID', dataIndex: 'package_id', width: 90, align: 'right' }, + { + title: 'share_code', + dataIndex: 'share_code', + width: 220, + render: (value: string) => ( + {value} + ), + }, + { + title: '新增 Token', + dataIndex: 'inserted', + width: 100, + align: 'right', + render: (value: number) => (value > 0 ? {value} : '-'), + }, + { + title: '跳过已绑定', + dataIndex: 'skipped_bound', + width: 100, + align: 'right', + render: (value: number) => (value > 0 ? {value} : '-'), + }, + { + title: '结果', + dataIndex: 'error', + ellipsis: true, + render: (value?: string) => + value ? ( + + {value} + + ) : ( + 成功 + ), + }, + ] + + const detailRows = (result?.results || []).filter( + (item) => item.inserted > 0 || Boolean(item.error), + ) + + return ( + + 关闭 + , + , + ] + : [ + , + , + ] + } + > + {!result ? ( + + + · 扫描全部上游包,本地已存在该包 Token 的整包跳过 +
· 只导入未绑定的 Token:bind_changed_at 与{' '} + bind_change_type 均为空 +
· 导入后统一标记为可用 +
· 单次最多处理 {MAX_PACKAGES} 个未导入包,剩余的下次再导入 + + } + /> + {running ? ( + + 正在逐个拉取包详情并导入,可能需要 1 分钟,请勿关闭窗口… + + ) : null} +
+ ) : ( + + + {result.scanned} + {result.skipped} + {result.empty} + {result.imported} + {result.tokens_imported} + {result.tokens_skipped_bound} + {result.failed} + {result.remaining} + + + {result.failed > 0 ? ( + + ) : ( + 0 + ? `本次新增 ${result.tokens_imported} 个可用 Token` + : '本次没有可导入的未绑定 Token' + } + /> + )} + + {detailRows.length > 0 ? ( + + ) : null} + + )} + + ) +}