commit 3345549051db4c6d4dacd65e12dc34005ae95112 Author: tolelom <98kimsungmin@naver.com> Date: Tue Feb 24 13:18:43 2026 +0900 Chore: project init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a92cae5 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +APP_PORT=8080 + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=password +DB_NAME=a301 + +REDIS_ADDR=localhost:6379 +REDIS_PASSWORD= + +JWT_SECRET=your-secret-key-here +JWT_EXPIRY_HOURS=24 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fb34fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Environment +.env + +# Build output +*.exe +*.exe~ +*.dll +*.so +*.dylib +dist/ + +# Test & coverage +*.test +*.out +coverage.html + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..b182ba4 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "log" + "os" + + "a301_server/internal/auth" + "a301_server/pkg/config" + "a301_server/pkg/database" + "golang.org/x/crypto/bcrypt" +) + +func main() { + config.Load() + + if err := database.ConnectMySQL(); err != nil { + log.Fatalf("MySQL 연결 실패: %v", err) + } + + username := getArg(1, "admin") + password := getArg(2, "admin1234") + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("비밀번호 해시 실패: %v", err) + } + + user := auth.User{ + Username: username, + PasswordHash: string(hash), + Role: auth.RoleAdmin, + } + + repo := auth.NewRepository(database.DB) + if err := repo.Create(&user); err != nil { + log.Fatalf("관리자 계정 생성 실패: %v", err) + } + + fmt.Printf("관리자 계정 생성 완료\n 아이디: %s\n 비밀번호: %s\n", username, password) +} + +func getArg(index int, fallback string) string { + if len(os.Args) > index { + return os.Args[index] + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8e16b3b --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module a301_server + +go 1.25 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/gofiber/fiber/v2 v2.52.11 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fba2253 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs= +github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go new file mode 100644 index 0000000..0026011 --- /dev/null +++ b/internal/announcement/handler.go @@ -0,0 +1,56 @@ +package announcement + +import "github.com/gofiber/fiber/v2" + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) GetAll(c *fiber.Ctx) error { + list, err := h.svc.GetAll() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항을 불러오지 못했습니다"}) + } + return c.JSON(list) +} + +func (h *Handler) Create(c *fiber.Ctx) error { + var body struct { + Title string `json:"title"` + Content string `json:"content"` + } + if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목과 내용을 입력해주세요"}) + } + a, err := h.svc.Create(body.Title, body.Content) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"}) + } + return c.Status(fiber.StatusCreated).JSON(a) +} + +func (h *Handler) Update(c *fiber.Ctx) error { + var body struct { + Title string `json:"title"` + Content string `json:"content"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + a, err := h.svc.Update(c.Params("id"), body.Title, body.Content) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(a) +} + +func (h *Handler) Delete(c *fiber.Ctx) error { + if err := h.svc.Delete(c.Params("id")); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "삭제에 실패했습니다"}) + } + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/internal/announcement/model.go b/internal/announcement/model.go new file mode 100644 index 0000000..0587632 --- /dev/null +++ b/internal/announcement/model.go @@ -0,0 +1,9 @@ +package announcement + +import "gorm.io/gorm" + +type Announcement struct { + gorm.Model + Title string `gorm:"not null"` + Content string `gorm:"type:text;not null"` +} diff --git a/internal/announcement/repository.go b/internal/announcement/repository.go new file mode 100644 index 0000000..1e6248a --- /dev/null +++ b/internal/announcement/repository.go @@ -0,0 +1,35 @@ +package announcement + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) FindAll() ([]Announcement, error) { + var list []Announcement + err := r.db.Order("created_at desc").Find(&list).Error + return list, err +} + +func (r *Repository) FindByID(id string) (*Announcement, error) { + var a Announcement + err := r.db.First(&a, id).Error + return &a, err +} + +func (r *Repository) Create(a *Announcement) error { + return r.db.Create(a).Error +} + +func (r *Repository) Save(a *Announcement) error { + return r.db.Save(a).Error +} + +func (r *Repository) Delete(id string) error { + return r.db.Delete(&Announcement{}, id).Error +} diff --git a/internal/announcement/service.go b/internal/announcement/service.go new file mode 100644 index 0000000..9428a87 --- /dev/null +++ b/internal/announcement/service.go @@ -0,0 +1,38 @@ +package announcement + +import "fmt" + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetAll() ([]Announcement, error) { + return s.repo.FindAll() +} + +func (s *Service) Create(title, content string) (*Announcement, error) { + a := &Announcement{Title: title, Content: content} + return a, s.repo.Create(a) +} + +func (s *Service) Update(id, title, content string) (*Announcement, error) { + a, err := s.repo.FindByID(id) + if err != nil { + return nil, fmt.Errorf("공지사항을 찾을 수 없습니다") + } + if title != "" { + a.Title = title + } + if content != "" { + a.Content = content + } + return a, s.repo.Save(a) +} + +func (s *Service) Delete(id string) error { + return s.repo.Delete(id) +} diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..ec8e612 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,41 @@ +package auth + +import "github.com/gofiber/fiber/v2" + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) Login(c *fiber.Ctx) error { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.Username == "" || req.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) + } + + tokenStr, user, err := h.svc.Login(req.Username, req.Password) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "token": tokenStr, + "username": user.Username, + "role": user.Role, + }) +} + +func (h *Handler) Logout(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + h.svc.Logout(userID) + return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"}) +} diff --git a/internal/auth/model.go b/internal/auth/model.go new file mode 100644 index 0000000..3f4e222 --- /dev/null +++ b/internal/auth/model.go @@ -0,0 +1,17 @@ +package auth + +import "gorm.io/gorm" + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null"` + PasswordHash string `gorm:"not null"` + Role Role `gorm:"default:'user'"` +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go new file mode 100644 index 0000000..07881f1 --- /dev/null +++ b/internal/auth/repository.go @@ -0,0 +1,21 @@ +package auth + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) FindByUsername(username string) (*User, error) { + var user User + err := r.db.Where("username = ?", username).First(&user).Error + return &user, err +} + +func (r *Repository) Create(user *User) error { + return r.db.Create(user).Error +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..02d77fd --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "a301_server/pkg/config" + "github.com/golang-jwt/jwt/v5" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" +) + +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type Service struct { + repo *Repository + rdb *redis.Client +} + +func NewService(repo *Repository, rdb *redis.Client) *Service { + return &Service{repo: repo, rdb: rdb} +} + +func (s *Service) Login(username, password string) (string, *User, error) { + user, err := s.repo.FindByUsername(username) + if err != nil { + return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + } + + expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour + claims := &Claims{ + UserID: user.ID, + Username: user.Username, + Role: string(user.Role), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) + if err != nil { + return "", nil, fmt.Errorf("토큰 생성에 실패했습니다") + } + + // Redis에 세션 저장 (1계정 1세션) + key := fmt.Sprintf("session:%d", user.ID) + s.rdb.Set(context.Background(), key, tokenStr, expiry) + + return tokenStr, user, nil +} + +func (s *Service) Logout(userID uint) { + key := fmt.Sprintf("session:%d", userID) + s.rdb.Del(context.Background(), key) +} diff --git a/internal/download/handler.go b/internal/download/handler.go new file mode 100644 index 0000000..0ebb15a --- /dev/null +++ b/internal/download/handler.go @@ -0,0 +1,36 @@ +package download + +import "github.com/gofiber/fiber/v2" + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) GetInfo(c *fiber.Ctx) error { + info, err := h.svc.GetInfo() + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "다운로드 정보가 없습니다"}) + } + return c.JSON(info) +} + +func (h *Handler) Upsert(c *fiber.Ctx) error { + var body struct { + URL string `json:"url"` + Version string `json:"version"` + FileName string `json:"fileName"` + FileSize string `json:"fileSize"` + } + if err := c.BodyParser(&body); err != nil || body.URL == "" || body.Version == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url과 version은 필수입니다"}) + } + info, err := h.svc.Upsert(body.URL, body.Version, body.FileName, body.FileSize) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업데이트에 실패했습니다"}) + } + return c.JSON(info) +} diff --git a/internal/download/model.go b/internal/download/model.go new file mode 100644 index 0000000..5638eab --- /dev/null +++ b/internal/download/model.go @@ -0,0 +1,11 @@ +package download + +import "gorm.io/gorm" + +type Info struct { + gorm.Model + URL string `gorm:"not null"` + Version string `gorm:"not null"` + FileName string `gorm:"not null"` + FileSize string `gorm:"not null"` +} diff --git a/internal/download/repository.go b/internal/download/repository.go new file mode 100644 index 0000000..419539b --- /dev/null +++ b/internal/download/repository.go @@ -0,0 +1,24 @@ +package download + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetLatest() (*Info, error) { + var info Info + err := r.db.Last(&info).Error + return &info, err +} + +func (r *Repository) Save(info *Info) error { + if info.ID == 0 { + return r.db.Create(info).Error + } + return r.db.Save(info).Error +} diff --git a/internal/download/service.go b/internal/download/service.go new file mode 100644 index 0000000..413f682 --- /dev/null +++ b/internal/download/service.go @@ -0,0 +1,25 @@ +package download + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetInfo() (*Info, error) { + return s.repo.GetLatest() +} + +func (s *Service) Upsert(url, version, fileName, fileSize string) (*Info, error) { + info, err := s.repo.GetLatest() + if err != nil { + info = &Info{} + } + info.URL = url + info.Version = version + info.FileName = fileName + info.FileSize = fileSize + return info, s.repo.Save(info) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6a6b4d5 --- /dev/null +++ b/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "log" + + "a301_server/internal/announcement" + "a301_server/internal/auth" + "a301_server/internal/download" + "a301_server/pkg/config" + "a301_server/pkg/database" + "a301_server/routes" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + config.Load() + + if err := database.ConnectMySQL(); err != nil { + log.Fatalf("MySQL 연결 실패: %v", err) + } + log.Println("MySQL 연결 성공") + + // AutoMigrate + database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}) + + if err := database.ConnectRedis(); err != nil { + log.Fatalf("Redis 연결 실패: %v", err) + } + log.Println("Redis 연결 성공") + + // 의존성 주입 + authRepo := auth.NewRepository(database.DB) + authSvc := auth.NewService(authRepo, database.RDB) + authHandler := auth.NewHandler(authSvc) + + annRepo := announcement.NewRepository(database.DB) + annSvc := announcement.NewService(annRepo) + annHandler := announcement.NewHandler(annSvc) + + dlRepo := download.NewRepository(database.DB) + dlSvc := download.NewService(dlRepo) + dlHandler := download.NewHandler(dlSvc) + + app := fiber.New() + app.Use(logger.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Authorization", + AllowMethods: "GET, POST, PUT, DELETE", + })) + + routes.Register(app, authHandler, annHandler, dlHandler) + + log.Fatal(app.Listen(":" + config.C.AppPort)) +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..7cdb25d --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,48 @@ +package config + +import ( + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + AppPort string + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + RedisAddr string + RedisPassword string + JWTSecret string + JWTExpiryHours int +} + +var C Config + +func Load() { + _ = godotenv.Load() + + hours, _ := strconv.Atoi(getEnv("JWT_EXPIRY_HOURS", "24")) + C = Config{ + AppPort: getEnv("APP_PORT", "8080"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnv("DB_PORT", "3306"), + DBUser: getEnv("DB_USER", "root"), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", "a301"), + RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + JWTSecret: getEnv("JWT_SECRET", "secret"), + JWTExpiryHours: hours, + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/pkg/database/mysql.go b/pkg/database/mysql.go new file mode 100644 index 0000000..21eb4d8 --- /dev/null +++ b/pkg/database/mysql.go @@ -0,0 +1,24 @@ +package database + +import ( + "fmt" + + "a301_server/pkg/config" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func ConnectMySQL() error { + c := config.C + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName, + ) + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return err + } + DB = db + return nil +} diff --git a/pkg/database/redis.go b/pkg/database/redis.go new file mode 100644 index 0000000..c76d8b8 --- /dev/null +++ b/pkg/database/redis.go @@ -0,0 +1,18 @@ +package database + +import ( + "context" + + "a301_server/pkg/config" + "github.com/redis/go-redis/v9" +) + +var RDB *redis.Client + +func ConnectRedis() error { + RDB = redis.NewClient(&redis.Options{ + Addr: config.C.RedisAddr, + Password: config.C.RedisPassword, + }) + return RDB.Ping(context.Background()).Err() +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go new file mode 100644 index 0000000..598a99d --- /dev/null +++ b/pkg/middleware/auth.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "context" + "fmt" + "strings" + + "a301_server/pkg/config" + "a301_server/pkg/database" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +func Auth(c *fiber.Ctx) error { + header := c.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증이 필요합니다"}) + } + tokenStr := strings.TrimPrefix(header, "Bearer ") + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(config.C.JWTSecret), nil + }) + if err != nil || !token.Valid { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + } + + claims := token.Claims.(jwt.MapClaims) + userID := uint(claims["user_id"].(float64)) + username := claims["username"].(string) + role := claims["role"].(string) + + // Redis 세션 확인 + key := fmt.Sprintf("session:%d", userID) + stored, err := database.RDB.Get(context.Background(), key).Result() + if err != nil || stored != tokenStr { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"}) + } + + c.Locals("userID", userID) + c.Locals("username", username) + c.Locals("role", role) + return c.Next() +} + +func AdminOnly(c *fiber.Ctx) error { + if c.Locals("role") != "admin" { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "관리자 권한이 필요합니다"}) + } + return c.Next() +} diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..840a46d --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,35 @@ +package routes + +import ( + "a301_server/internal/announcement" + "a301_server/internal/auth" + "a301_server/internal/download" + "a301_server/pkg/middleware" + "github.com/gofiber/fiber/v2" +) + +func Register( + app *fiber.App, + authH *auth.Handler, + annH *announcement.Handler, + dlH *download.Handler, +) { + api := app.Group("/api") + + // Auth + a := api.Group("/auth") + a.Post("/login", authH.Login) + a.Post("/logout", middleware.Auth, authH.Logout) + + // Announcements + ann := api.Group("/announcements") + ann.Get("/", annH.GetAll) + ann.Post("/", middleware.Auth, middleware.AdminOnly, annH.Create) + ann.Put("/:id", middleware.Auth, middleware.AdminOnly, annH.Update) + ann.Delete("/:id", middleware.Auth, middleware.AdminOnly, annH.Delete) + + // Download + dl := api.Group("/download") + dl.Get("/info", dlH.GetInfo) + dl.Put("/info", middleware.Auth, middleware.AdminOnly, dlH.Upsert) +}