diff --git a/backend/Dockerfile b/backend/Dockerfile index 867d62d..4465db5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,14 +9,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/api/main.go FROM golang:1.25-alpine AS dev -RUN go install github.com/air-verse/air@latest +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=off WORKDIR /app COPY go.mod go.sum ./ RUN go mod download EXPOSE 8080 -CMD ["air", "-c", ".air.toml"] +CMD ["go", "run", "./cmd/api/main.go"] FROM alpine:latest AS production diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index d178bf5..82c0618 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -18,7 +18,7 @@ func Init(cfg *config.Config) *gorm.DB { log.Fatalf("failed to connect database: %v", err) } - if err := DB.AutoMigrate(&domain.User{}); err != nil { + if err := DB.AutoMigrate(&domain.User{}, &domain.Bookmark{}); err != nil { log.Fatalf("failed to migrate database: %v", err) } diff --git a/backend/internal/domain/bookmark.go b/backend/internal/domain/bookmark.go new file mode 100644 index 0000000..06e9877 --- /dev/null +++ b/backend/internal/domain/bookmark.go @@ -0,0 +1,16 @@ +package domain + +import "time" + +type Bookmark struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"userId"` + Title string `gorm:"not null" json:"title"` + URL string `gorm:"not null" json:"url"` + Description string `json:"description"` + Icon string `json:"icon"` + Category string `gorm:"default:'默认'" json:"category"` + SortOrder int `gorm:"default:0" json:"sortOrder"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/handler/bookmark.go b/backend/internal/handler/bookmark.go new file mode 100644 index 0000000..cc589f4 --- /dev/null +++ b/backend/internal/handler/bookmark.go @@ -0,0 +1,160 @@ +package handler + +import ( + "net/http" + "strconv" + + "evanpage-backend/internal/domain" + "evanpage-backend/internal/service" + "github.com/gin-gonic/gin" +) + +type BookmarkHandler struct { + bookmarkService *service.BookmarkService + userService *service.UserService +} + +func NewBookmarkHandler(bookmarkService *service.BookmarkService, userService *service.UserService) *BookmarkHandler { + return &BookmarkHandler{bookmarkService: bookmarkService, userService: userService} +} + +func getUserID(c *gin.Context) uint { + uid, _ := c.Get("userID") + id, _ := strconv.ParseUint(uid.(string), 10, 64) + return uint(id) +} + +func (h *BookmarkHandler) Create(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` + URL string `json:"url" binding:"required"` + Description string `json:"description"` + Icon string `json:"icon"` + Category string `json:"category"` + SortOrder int `json:"sortOrder"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + bm, err := h.bookmarkService.Create(userID, req.Title, req.URL, req.Description, req.Icon, req.Category, req.SortOrder) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, bm) +} + +func (h *BookmarkHandler) List(c *gin.Context) { + userID := getUserID(c) + category := c.Query("category") + + var bms []domain.Bookmark + var err error + + if category != "" { + bms, err = h.bookmarkService.ListByUserAndCategory(userID, category) + } else { + bms, err = h.bookmarkService.ListByUser(userID) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + categories, _ := h.bookmarkService.ListCategoriesByUser(userID) + + c.JSON(http.StatusOK, gin.H{ + "bookmarks": bms, + "categories": categories, + }) +} + +func (h *BookmarkHandler) PublicList(c *gin.Context) { + admin, err := h.userService.FindByRole("admin") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "bookmarks": []domain.Bookmark{}, + "categories": []string{}, + }) + return + } + + category := c.Query("category") + var bms []domain.Bookmark + if category != "" { + bms, err = h.bookmarkService.ListByUserAndCategory(admin.ID, category) + } else { + bms, err = h.bookmarkService.ListByUser(admin.ID) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + categories, _ := h.bookmarkService.ListCategoriesByUser(admin.ID) + + c.JSON(http.StatusOK, gin.H{ + "bookmarks": bms, + "categories": categories, + }) +} + +func (h *BookmarkHandler) Update(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + Icon string `json:"icon"` + Category string `json:"category"` + SortOrder int `json:"sortOrder"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + bm, err := h.bookmarkService.Update(userID, uint(id), req.Title, req.URL, req.Description, req.Icon, req.Category, req.SortOrder) + if err != nil { + if err.Error() == "forbidden" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, bm) +} + +func (h *BookmarkHandler) Delete(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + userID := getUserID(c) + if err := h.bookmarkService.Delete(userID, uint(id)); err != nil { + if err.Error() == "forbidden" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/backend/internal/repository/bookmark.go b/backend/internal/repository/bookmark.go new file mode 100644 index 0000000..0483852 --- /dev/null +++ b/backend/internal/repository/bookmark.go @@ -0,0 +1,51 @@ +package repository + +import ( + "evanpage-backend/internal/domain" + + "gorm.io/gorm" +) + +type BookmarkRepository struct { + db *gorm.DB +} + +func NewBookmarkRepository(db *gorm.DB) *BookmarkRepository { + return &BookmarkRepository{db: db} +} + +func (r *BookmarkRepository) Create(bm *domain.Bookmark) error { + return r.db.Create(bm).Error +} + +func (r *BookmarkRepository) FindByID(id uint) (*domain.Bookmark, error) { + var bm domain.Bookmark + err := r.db.First(&bm, id).Error + return &bm, err +} + +func (r *BookmarkRepository) ListByUser(userID uint) ([]domain.Bookmark, error) { + var bms []domain.Bookmark + err := r.db.Where("user_id = ?", userID).Order("sort_order asc, created_at desc").Find(&bms).Error + return bms, err +} + +func (r *BookmarkRepository) ListByUserAndCategory(userID uint, category string) ([]domain.Bookmark, error) { + var bms []domain.Bookmark + err := r.db.Where("user_id = ? AND category = ?", userID, category).Order("sort_order asc, created_at desc").Find(&bms).Error + return bms, err +} + +func (r *BookmarkRepository) ListCategoriesByUser(userID uint) ([]string, error) { + var categories []string + err := r.db.Model(&domain.Bookmark{}).Where("user_id = ?", userID).Distinct().Pluck("category", &categories).Error + return categories, err +} + +func (r *BookmarkRepository) Update(bm *domain.Bookmark) error { + return r.db.Save(bm).Error +} + +func (r *BookmarkRepository) Delete(id uint) error { + return r.db.Delete(&domain.Bookmark{}, id).Error +} diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index 550a733..b130d25 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -41,6 +41,12 @@ func (r *UserRepository) FindByKeycloakID(keycloakID string) (*domain.User, erro return &user, err } +func (r *UserRepository) FindByRole(role string) (*domain.User, error) { + var user domain.User + err := r.db.Where("role = ?", role).First(&user).Error + return &user, err +} + func (r *UserRepository) ListAll() ([]domain.User, error) { var users []domain.User err := r.db.Select("id", "username", "email", "role", "keycloak_id", "created_at", "updated_at").Find(&users).Error diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 847357b..bead4eb 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -20,8 +20,12 @@ func Setup(cfg *config.Config) *gin.Engine { userRepo := repository.NewUserRepository(db.DB) userService := service.NewUserService(userRepo) + bookmarkRepo := repository.NewBookmarkRepository(db.DB) + bookmarkService := service.NewBookmarkService(bookmarkRepo) + authHandler := handler.NewAuthHandler(userService) healthHandler := handler.NewHealthHandler(db.DB) + bookmarkHandler := handler.NewBookmarkHandler(bookmarkService, userService) // Public routes r.POST("/api/auth/local-login", authHandler.LocalLogin) @@ -29,6 +33,17 @@ func Setup(cfg *config.Config) *gin.Engine { r.POST("/api/auth/bind-keycloak", authHandler.BindKeycloak) r.POST("/api/auth/init", authHandler.InitAdmin) r.GET("/api/health", healthHandler.Check) + r.GET("/api/bookmarks/public", bookmarkHandler.PublicList) + + // Authenticated routes + auth := r.Group("/api") + auth.Use(middleware.AuthProxy()) + { + auth.GET("/bookmarks", bookmarkHandler.List) + auth.POST("/bookmarks", bookmarkHandler.Create) + auth.PUT("/bookmarks/:id", bookmarkHandler.Update) + auth.DELETE("/bookmarks/:id", bookmarkHandler.Delete) + } return r } diff --git a/backend/internal/service/bookmark.go b/backend/internal/service/bookmark.go new file mode 100644 index 0000000..5b1c22c --- /dev/null +++ b/backend/internal/service/bookmark.go @@ -0,0 +1,87 @@ +package service + +import ( + "errors" + + "evanpage-backend/internal/domain" + "evanpage-backend/internal/repository" +) + +type BookmarkService struct { + repo *repository.BookmarkRepository +} + +func NewBookmarkService(repo *repository.BookmarkRepository) *BookmarkService { + return &BookmarkService{repo: repo} +} + +func (s *BookmarkService) Create(userID uint, title, url, description, icon, category string, sortOrder int) (*domain.Bookmark, error) { + if title == "" || url == "" { + return nil, errors.New("title and url are required") + } + if category == "" { + category = "默认" + } + bm := &domain.Bookmark{ + UserID: userID, + Title: title, + URL: url, + Description: description, + Icon: icon, + Category: category, + SortOrder: sortOrder, + } + if err := s.repo.Create(bm); err != nil { + return nil, err + } + return bm, nil +} + +func (s *BookmarkService) ListByUser(userID uint) ([]domain.Bookmark, error) { + return s.repo.ListByUser(userID) +} + +func (s *BookmarkService) ListByUserAndCategory(userID uint, category string) ([]domain.Bookmark, error) { + return s.repo.ListByUserAndCategory(userID, category) +} + +func (s *BookmarkService) ListCategoriesByUser(userID uint) ([]string, error) { + return s.repo.ListCategoriesByUser(userID) +} + +func (s *BookmarkService) Update(userID, bookmarkID uint, title, url, description, icon, category string, sortOrder int) (*domain.Bookmark, error) { + bm, err := s.repo.FindByID(bookmarkID) + if err != nil { + return nil, errors.New("bookmark not found") + } + if bm.UserID != userID { + return nil, errors.New("forbidden") + } + if title != "" { + bm.Title = title + } + if url != "" { + bm.URL = url + } + bm.Description = description + bm.Icon = icon + if category != "" { + bm.Category = category + } + bm.SortOrder = sortOrder + if err := s.repo.Update(bm); err != nil { + return nil, err + } + return bm, nil +} + +func (s *BookmarkService) Delete(userID, bookmarkID uint) error { + bm, err := s.repo.FindByID(bookmarkID) + if err != nil { + return errors.New("bookmark not found") + } + if bm.UserID != userID { + return errors.New("forbidden") + } + return s.repo.Delete(bookmarkID) +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 27ab029..be1f326 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -93,6 +93,10 @@ func (s *UserService) BindKeycloak(username, password, keycloakID, keycloakEmail return user, nil } +func (s *UserService) FindByRole(role string) (*domain.User, error) { + return s.repo.FindByRole(role) +} + func (s *UserService) ListUsers() ([]domain.User, error) { return s.repo.ListAll() }