// 按产品导出 JSON 清理数据库:软删除「导出中已下线」或「不在有效列表」的产品及其目录关联数据。 // // go run ./cmd/product_prune -dry-run // go run ./cmd/product_prune -export ../public_product_export_2026-06-10_163520.json package main import ( "encoding/json" "flag" "fmt" "log" "os" "sort" "strings" "hyapi-server/internal/config" "hyapi-server/internal/domains/product/entities" "hyapi-server/internal/infrastructure/database" "go.uber.org/zap" "gorm.io/gorm" ) type exportProduct struct { ID string `json:"id"` Code string `json:"code"` DeletedAt *string `json:"deleted_at"` } func main() { exportPath := flag.String("export", "../public_product_export_2026-06-10_163520.json", "产品导出 JSON 路径") dryRun := flag.Bool("dry-run", false, "仅统计,不写库") pruneSubscriptions := flag.Bool("prune-subscriptions", true, "同时软删除指向已清理产品的订阅") flag.Parse() activeCodes, err := loadActiveCodes(*exportPath) if err != nil { log.Fatalf("读取导出文件失败: %v", err) } log.Printf("导出中有效产品(deleted_at 为空)共 %d 个", len(activeCodes)) cfg, err := config.LoadConfig() if err != nil { log.Fatalf("加载配置失败: %v", err) } logger, _ := zap.NewProduction() defer logger.Sync() dbCfg := database.Config{ Host: cfg.Database.Host, Port: cfg.Database.Port, User: cfg.Database.User, Password: cfg.Database.Password, Name: cfg.Database.Name, SSLMode: cfg.Database.SSLMode, Timezone: cfg.Database.Timezone, MaxOpenConns: cfg.Database.MaxOpenConns, MaxIdleConns: cfg.Database.MaxIdleConns, ConnMaxLifetime: cfg.Database.ConnMaxLifetime, } dbWrapper, err := database.NewConnection(dbCfg) if err != nil { log.Fatalf("连接数据库失败: %v", err) } db := dbWrapper.DB var products []entities.Product if err := db.Unscoped().Select("id", "code", "deleted_at").Find(&products).Error; err != nil { log.Fatalf("查询产品失败: %v", err) } var removeIDs []string var removeCodes []string for _, p := range products { code := strings.TrimSpace(strings.ToUpper(p.Code)) if code == "" { removeIDs = append(removeIDs, p.ID) removeCodes = append(removeCodes, p.Code) continue } if _, ok := activeCodes[code]; !ok { removeIDs = append(removeIDs, p.ID) removeCodes = append(removeCodes, p.Code) } } sort.Strings(removeCodes) log.Printf("数据库产品总数 %d,待清理 %d", len(products), len(removeIDs)) if len(removeCodes) > 0 { limit := len(removeCodes) if limit > 30 { limit = 30 } log.Printf("待清理产品编号示例(前 %d 个): %s", limit, strings.Join(removeCodes[:limit], ", ")) } if len(removeIDs) == 0 { log.Println("无需清理") return } if *dryRun { log.Println("dry-run 模式,未修改数据库") printRelatedCounts(db, removeIDs, *pruneSubscriptions) return } err = db.Transaction(func(tx *gorm.DB) error { if err := softDeleteByProductOrPackage(tx, &entities.ProductPackageItem{}, removeIDs); err != nil { return fmt.Errorf("product_package_item: %w", err) } if err := softDeleteByProductID(tx, &entities.ProductDocumentation{}, removeIDs); err != nil { return fmt.Errorf("product_documentation: %w", err) } if err := softDeleteByProductID(tx, &entities.ProductApiConfig{}, removeIDs); err != nil { return fmt.Errorf("product_api_config: %w", err) } if err := softDeleteByProductID(tx, &entities.ProductParameter{}, removeIDs); err != nil { return fmt.Errorf("product_parameter: %w", err) } if err := softDeleteByProductID(tx, &entities.ProductUIComponent{}, removeIDs); err != nil { return fmt.Errorf("product_ui_component: %w", err) } if err := softDeleteByProductID(tx, &entities.ComponentReportDownload{}, removeIDs); err != nil { return fmt.Errorf("component_report_download: %w", err) } if *pruneSubscriptions { if err := softDeleteByProductID(tx, &entities.Subscription{}, removeIDs); err != nil { return fmt.Errorf("subscription: %w", err) } } res := tx.Where("id IN ?", removeIDs).Delete(&entities.Product{}) if res.Error != nil { return fmt.Errorf("product: %w", res.Error) } log.Printf("已软删除 product %d 行", res.RowsAffected) return nil }) if err != nil { log.Fatalf("清理失败: %v", err) } log.Println("清理完成") } func loadActiveCodes(path string) (map[string]struct{}, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var rows []exportProduct if err := json.Unmarshal(raw, &rows); err != nil { return nil, err } active := make(map[string]struct{}) for _, r := range rows { if !isExportActive(r.DeletedAt) { continue } code := strings.TrimSpace(strings.ToUpper(r.Code)) if code != "" { active[code] = struct{}{} } } return active, nil } func isExportActive(deletedAt *string) bool { if deletedAt == nil { return true } s := strings.TrimSpace(*deletedAt) return s == "" || strings.EqualFold(s, "null") } func softDeleteByProductID(tx *gorm.DB, model interface{}, productIDs []string) error { if len(productIDs) == 0 { return nil } res := tx.Where("product_id IN ?", productIDs).Delete(model) if res.Error != nil { return res.Error } if res.RowsAffected > 0 { log.Printf("已软删除 %T %d 行", model, res.RowsAffected) } return nil } func softDeleteByProductOrPackage(tx *gorm.DB, model interface{}, productIDs []string) error { if len(productIDs) == 0 { return nil } res := tx.Where("product_id IN ? OR package_id IN ?", productIDs, productIDs).Delete(model) if res.Error != nil { return res.Error } if res.RowsAffected > 0 { log.Printf("已软删除 %T %d 行", model, res.RowsAffected) } return nil } func printRelatedCounts(db *gorm.DB, productIDs []string, subs bool) { checks := []struct { name string fn func() int64 }{ {"product_package_item", func() int64 { var n int64 db.Model(&entities.ProductPackageItem{}).Where("product_id IN ? OR package_id IN ?", productIDs, productIDs).Count(&n) return n }}, {"product_documentation", countByProduct(db, &entities.ProductDocumentation{}, productIDs)}, {"product_api_config", countByProduct(db, &entities.ProductApiConfig{}, productIDs)}, {"product_parameter", countByProduct(db, &entities.ProductParameter{}, productIDs)}, {"product_ui_component", countByProduct(db, &entities.ProductUIComponent{}, productIDs)}, {"component_report_download", countByProduct(db, &entities.ComponentReportDownload{}, productIDs)}, } if subs { checks = append(checks, struct { name string fn func() int64 }{"subscription", countByProduct(db, &entities.Subscription{}, productIDs)}) } for _, c := range checks { log.Printf("[dry-run] 关联 %s 约 %d 行", c.name, c.fn()) } } func countByProduct(db *gorm.DB, model interface{}, productIDs []string) func() int64 { return func() int64 { var n int64 db.Model(model).Where("product_id IN ?", productIDs).Count(&n) return n } }