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:
2026-05-03 01:51:43 +08:00
parent efd644dc67
commit ffecc9451d
9 changed files with 343 additions and 3 deletions

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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