Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func main() {
albumService := service.NewAlbumService(config.DB)
linkService := service.NewLinkService(config.DB)
blogInfoService := service.NewBlogInfoService(config.DB)
aboutService := service.NewAboutService(config.DB)
logService := service.NewLogService(config.DB)
uploadService := service.NewUploadService(cfg.Upload.Mode, cfg.Upload.LocalPath, cfg.Upload.MaxSize)

Expand All @@ -76,6 +77,7 @@ func main() {
albumHandler := handler.NewAlbumHandler(albumService)
linkHandler := handler.NewLinkHandler(linkService)
blogInfoHandler := handler.NewBlogInfoHandler(blogInfoService, logService)
aboutHandler := handler.NewAboutHandler(aboutService)
uploadHandler := handler.NewUploadHandler(uploadService)

deps := &handler.Deps{
Expand All @@ -90,6 +92,7 @@ func main() {
AlbumHandler: albumHandler,
LinkHandler: linkHandler,
BlogInfoHandler: blogInfoHandler,
AboutHandler: aboutHandler,
UploadHandler: uploadHandler,
UploadPath: cfg.Upload.LocalPath,
DB: config.DB,
Expand Down
55 changes: 55 additions & 0 deletions backend/internal/handler/about.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package handler

import (
"github.com/gin-gonic/gin"

"github.com/tajiaoyezi/blog-go-next/backend/internal/model"
"github.com/tajiaoyezi/blog-go-next/backend/internal/service"
)

// AboutHandler 「关于我」接口处理器。
//
// 拆分自 BlogInfoHandler 是因为「关于我」是结构化数据(多字段),
// 与旧版 BlogInfoHandler.UpdateAbout 把整段 markdown 当字符串塞进
// WebsiteConfig.config JSON 的设计不同——新设计把整张表独立出来,
// 用专门的字段表达每个语义单元。
type AboutHandler struct {
svc *service.AboutService
}

func NewAboutHandler(svc *service.AboutService) *AboutHandler {
return &AboutHandler{svc: svc}
}

// GetAbout 公开接口,返回当前「关于我」配置(不存在时返回默认值)
func (h *AboutHandler) GetAbout(c *gin.Context) {
about, err := h.svc.GetAbout(c.Request.Context())
if err != nil {
FailServer(c, err.Error())
return
}
OK(c, about)
}

// UpdateAbout 管理员更新「关于我」配置。
//
// 注意:直接绑定 model.About 结构体——gin 会忽略空字段的非零校验,
// 允许部分字段更新。但 ID/CreateTime/UpdateTime 这类字段会暴露在请求里,
// 因此 handler 在写库前由 service 重置 ID/CreateTime,避免越权覆盖。
func (h *AboutHandler) UpdateAbout(c *gin.Context) {
var req model.About
if err := c.ShouldBindJSON(&req); err != nil {
FailValidation(c, "请求格式错误")
return
}
// 防止前端意外把空字符串当成"清空昵称"
if req.Nickname == "" {
FailValidation(c, "昵称不能为空")
return
}
if err := h.svc.UpdateAbout(c.Request.Context(), &req); err != nil {
FailServer(c, err.Error())
return
}
OK(c, nil)
}
7 changes: 6 additions & 1 deletion backend/internal/handler/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Deps struct {
AlbumHandler *AlbumHandler
LinkHandler *LinkHandler
BlogInfoHandler *BlogInfoHandler
AboutHandler *AboutHandler
UploadHandler *UploadHandler
UploadPath string // 本地上传文件根目录,用于静态文件服务

Expand Down Expand Up @@ -123,6 +124,9 @@ func setupPublicRoutes(rg *gin.RouterGroup, deps *Deps, rdb *redis.Client) {
// 站点信息
rg.GET("/website/config", deps.BlogInfoHandler.GetWebsiteConfig)

// 关于我(公开)
rg.GET("/about", deps.AboutHandler.GetAbout)

// 认证(真实 handler,带限流防爆破/发信滥用)
rg.POST("/register", middleware.RateLimit(rdb, 3, time.Minute), deps.AuthHandler.Register)
rg.POST("/login", middleware.RateLimit(rdb, 5, time.Minute), deps.AuthHandler.Login)
Expand Down Expand Up @@ -230,7 +234,8 @@ func setupAdminRoutes(rg *gin.RouterGroup, deps *Deps) {

// 站点配置
rg.PUT("/website/config", deps.BlogInfoHandler.UpdateWebsiteConfig)
rg.PUT("/about", deps.BlogInfoHandler.UpdateAbout)
rg.PUT("/about", deps.AboutHandler.UpdateAbout)
rg.GET("/about", deps.AboutHandler.GetAbout)

// 操作日志
rg.GET("/operation/logs", deps.BlogInfoHandler.ListLogs)
Expand Down
42 changes: 32 additions & 10 deletions backend/internal/model/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,39 @@ func (UniqueView) TableName() string { return "tb_unique_view" }
// 这样无需在前端处理「配置尚未初始化」的分支,简化读写语义。
type About struct {
BaseModel
Avatar string `gorm:"type:varchar(1024);not null;default:''" json:"avatar"`
Nickname string `gorm:"type:varchar(50);not null" json:"nickname"`
Intro string `gorm:"type:varchar(255)" json:"intro"`
Bio string `gorm:"type:text" json:"bio"`
Email string `gorm:"type:varchar(100)" json:"email"`
Website string `gorm:"type:varchar(255)" json:"website"`
Github string `gorm:"type:varchar(255)" json:"github"`
TechStack string `gorm:"type:text" json:"techStack"`
BackendSkills string `gorm:"type:text" json:"backendSkills"`
// 顶部 banner 大图(关于页右上方的展示图)
Banner string `gorm:"type:varchar(1024);not null;default:''" json:"banner"`
// 头像
Avatar string `gorm:"type:varchar(1024);not null;default:''" json:"avatar"`
// 昵称(侧边栏和 "Hi, I'm X" 主标题共用)
Nickname string `gorm:"type:varchar(50);not null" json:"nickname"`
// 代词 / pronouns(昵称下方一行,如 he/him、she/her)
Pronouns string `gorm:"type:varchar(50);not null;default:''" json:"pronouns"`
// 简短一句话介绍(昵称下方 + 邮箱前的 bio)
Intro string `gorm:"type:varchar(255);not null;default:''" json:"intro"`
// 主标题下方的副标题(红字显示,如 "AI Agent")
Subtitle string `gorm:"type:varchar(100);not null;default:''" json:"subtitle"`
// 详细 bio / markdown 文本(用于 About Me 区域)
Bio string `gorm:"type:text" json:"bio"`
// About Me 区域的项目列表(JSON:[{emoji, text}])
AboutPoints string `gorm:"type:text;not null;default:'[]'" json:"aboutPoints"`
// 邮箱
Email string `gorm:"type:varchar(100)" json:"email"`
// 个人网站链接
Website string `gorm:"type:varchar(255)" json:"website"`
// Github 链接
Github string `gorm:"type:varchar(255)" json:"github"`
// 关注的粉丝数 / 关注数(仅展示用,由管理员手填)
Followers int `gorm:"not null;default:0" json:"followers"`
Following int `gorm:"not null;default:0" json:"following"`
// 技术栈展示(icon + 名称的 JSON 字符串:[{"name","icon","color"}])
TechStack string `gorm:"type:text;not null;default:'[]'" json:"techStack"`
// 后端 / 前端 / 其他技能列表(每项一个字符串,简单换行展示)
BackendSkills string `gorm:"type:text" json:"backendSkills"`
FrontendSkills string `gorm:"type:text" json:"frontendSkills"`
OtherSkills string `gorm:"type:text" json:"otherSkills"`
OtherSkills string `gorm:"type:text" json:"otherSkills"`
// 成就 / 徽章(JSON:[{"name","icon","color"}])
Achievements string `gorm:"type:text;not null;default:'[]'" json:"achievements"`
}

func (About) TableName() string { return "tb_about" }
128 changes: 128 additions & 0 deletions backend/internal/service/about.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package service

import (
"context"
"encoding/json"
"fmt"

"gorm.io/gorm"

"github.com/tajiaoyezi/blog-go-next/backend/internal/model"
)

// AboutService 个人信息(关于页内容)的读写服务。
//
// 单行表设计:整站只维护一份「关于我」。读路径在表为空时返回一份默认值,
// 写路径使用 upsert,保证无论管理员是首次保存还是后续编辑都写入第一行。
type AboutService struct {
db *gorm.DB
}

func NewAboutService(db *gorm.DB) *AboutService {
return &AboutService{db: db}
}

// defaultAbout 用于表为空时的占位默认值,避免前端在初次部署时拿到零值。
//
// 字段保持与 model.About 对齐;JSON 数组型字段返回合法空数组,
// 让前端无需做 null 兜底,直接遍历即可。
func defaultAbout() model.About {
return model.About{
Nickname: "提子",
Pronouns: "he/him",
Intro: "🍊 Hi, I'm 提子. Learning full-stack development.",
Subtitle: "Hello World",
AboutPoints: "[{\"emoji\":\"🌱\",\"text\":\"热爱技术与开源,持续学习与实践\"}]",
TechStack: "[]",
Achievements: "[]",
Avatar: "",
Banner: "",
Bio: "",
Email: "",
Website: "",
Github: "",
Followers: 0,
Following: 0,
BackendSkills: "",
FrontendSkills: "",
OtherSkills: "",
}
}

// GetAbout 读取「关于我」配置;不存在时返回默认值而不报错,
// 让前端无需处理「首次部署无配置」的特殊分支。
func (s *AboutService) GetAbout(ctx context.Context) (*model.About, error) {
var about model.About
if err := s.db.WithContext(ctx).First(&about).Error; err != nil {
if err == gorm.ErrRecordNotFound {
def := defaultAbout()
return &def, nil
}
return nil, fmt.Errorf("读取关于我配置失败: %w", err)
}
return &about, nil
}

// UpdateAbout 写入「关于我」配置(upsert 到首行)。
//
// 入参来自 handler 的请求体,前端在用户未填的 JSON 数组字段上传的是 "[]",
// 这里做一次兜底校验:JSON 数组字段如果反序列化失败则强制为空数组,
// 避免数据库里残留非法 JSON 导致后续解析异常。
func (s *AboutService) UpdateAbout(ctx context.Context, in *model.About) error {
if in == nil {
return fmt.Errorf("请求体为空")
}
in.AboutPoints = normalizeJSONArray(in.AboutPoints)
in.TechStack = normalizeJSONArray(in.TechStack)
in.Achievements = normalizeJSONArray(in.Achievements)

return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.About
err := tx.First(&existing).Error
if err == gorm.ErrRecordNotFound {
return tx.Create(in).Error
}
if err != nil {
return fmt.Errorf("读取关于我配置失败: %w", err)
}
in.ID = existing.ID
in.CreateTime = existing.CreateTime
return tx.Save(in).Error
})
}

// normalizeJSONArray 把 JSON 数组字符串校验/归一化为合法数组。
// 不是合法 JSON(或为空)时返回 "[]",避免存储污染。
func normalizeJSONArray(raw string) string {
raw = trimSpace(raw)
if raw == "" {
return "[]"
}
var probe interface{}
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
return "[]"
}
if _, ok := probe.([]interface{}); !ok {
return "[]"
}
return raw
}

func trimSpace(s string) string {
start, end := 0, len(s)
for start < end {
c := s[start]
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
break
}
start++
}
for end > start {
c := s[end-1]
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
break
}
end--
}
return s[start:end]
}
Loading
Loading