backend: add bookmark CRUD with public list endpoint
New bookmarks feature: domain model, repository, service and handler supporting list/create/update/delete. Public endpoint exposes the admin user's bookmarks for the homepage navigation grid; authenticated endpoints scope by user. Dev Dockerfile drops air for plain `go run` and uses goproxy.cn to avoid build failures on the deploy host. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
16
backend/internal/domain/bookmark.go
Normal file
16
backend/internal/domain/bookmark.go
Normal file
@@ -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"`
|
||||
}
|
||||
160
backend/internal/handler/bookmark.go
Normal file
160
backend/internal/handler/bookmark.go
Normal file
@@ -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)
|
||||
}
|
||||
51
backend/internal/repository/bookmark.go
Normal file
51
backend/internal/repository/bookmark.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
87
backend/internal/service/bookmark.go
Normal file
87
backend/internal/service/bookmark.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user