Chore: project init

This commit is contained in:
2026-02-24 13:18:43 +09:00
commit 3345549051
23 changed files with 788 additions and 0 deletions

13
.env.example Normal file
View File

@@ -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

25
.gitignore vendored Normal file
View File

@@ -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

48
cmd/seed/main.go Normal file
View File

@@ -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
}

32
go.mod Normal file
View File

@@ -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
)

55
go.sum Normal file
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)
}

41
internal/auth/handler.go Normal file
View File

@@ -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": "로그아웃 되었습니다"})
}

17
internal/auth/model.go Normal file
View File

@@ -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'"`
}

View File

@@ -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
}

66
internal/auth/service.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)
}

57
main.go Normal file
View File

@@ -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))
}

48
pkg/config/config.go Normal file
View File

@@ -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
}

24
pkg/database/mysql.go Normal file
View File

@@ -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
}

18
pkg/database/redis.go Normal file
View File

@@ -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()
}

54
pkg/middleware/auth.go Normal file
View File

@@ -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()
}

35
routes/routes.go Normal file
View File

@@ -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)
}