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).Unscoped().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) }