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