package main import ( "context" "database/sql" "encoding/json" "fmt" "log" "os" "sort" "strings" "time" "github.com/redis/go-redis/v9" _ "github.com/go-sql-driver/mysql" "gopkg.in/yaml.v3" ) type appConfig struct { MySQL struct { Addr string `yaml:"Addr"` Username string `yaml:"Username"` Password string `yaml:"Password"` Dbname string `yaml:"Dbname"` Config string `yaml:"Config"` } `yaml:"MySQL"` Redis struct { Host string `yaml:"Host"` Pass string `yaml:"Pass"` DB int `yaml:"DB"` } `yaml:"Redis"` } type userRow struct { ID int64 `json:"id"` ReferCode string `json:"refer_code"` Balance int64 `json:"balance"` Commission int64 `json:"commission"` GiftAmount int64 `json:"gift_amount"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin"` ValidEmail bool `json:"valid_email"` MemberStatus string `json:"member_status"` CreatedAt time.Time `json:"created_at"` DeletedAt sql.NullTime `json:"-"` } type authMethod struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` AuthType string `json:"auth_type"` Identifier string `json:"identifier"` Verified bool `json:"verified"` CreatedAt time.Time `json:"created_at"` } type deviceInfo struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` IP string `json:"ip"` UserAgent string `json:"user_agent"` Identifier string `json:"identifier"` ShortCode string `json:"short_code"` Online bool `json:"online"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` } type subscribeInfo struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` OrderID int64 `json:"order_id"` SubscribeID int64 `json:"subscribe_id"` Token string `json:"token"` UUID string `json:"uuid"` Status uint8 `json:"status"` StartTime time.Time `json:"start_time"` ExpireTime time.Time `json:"expire_time"` } type familyInfo struct { FamilyID int64 `json:"family_id"` OwnerUserID int64 `json:"owner_user_id"` IsOwner bool `json:"is_owner"` MemberCount int64 `json:"member_count"` } type userSummary struct { User userRow `json:"user"` AuthMethods []authMethod `json:"auth_methods"` Devices []deviceInfo `json:"devices"` Subscriptions []subscribeInfo `json:"subscriptions"` Family *familyInfo `json:"family,omitempty"` OrderCount int64 `json:"order_count"` TicketCount int64 `json:"ticket_count"` TrafficLogCount int64 `json:"traffic_log_count"` SystemLogCount int64 `json:"system_log_count"` WithdrawalCount int64 `json:"withdrawal_count"` IAPTransactionCount int64 `json:"iap_transaction_count"` LogMessageCount int64 `json:"log_message_count"` OnlineRecordCount int64 `json:"online_record_count"` } type deleteResult struct { UserID int64 `json:"user_id"` DeletedDBRows []string `json:"deleted_db_rows"` DeletedRedisKeys int `json:"deleted_redis_keys"` } func must(err error) { if err != nil { log.Fatal(err) } } func main() { ctx := context.Background() cfg := loadConfig("/Users/Apple/code_vpn/vpn/ppanel-server/etc/ppanel.yaml") dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config) db, err := sql.Open("mysql", dsn) must(err) defer db.Close() must(db.PingContext(ctx)) rdb := redis.NewClient(&redis.Options{ Addr: cfg.Redis.Host, Password: cfg.Redis.Pass, DB: cfg.Redis.DB, }) defer rdb.Close() must(rdb.Ping(ctx).Err()) targetUserIDs, err := findTargetUsers(ctx, db) must(err) if len(targetUserIDs) == 0 { fmt.Println(`{"matched_users":[],"deleted":[]}`) return } summaries := make([]userSummary, 0, len(targetUserIDs)) for _, userID := range targetUserIDs { summary, sumErr := collectSummary(ctx, db, userID) must(sumErr) summaries = append(summaries, summary) } before, err := json.MarshalIndent(map[string]interface{}{ "matched_users": summaries, }, "", " ") must(err) fmt.Println(string(before)) results := make([]deleteResult, 0, len(targetUserIDs)) for _, summary := range summaries { result, delErr := deleteUser(ctx, db, rdb, summary) must(delErr) results = append(results, result) } after, err := json.MarshalIndent(map[string]interface{}{ "deleted": results, }, "", " ") must(err) fmt.Println(string(after)) } func loadConfig(path string) appConfig { content, err := os.ReadFile(path) must(err) var cfg appConfig must(yaml.Unmarshal(content, &cfg)) return cfg } func findTargetUsers(ctx context.Context, db *sql.DB) ([]int64, error) { rows, err := db.QueryContext(ctx, ` SELECT DISTINCT user_id FROM user_device WHERE user_agent LIKE ? ORDER BY user_id ASC `, "%999%") if err != nil { return nil, err } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func collectSummary(ctx context.Context, db *sql.DB, userID int64) (userSummary, error) { var summary userSummary summary.User.ID = userID err := db.QueryRowContext(ctx, ` SELECT id, refer_code, balance, commission, gift_amount, enable, is_admin, valid_email, member_status, created_at, deleted_at FROM user WHERE id = ? `, userID).Scan( &summary.User.ID, &summary.User.ReferCode, &summary.User.Balance, &summary.User.Commission, &summary.User.GiftAmount, &summary.User.Enable, &summary.User.IsAdmin, &summary.User.ValidEmail, &summary.User.MemberStatus, &summary.User.CreatedAt, &summary.User.DeletedAt, ) if err != nil { return summary, err } summary.AuthMethods, err = queryAuthMethods(ctx, db, userID) if err != nil { return summary, err } summary.Devices, err = queryDevices(ctx, db, userID) if err != nil { return summary, err } summary.Subscriptions, err = querySubscriptions(ctx, db, userID) if err != nil { return summary, err } summary.Family, err = queryFamily(ctx, db, userID) if err != nil { return summary, err } if summary.OrderCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM `order` WHERE user_id = ?", userID); err != nil { return summary, err } if summary.TicketCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM ticket WHERE user_id = ?", userID); err != nil { return summary, err } if summary.TrafficLogCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM traffic_log WHERE user_id = ?", userID); err != nil { return summary, err } if summary.SystemLogCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM system_logs WHERE object_id = ?", userID); err != nil { return summary, err } if summary.WithdrawalCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM user_withdrawal WHERE user_id = ?", userID); err != nil { return summary, err } if summary.IAPTransactionCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM apple_iap_transactions WHERE user_id = ?", userID); err != nil { return summary, err } if summary.LogMessageCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM log_message WHERE user_id = ?", userID); err != nil { return summary, err } if summary.OnlineRecordCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM user_device_online_record WHERE user_id = ?", userID); err != nil { return summary, err } return summary, nil } func queryAuthMethods(ctx context.Context, db *sql.DB, userID int64) ([]authMethod, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, auth_type, auth_identifier, verified, created_at FROM user_auth_methods WHERE user_id = ? ORDER BY id ASC `, userID) if err != nil { return nil, err } defer rows.Close() var items []authMethod for rows.Next() { var item authMethod if err := rows.Scan(&item.ID, &item.UserID, &item.AuthType, &item.Identifier, &item.Verified, &item.CreatedAt); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func queryDevices(ctx context.Context, db *sql.DB, userID int64) ([]deviceInfo, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, ip, user_agent, identifier, short_code, online, enabled, created_at FROM user_device WHERE user_id = ? ORDER BY id ASC `, userID) if err != nil { return nil, err } defer rows.Close() var items []deviceInfo for rows.Next() { var item deviceInfo if err := rows.Scan(&item.ID, &item.UserID, &item.IP, &item.UserAgent, &item.Identifier, &item.ShortCode, &item.Online, &item.Enabled, &item.CreatedAt); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func querySubscriptions(ctx context.Context, db *sql.DB, userID int64) ([]subscribeInfo, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, order_id, subscribe_id, token, uuid, status, start_time, expire_time FROM user_subscribe WHERE user_id = ? ORDER BY id ASC `, userID) if err != nil { return nil, err } defer rows.Close() var items []subscribeInfo for rows.Next() { var item subscribeInfo if err := rows.Scan(&item.ID, &item.UserID, &item.OrderID, &item.SubscribeID, &item.Token, &item.UUID, &item.Status, &item.StartTime, &item.ExpireTime); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func queryFamily(ctx context.Context, db *sql.DB, userID int64) (*familyInfo, error) { var info familyInfo err := db.QueryRowContext(ctx, ` SELECT ufm.family_id, uf.owner_user_id FROM user_family_member ufm JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL WHERE ufm.user_id = ? AND ufm.deleted_at IS NULL LIMIT 1 `, userID).Scan(&info.FamilyID, &info.OwnerUserID) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } info.IsOwner = info.OwnerUserID == userID memberCount, err := queryCount(ctx, db, ` SELECT COUNT(*) FROM user_family_member WHERE family_id = ? AND deleted_at IS NULL `, info.FamilyID) if err != nil { return nil, err } info.MemberCount = memberCount return &info, nil } func queryCount(ctx context.Context, db *sql.DB, q string, arg interface{}) (int64, error) { var count int64 err := db.QueryRowContext(ctx, q, arg).Scan(&count) return count, err } func deleteUser(ctx context.Context, db *sql.DB, rdb *redis.Client, summary userSummary) (deleteResult, error) { result := deleteResult{UserID: summary.User.ID} tx, err := db.BeginTx(ctx, nil) if err != nil { return result, err } defer tx.Rollback() if summary.Family != nil { if summary.Family.IsOwner { if res, err := tx.ExecContext(ctx, `DELETE FROM user_family_member WHERE family_id = ?`, summary.Family.FamilyID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family_member=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user_family WHERE id = ?`, summary.Family.FamilyID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family=%d", rowsAffected(res))) } } else { if res, err := tx.ExecContext(ctx, `DELETE FROM user_family_member WHERE user_id = ? AND family_id = ?`, summary.User.ID, summary.Family.FamilyID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family_member=%d", rowsAffected(res))) } } } if res, err := tx.ExecContext(ctx, `DELETE FROM user_auth_methods WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_auth_methods=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user_subscribe WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_subscribe=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user_device WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_device=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user_device_online_record WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_device_online_record=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user_withdrawal WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_withdrawal=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, "DELETE FROM `order` WHERE user_id = ?", summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("order=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM traffic_log WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("traffic_log=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM system_logs WHERE object_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("system_logs=%d", rowsAffected(res))) } var ticketIDs []int64 ticketRows, err := tx.QueryContext(ctx, `SELECT id FROM ticket WHERE user_id = ?`, summary.User.ID) if err != nil { return result, err } for ticketRows.Next() { var id int64 if err := ticketRows.Scan(&id); err != nil { ticketRows.Close() return result, err } ticketIDs = append(ticketIDs, id) } ticketRows.Close() if len(ticketIDs) > 0 { holders := strings.TrimSuffix(strings.Repeat("?,", len(ticketIDs)), ",") args := make([]interface{}, 0, len(ticketIDs)) for _, id := range ticketIDs { args = append(args, id) } if res, err := tx.ExecContext(ctx, "DELETE FROM ticket_follow WHERE ticket_id IN ("+holders+")", args...); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("ticket_follow=%d", rowsAffected(res))) } } if res, err := tx.ExecContext(ctx, `DELETE FROM ticket WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("ticket=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM apple_iap_transactions WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("apple_iap_transactions=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM log_message WHERE user_id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("log_message=%d", rowsAffected(res))) } if res, err := tx.ExecContext(ctx, `DELETE FROM user WHERE id = ?`, summary.User.ID); err != nil { return result, err } else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user=%d", rowsAffected(res))) } if err := tx.Commit(); err != nil { return result, err } redisKeys, err := cleanupRedis(ctx, rdb, summary) if err != nil { return result, err } result.DeletedRedisKeys = len(redisKeys) sort.Strings(result.DeletedDBRows) return result, nil } func cleanupRedis(ctx context.Context, rdb *redis.Client, summary userSummary) ([]string, error) { keySet := map[string]struct{}{ fmt.Sprintf("cache:user:id:%d", summary.User.ID): {}, fmt.Sprintf("cache:user:subscribe:user:%d", summary.User.ID): {}, fmt.Sprintf("cache:user:subscribe:user:%d:all", summary.User.ID): {}, fmt.Sprintf("auth:user_sessions:%d", summary.User.ID): {}, } for _, am := range summary.AuthMethods { if am.AuthType == "email" && am.Identifier != "" { keySet[fmt.Sprintf("cache:user:email:%s", am.Identifier)] = struct{}{} } } for _, sub := range summary.Subscriptions { keySet[fmt.Sprintf("cache:user:subscribe:id:%d", sub.ID)] = struct{}{} if sub.Token != "" { keySet[fmt.Sprintf("cache:user:subscribe:token:%s", sub.Token)] = struct{}{} } } for _, device := range summary.Devices { keySet[fmt.Sprintf("cache:user:device:id:%d", device.ID)] = struct{}{} if device.Identifier != "" { keySet[fmt.Sprintf("cache:user:device:number:%s", device.Identifier)] = struct{}{} keySet[fmt.Sprintf("auth:device_identifier:%s", device.Identifier)] = struct{}{} } } sessionsKey := fmt.Sprintf("auth:user_sessions:%d", summary.User.ID) sessionIDs, err := rdb.ZRange(ctx, sessionsKey, 0, -1).Result() if err != nil && err != redis.Nil { return nil, err } for _, sessionID := range sessionIDs { if sessionID == "" { continue } keySet[fmt.Sprintf("auth:session_id:%s", sessionID)] = struct{}{} keySet[fmt.Sprintf("auth:session_id:detail:%s", sessionID)] = struct{}{} } var cursor uint64 for { keys, nextCursor, scanErr := rdb.Scan(ctx, cursor, "auth:session_id:*", 200).Result() if scanErr != nil { return nil, scanErr } for _, key := range keys { if strings.Contains(key, ":detail:") { continue } value, getErr := rdb.Get(ctx, key).Result() if getErr != nil { continue } if value == fmt.Sprintf("%d", summary.User.ID) { keySet[key] = struct{}{} sessionID := strings.TrimPrefix(key, "auth:session_id:") if sessionID != "" { keySet[fmt.Sprintf("auth:session_id:detail:%s", sessionID)] = struct{}{} } } } cursor = nextCursor if cursor == 0 { break } } keys := make([]string, 0, len(keySet)) for key := range keySet { keys = append(keys, key) } sort.Strings(keys) if len(keys) == 0 { return keys, nil } if err := rdb.Del(ctx, keys...).Err(); err != nil { return nil, err } return keys, nil } func rowsAffected(res sql.Result) int64 { if res == nil { return 0 } n, err := res.RowsAffected() if err != nil { return 0 } return n }