package main import ( "context" "database/sql" "encoding/csv" "fmt" "log" "os" "path/filepath" "regexp" "strconv" "time" "github.com/go-sql-driver/mysql" ) const ( defaultUpdateDir = "./update_data" defaultTable = "user_program_info" defaultDB = "user_program_info" defaultDumpTimeout = 5 * time.Minute ) type config struct { host string port int user string password string database string table string updateDir string target time.Time } func main() { logger := log.New(os.Stdout, "[dump] ", log.LstdFlags) cfg, err := loadConfig() if err != nil { log.Fatalf("config error: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), defaultDumpTimeout) defer cancel() dsn := (&mysql.Config{ User: cfg.user, Passwd: cfg.password, Net: "tcp", Addr: netAddr(cfg.host, cfg.port), DBName: cfg.database, Params: map[string]string{"parseTime": "true", "loc": "UTC", "charset": "utf8mb4"}, AllowNativePasswords: true, }).FormatDSN() db, err := sql.Open("mysql", dsn) if err != nil { log.Fatalf("failed to open mysql connection: %v", err) } defer db.Close() if err := db.PingContext(ctx); err != nil { log.Fatalf("failed to ping mysql: %v", err) } if _, err := db.ExecContext(ctx, "SET time_zone = '+00:00'"); err != nil { log.Fatalf("failed to set timezone: %v", err) } outPath, err := dumpToCSV(ctx, db, cfg) if err != nil { log.Fatalf("dump failed: %v", err) } logger.Printf("dumped %s to %s", cfg.target.Format("2006-01-02"), outPath) } func loadConfig() (config, error) { port, err := strconv.Atoi(env("USER_PROGRAM_INFO_PORT", "3306")) if err != nil { return config{}, fmt.Errorf("invalid USER_PROGRAM_INFO_PORT: %w", err) } target, err := resolveTargetDate(env("USER_PROGRAM_TARGET_DATE", "")) if err != nil { return config{}, err } table := env("USER_PROGRAM_INFO_TABLE", defaultTable) if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(table) { return config{}, fmt.Errorf("invalid table name: %s", table) } updateDir := env("USER_PROGRAM_UPDATE_DIR", defaultUpdateDir) if updateDir == "" { updateDir = defaultUpdateDir } if err := os.MkdirAll(updateDir, 0o755); err != nil { return config{}, fmt.Errorf("creating update dir: %w", err) } return config{ host: envRequired("USER_PROGRAM_INFO_HOST"), port: port, user: envRequired("USER_PROGRAM_INFO_USERNAME"), password: envRequired("USER_PROGRAM_INFO_PASSWORD"), database: env("USER_PROGRAM_INFO_DB", defaultDB), table: table, updateDir: updateDir, target: target, }, nil } func dumpToCSV(ctx context.Context, db *sql.DB, cfg config) (string, error) { query := fmt.Sprintf(` SELECT id, product_name, login_id, user_employee_id, login_version, login_public_ip, login_local_ip, user_company, user_department, user_position, user_login_time, created_at, user_family_flag FROM %s WHERE DATE(CONVERT_TZ(created_at, '+00:00', '+09:00')) = ?;`, cfg.table) rows, err := db.QueryContext(ctx, query, cfg.target.Format("2006-01-02")) if err != nil { return "", err } defer rows.Close() filename := fmt.Sprintf("user_program_info_%s.csv", cfg.target.Format("20060102")) outPath := filepath.Join(cfg.updateDir, filename) tmpPath := outPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return "", err } defer f.Close() writer := csv.NewWriter(f) defer writer.Flush() header := []string{ "id", "product_name", "login_id", "user_employee_id", "login_version", "login_public_ip", "login_local_ip", "user_company", "user_department", "user_position", "user_login_time", "created_at", "user_family_flag", } if err := writer.Write(header); err != nil { return "", err } for rows.Next() { record, err := scanRow(rows) if err != nil { return "", err } if err := writer.Write(record); err != nil { return "", err } } if err := rows.Err(); err != nil { return "", err } writer.Flush() if err := writer.Error(); err != nil { return "", err } if err := os.Rename(tmpPath, outPath); err != nil { return "", err } return outPath, nil } func scanRow(rows *sql.Rows) ([]string, error) { var ( id sql.NullInt64 productName sql.NullString loginID sql.NullString employeeID sql.NullString loginVersion sql.NullString loginPublicIP sql.NullString loginLocalIP sql.NullString userCompany sql.NullString userDepartment sql.NullString userPosition sql.NullString userLoginTime sql.NullString createdAt sql.NullString userFamilyFlag sql.NullString ) if err := rows.Scan( &id, &productName, &loginID, &employeeID, &loginVersion, &loginPublicIP, &loginLocalIP, &userCompany, &userDepartment, &userPosition, &userLoginTime, &createdAt, &userFamilyFlag, ); err != nil { return nil, err } if !id.Valid { return nil, fmt.Errorf("row missing id") } return []string{ strconv.FormatInt(id.Int64, 10), nullToString(productName), nullToString(loginID), nullToString(employeeID), nullToString(loginVersion), nullToString(loginPublicIP), nullToString(loginLocalIP), nullToString(userCompany), nullToString(userDepartment), nullToString(userPosition), nullToString(userLoginTime), nullToString(createdAt), nullToString(userFamilyFlag), }, nil } func resolveTargetDate(raw string) (time.Time, error) { if raw == "" { now := time.Now().In(kst()) yesterday := now.AddDate(0, 0, -1) return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, kst()), nil } t, err := time.ParseInLocation("2006-01-02", raw, kst()) if err != nil { return time.Time{}, fmt.Errorf("invalid USER_PROGRAM_TARGET_DATE: %w", err) } return t, nil } func kst() *time.Location { loc, err := time.LoadLocation("Asia/Seoul") if err != nil { return time.FixedZone("KST", 9*60*60) } return loc } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envRequired(key string) string { v := os.Getenv(key) if v == "" { log.Fatalf("%s is required", key) } return v } func netAddr(host string, port int) string { return fmt.Sprintf("%s:%d", host, port) } func nullToString(v sql.NullString) string { if v.Valid { return v.String } return "" }