feat: 添加仪表板功能,整合统计数据并优化后台管理界面

This commit is contained in:
2025-09-24 12:09:16 +08:00
parent 2bcaad716d
commit 636b4d5ea0
13 changed files with 430 additions and 220 deletions

View File

@ -2,12 +2,13 @@ package v1
import (
"context"
"strconv"
"github.com/cloudwego/hertz/pkg/app"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
"strconv"
)
type AdminController struct {
@ -20,6 +21,15 @@ func NewAdminController() *AdminController {
}
}
func (cc *AdminController) GetDashboard(ctx context.Context, c *app.RequestContext) {
dashboardData, err := cc.service.GetDashboard()
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, err.Error(), nil)
}
resps.Ok(c, resps.Success, dashboardData)
}
func (cc *AdminController) CreateOidc(ctx context.Context, c *app.RequestContext) {
var adminCreateOidcReq dto.AdminOidcConfigDto
if err := c.BindAndValidate(&adminCreateOidcReq); err != nil {

View File

@ -6,19 +6,24 @@ import (
"gorm.io/gorm"
)
type PostBase struct {
Title string `gorm:"type:text;not null"`
Cover string `gorm:"type:text"`
Content string `gorm:"type:text;not null"`
Type string `gorm:"type:text;default:markdown"`
CategoryID uint `gorm:"index"`
Category Category `gorm:"foreignKey:CategoryID;references:ID"`
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
IsOriginal bool `gorm:"default:true"`
IsPrivate bool `gorm:"default:false"`
}
type Post struct {
gorm.Model
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Cover string `gorm:"type:text"` // 帖子封面图
Content string `gorm:"type:text;not null"` // 帖子内容
Type string `gorm:"type:text;default:markdown"` // markdown类型支持markdown或html或txt
CategoryID uint `gorm:"index"` // 帖子分类ID
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsOriginal bool `gorm:"default:true"` // 是否为原创帖子
IsPrivate bool `gorm:"default:false"`
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
// core fields
PostBase
//
LikeCount uint64
CommentCount uint64
ViewCount uint64
@ -71,3 +76,11 @@ func (p *Post) ToDtoWithShortContent(contentLength int) *dto.PostDto {
}
return dtoPost
}
// Draft 草稿
type Draft struct {
gorm.Model
PostID uint `gorm:"uniqueIndex"` // 关联的文章ID
Post Post `gorm:"foreignKey:PostID;references:ID"`
PostBase
}

View File

@ -1,21 +1,22 @@
package apiv1
import (
"github.com/cloudwego/hertz/pkg/route"
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/cloudwego/hertz/pkg/route"
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware"
"github.com/snowykami/neo-blog/pkg/constant"
)
func registerAdminRoutes(group *route.RouterGroup) {
// Need Admin Middleware
adminController := v1.NewAdminController()
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
{
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
consoleGroup.GET("/oidc/list", adminController.ListOidc)
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
}
// Need Admin Middleware
adminController := v1.NewAdminController()
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
{
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
consoleGroup.GET("/oidc/list", adminController.ListOidc)
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
consoleGroup.GET("/dashboard", adminController.GetDashboard)
}
}

View File

@ -1,77 +1,110 @@
package service
import (
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"gorm.io/gorm"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"gorm.io/gorm"
)
type AdminService struct{}
func NewAdminService() *AdminService {
return &AdminService{}
return &AdminService{}
}
func (c *AdminService) GetDashboard() (map[string]any, error) {
var (
postCount, commentCount, userCount, viewCount int64
err error
mustCount = func(q *gorm.DB, dest *int64) {
if err == nil {
err = q.Count(dest).Error
}
}
mustScan = func(q *gorm.DB, dest *int64) {
if err == nil {
err = q.Scan(dest).Error
}
}
)
db := repo.GetDB()
mustCount(db.Model(&model.Comment{}), &commentCount)
mustCount(db.Model(&model.Post{}), &postCount)
mustCount(db.Model(&model.User{}), &userCount)
mustScan(db.Model(&model.Post{}).Select("SUM(view_count)"), &viewCount)
if err != nil {
return nil, err
}
return map[string]any{
"total_comments": commentCount,
"total_posts": postCount,
"total_users": userCount,
"total_views": viewCount,
}, nil
}
func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error {
oidcConfig := &model.OidcConfig{
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.CreateOidcConfig(oidcConfig)
oidcConfig := &model.OidcConfig{
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.CreateOidcConfig(oidcConfig)
}
func (c *AdminService) DeleteOidcConfig(id string) error {
if id == "" {
return errs.ErrBadRequest
}
return repo.Oidc.DeleteOidcConfig(id)
if id == "" {
return errs.ErrBadRequest
}
return repo.Oidc.DeleteOidcConfig(id)
}
func (c *AdminService) GetOidcConfigByID(id string) (*dto.AdminOidcConfigDto, error) {
if id == "" {
return nil, errs.ErrBadRequest
}
config, err := repo.Oidc.GetOidcConfigByID(id)
if err != nil {
return nil, err
}
return config.ToAdminDto(), nil
if id == "" {
return nil, errs.ErrBadRequest
}
config, err := repo.Oidc.GetOidcConfigByID(id)
if err != nil {
return nil, err
}
return config.ToAdminDto(), nil
}
func (c *AdminService) ListOidcConfigs(onlyEnabled bool) ([]*dto.AdminOidcConfigDto, error) {
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
if err != nil {
return nil, err
}
var dtos []*dto.AdminOidcConfigDto
for _, config := range configs {
dtos = append(dtos, config.ToAdminDto())
}
return dtos, nil
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
if err != nil {
return nil, err
}
var dtos []*dto.AdminOidcConfigDto
for _, config := range configs {
dtos = append(dtos, config.ToAdminDto())
}
return dtos, nil
}
func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error {
if req.ID == 0 {
return errs.ErrBadRequest
}
oidcConfig := &model.OidcConfig{
Model: gorm.Model{ID: req.ID},
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.UpdateOidcConfig(oidcConfig)
if req.ID == 0 {
return errs.ErrBadRequest
}
oidcConfig := &model.OidcConfig{
Model: gorm.Model{ID: req.ID},
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.UpdateOidcConfig(oidcConfig)
}

View File

@ -1,155 +1,157 @@
package service
import (
"context"
"strconv"
"context"
"strconv"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
)
type PostService struct{}
func NewPostService() *PostService {
return &PostService{}
return &PostService{}
}
func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePostReq) (uint, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
post := &model.Post{
Title: req.Title,
Content: req.Content,
UserID: currentUser.ID,
Labels: func() []model.Label {
labelModels := make([]model.Label, 0)
for _, labelID := range req.Labels {
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}(),
IsPrivate: req.IsPrivate,
}
if err := repo.Post.CreatePost(post); err != nil {
return 0, err
}
return post.ID, nil
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
post := &model.Post{
UserID: currentUser.ID,
PostBase: model.PostBase{
Title: req.Title,
Content: req.Content,
Labels: func() []model.Label {
labelModels := make([]model.Label, 0)
for _, labelID := range req.Labels {
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}(),
IsPrivate: req.IsPrivate,
},
}
if err := repo.Post.CreatePost(post); err != nil {
return 0, err
}
return post.ID, nil
}
func (p *PostService) DeletePost(ctx context.Context, id string) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
if id == "" {
return errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID != currentUser.ID {
return errs.ErrForbidden
}
if err := repo.Post.DeletePost(id); err != nil {
return errs.ErrInternalServer
}
return nil
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
if id == "" {
return errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID != currentUser.ID {
return errs.ErrForbidden
}
if err := repo.Post.DeletePost(id); err != nil {
return errs.ErrInternalServer
}
return nil
}
func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) {
if id == "" {
return nil, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
return nil, errs.ErrForbidden
}
if id == "" {
return nil, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
return nil, errs.ErrForbidden
}
return post.ToDto(), nil
return post.ToDto(), nil
}
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
if id == "" {
return 0, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return 0, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID != currentUser.ID {
return 0, errs.ErrForbidden
}
post.Title = req.Title
post.Content = req.Content
post.IsPrivate = req.IsPrivate
post.Labels = func() []model.Label {
labelModels := make([]model.Label, len(req.Labels))
for _, labelID := range req.Labels {
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}()
if err := repo.Post.UpdatePost(post); err != nil {
return 0, errs.ErrInternalServer
}
return post.ID, nil
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
if id == "" {
return 0, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return 0, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID != currentUser.ID {
return 0, errs.ErrForbidden
}
post.Title = req.Title
post.Content = req.Content
post.IsPrivate = req.IsPrivate
post.Labels = func() []model.Label {
labelModels := make([]model.Label, len(req.Labels))
for _, labelID := range req.Labels {
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}()
if err := repo.Post.UpdatePost(post); err != nil {
return 0, errs.ErrInternalServer
}
return post.ID, nil
}
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) {
postDtos := make([]*dto.PostDto, 0)
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc)
if err != nil {
return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
}
for _, post := range posts {
postDtos = append(postDtos, post.ToDtoWithShortContent(100))
}
return postDtos, total, nil
postDtos := make([]*dto.PostDto, 0)
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc)
if err != nil {
return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
}
for _, post := range posts {
postDtos = append(postDtos, post.ToDtoWithShortContent(100))
}
return postDtos, total, nil
}
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return false, errs.ErrUnauthorized
}
if id == "" {
return false, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID == currentUser.ID {
return false, errs.ErrForbidden
}
idInt, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
}
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
if err != nil {
return false, errs.ErrInternalServer
}
return liked, nil
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return false, errs.ErrUnauthorized
}
if id == "" {
return false, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID == currentUser.ID {
return false, errs.ErrForbidden
}
idInt, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
}
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
if err != nil {
return false, errs.ErrInternalServer
}
return liked, nil
}