Chore: project init
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
25
.gitignore
vendored
Normal 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
48
cmd/seed/main.go
Normal 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
32
go.mod
Normal 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
55
go.sum
Normal 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=
|
||||||
56
internal/announcement/handler.go
Normal file
56
internal/announcement/handler.go
Normal 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)
|
||||||
|
}
|
||||||
9
internal/announcement/model.go
Normal file
9
internal/announcement/model.go
Normal 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"`
|
||||||
|
}
|
||||||
35
internal/announcement/repository.go
Normal file
35
internal/announcement/repository.go
Normal 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
|
||||||
|
}
|
||||||
38
internal/announcement/service.go
Normal file
38
internal/announcement/service.go
Normal 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
41
internal/auth/handler.go
Normal 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
17
internal/auth/model.go
Normal 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'"`
|
||||||
|
}
|
||||||
21
internal/auth/repository.go
Normal file
21
internal/auth/repository.go
Normal 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
66
internal/auth/service.go
Normal 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)
|
||||||
|
}
|
||||||
36
internal/download/handler.go
Normal file
36
internal/download/handler.go
Normal 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)
|
||||||
|
}
|
||||||
11
internal/download/model.go
Normal file
11
internal/download/model.go
Normal 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"`
|
||||||
|
}
|
||||||
24
internal/download/repository.go
Normal file
24
internal/download/repository.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/download/service.go
Normal file
25
internal/download/service.go
Normal 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
57
main.go
Normal 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
48
pkg/config/config.go
Normal 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
24
pkg/database/mysql.go
Normal 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
18
pkg/database/redis.go
Normal 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
54
pkg/middleware/auth.go
Normal 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
35
routes/routes.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user