diff --git a/cmd/server/main.go b/cmd/server/main.go index 173096a..bc95979 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,13 +3,18 @@ package main import ( "context" "errors" + "fmt" "log" "net/url" "os" + "path/filepath" + "strconv" "strings" + "sync" "time" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/logger" "geoip-rest/internal/geo" "geoip-rest/internal/schedule" @@ -45,6 +50,8 @@ func main() { ReadBufferSize: 16 * 1024, // allow larger request headers (e.g., proxy cookies) }) + app.Use(newFileLogger(env("ACCESS_LOG_PATH", "/log/api-access.log"))) + app.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "service": "geoip-rest", @@ -107,6 +114,86 @@ func main() { } } +func newFileLogger(path string) fiber.Handler { + if path == "" { + return func(c *fiber.Ctx) error { return c.Next() } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + log.Printf("access log disabled (mkdir failed: %v)", err) + return func(c *fiber.Ctx) error { return c.Next() } + } + maxBytes := int64(envInt("ACCESS_LOG_MAX_BYTES", 10*1024*1024)) + writer, err := newRotatingWriter(path, maxBytes) + if err != nil { + log.Printf("access log disabled (open failed: %v)", err) + return func(c *fiber.Ctx) error { return c.Next() } + } + + format := "${time} ${ip} ${method} ${path} ${protocol} ${status} ${latency_human} headers=${reqHeaders}\n" + cfg := logger.Config{ + Format: format, + TimeFormat: time.RFC3339, + TimeZone: "Asia/Seoul", + Output: writer, + } + return logger.New(cfg) +} + +type rotatingWriter struct { + mu sync.Mutex + path string + maxBytes int64 + file *os.File +} + +func newRotatingWriter(path string, maxBytes int64) (*rotatingWriter, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, err + } + return &rotatingWriter{ + path: path, + maxBytes: maxBytes, + file: f, + }, nil +} + +func (w *rotatingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if err := w.rotateIfNeeded(len(p)); err != nil { + return 0, err + } + return w.file.Write(p) +} + +func (w *rotatingWriter) rotateIfNeeded(incoming int) error { + info, err := w.file.Stat() + if err != nil { + return err + } + if info.Size()+int64(incoming) <= w.maxBytes { + return nil + } + _ = w.file.Close() + + ts := time.Now().Format("20060102-150405") + rotated := fmt.Sprintf("%s.%s", w.path, ts) + if err := os.Rename(w.path, rotated); err != nil { + // attempt to reopen original to keep logging + w.file, _ = os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + return err + } + + f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + w.file = f + return nil +} + func env(key, fallback string) string { if val := os.Getenv(key); val != "" { return val @@ -129,6 +216,18 @@ func envBool(key string, fallback bool) bool { } } +func envInt(key string, fallback int) int { + val := os.Getenv(key) + if val == "" { + return fallback + } + parsed, err := strconv.Atoi(val) + if err != nil { + return fallback + } + return parsed +} + func sanitizeDBURL(raw string) string { u, err := url.Parse(raw) if err != nil { diff --git a/cmd/user_program_import/main.go b/cmd/user_program_import/main.go index d972be6..9bb32af 100644 --- a/cmd/user_program_import/main.go +++ b/cmd/user_program_import/main.go @@ -29,7 +29,7 @@ func main() { csvPath := env("USER_PROGRAM_INFO_CSV", defaultCSVPath) updateDir := env("USER_PROGRAM_UPDATE_DIR", defaultUpdateDir) - schema := env("USER_PROGRAM_INFO_SCHEMA", defaultSchema) + schema := env("USER_PROGRAM_INFO_SCHEMA", env("POSTGRES_SCHEMA", defaultSchema)) logDir := env("USER_PROGRAM_IMPORT_LOG_DIR", defaultLogDir) ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) diff --git a/internal/importer/user_program_info.go b/internal/importer/user_program_info.go index 9c1137f..c162ae9 100644 --- a/internal/importer/user_program_info.go +++ b/internal/importer/user_program_info.go @@ -49,6 +49,8 @@ var ( timeLayouts = []string{ "2006-01-02 15:04:05.000", "2006-01-02 15:04:05", + time.RFC3339, + "2006-01-02T15:04:05.000Z07:00", } ) @@ -63,6 +65,10 @@ func EnsureUserProgramReplica(ctx context.Context, conn *pgx.Conn, csvPath, sche logDir = "log" } + if err := ensureSchema(ctx, conn, schema); err != nil { + return err + } + if err := createReplicaTable(ctx, conn, schema, ReplicaTable); err != nil { return err } @@ -143,6 +149,14 @@ CREATE TABLE IF NOT EXISTS %s ( return err } +func ensureSchema(ctx context.Context, conn *pgx.Conn, schema string) error { + if schema == "" { + return nil + } + _, err := conn.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, pgx.Identifier{schema}.Sanitize())) + return err +} + type importResult struct { rowsCopied int64 rowsUpserted int64 diff --git a/internal/userprogram/config.go b/internal/userprogram/config.go index 43311bc..9b20c1c 100644 --- a/internal/userprogram/config.go +++ b/internal/userprogram/config.go @@ -69,11 +69,12 @@ func NewMySQLConfigFromEnv() (MySQLConfig, error) { } func NewPathsFromEnv() (Paths, error) { + schema := env("USER_PROGRAM_INFO_SCHEMA", env("POSTGRES_SCHEMA", DefaultSchema)) paths := Paths{ UpdateDir: env("USER_PROGRAM_UPDATE_DIR", DefaultUpdateDir), LogDir: env("USER_PROGRAM_IMPORT_LOG_DIR", DefaultLogDir), InitialCSV: env("USER_PROGRAM_INFO_CSV", DefaultInitialCSV), - Schema: env("USER_PROGRAM_INFO_SCHEMA", DefaultSchema), + Schema: schema, } for _, dir := range []string{paths.UpdateDir, paths.LogDir} { diff --git a/internal/userprogram/dumper.go b/internal/userprogram/dumper.go index 4971f51..c10a39a 100644 --- a/internal/userprogram/dumper.go +++ b/internal/userprogram/dumper.go @@ -188,8 +188,8 @@ func scanRow(rows *sql.Rows) ([]string, error) { userCompany sql.NullString userDepartment sql.NullString userPosition sql.NullString - userLoginTime sql.NullString - createdAt sql.NullString + userLoginTime sql.NullTime + createdAt sql.NullTime userFamilyFlag sql.NullString ) @@ -225,8 +225,8 @@ func scanRow(rows *sql.Rows) ([]string, error) { nullToString(userCompany), nullToString(userDepartment), nullToString(userPosition), - nullToString(userLoginTime), - nullToString(createdAt), + formatTimestamp(userLoginTime), + formatTimestamp(createdAt), nullToString(userFamilyFlag), }, nil } @@ -241,3 +241,10 @@ func nullToString(v sql.NullString) string { func netAddr(host string, port int) string { return fmt.Sprintf("%s:%d", host, port) } + +func formatTimestamp(t sql.NullTime) string { + if !t.Valid { + return "" + } + return t.Time.In(kst()).Format("2006-01-02 15:04:05.000") +}