️ feat: add main page layout with navigation and footer

feat: create random labels page

feat: implement login page with OpenID Connect support

feat: add Gravatar component for user avatars

feat: create Navbar component with navigation menu

chore: create Sidebar component placeholder

feat: implement login form with OIDC and email/password options

feat: add reusable button component

feat: create card component for structured content display

feat: implement input component for forms

feat: create label component for form labels

feat: add navigation menu component for site navigation

chore: add configuration file for site metadata

feat: implement device context for responsive design

feat: add utility functions for class name management

feat: define OIDC configuration model

feat: define base response model for API responses

feat: define user model for user data

feat: implement i18n for internationalization support

feat: add English and Chinese translations for login

chore: create index for locale resources

chore: add blog home view placeholder
This commit is contained in:
2025-07-24 09:22:50 +08:00
parent 19c8a9eac5
commit 9ca307f4d9
57 changed files with 2453 additions and 108 deletions

View File

@ -7,6 +7,7 @@ import (
"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 {
@ -22,7 +23,7 @@ func NewAdminController() *AdminController {
func (cc *AdminController) CreateOidc(ctx context.Context, c *app.RequestContext) {
var adminCreateOidcReq dto.AdminOidcConfigDto
if err := c.BindAndValidate(&adminCreateOidcReq); err != nil {
c.JSON(400, map[string]string{"error": "Invalid parameters"})
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
err := cc.service.CreateOidcConfig(&adminCreateOidcReq)
@ -77,12 +78,23 @@ func (cc *AdminController) ListOidc(ctx context.Context, c *app.RequestContext)
}
func (cc *AdminController) UpdateOidc(ctx context.Context, c *app.RequestContext) {
var adminUpdateOidcReq dto.AdminOidcConfigDto
if err := c.BindAndValidate(&adminUpdateOidcReq); err != nil {
c.JSON(400, map[string]string{"error": "Invalid parameters"})
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
err := cc.service.UpdateOidcConfig(&adminUpdateOidcReq)
idInt, err := strconv.Atoi(id)
if err != nil || idInt <= 0 {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
var adminUpdateOidcReq dto.AdminOidcConfigDto
if err := c.BindAndValidate(&adminUpdateOidcReq); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
adminUpdateOidcReq.ID = uint(idInt)
err = cc.service.UpdateOidcConfig(&adminUpdateOidcReq)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)

View File

@ -3,10 +3,14 @@ package v1
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
"slices"
"strings"
)
type PostController struct {
@ -24,7 +28,7 @@ func (p *PostController) Create(ctx context.Context, c *app.RequestContext) {
if err := c.BindAndValidate(&req); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
}
if err := p.service.CreatePost(&req); err != nil {
if err := p.service.CreatePost(ctx, &req); err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
@ -47,13 +51,63 @@ func (p *PostController) Delete(ctx context.Context, c *app.RequestContext) {
}
func (p *PostController) Get(ctx context.Context, c *app.RequestContext) {
// TODO: Impl
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
post, err := p.service.GetPost(ctx, id)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
if post == nil {
resps.NotFound(c, resps.ErrNotFound)
return
}
resps.Ok(c, resps.Success, post)
}
func (p *PostController) Update(ctx context.Context, c *app.RequestContext) {
// TODO: Impl
var req dto.CreateOrUpdatePostReq
if err := c.BindAndValidate(&req); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
id := c.Param("id")
if id == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
if err := p.service.UpdatePost(ctx, id, &req); err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, nil)
}
func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
// TODO: Impl
pagination := ctxutils.GetPaginationParams(c)
if pagination.OrderedBy != "" && !slices.Contains(constant.OrderedByEnumPost, pagination.OrderedBy) {
resps.BadRequest(c, "无效的排序字段")
return
}
keywords := c.Query("keywords")
keywordsArray := strings.Split(keywords, ",")
req := &dto.ListPostReq{
Keywords: keywordsArray,
Page: pagination.Page,
Size: pagination.Size,
OrderedBy: pagination.OrderedBy,
Reverse: pagination.Reverse,
}
resp, err := p.service.ListPosts(ctx, req)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, resp)
}

View File

@ -2,6 +2,7 @@ package v1
import (
"context"
"fmt"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils"
"github.com/snowykami/neo-blog/internal/ctxutils"
@ -82,8 +83,13 @@ func (u *UserController) OidcList(ctx context.Context, c *app.RequestContext) {
func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) {
name := c.Param("name")
code := c.Param("code")
state := c.Param("state")
code := c.Query("code")
state := c.Query("state")
redirectUri := c.Query("redirect_back") // 前端路由登录前的重定向地址
if redirectUri == "" {
redirectUri = "/"
}
fmt.Println("redirectBack:", redirectUri)
oidcLoginReq := &dto.OidcLoginReq{
Name: name,
Code: code,
@ -96,22 +102,14 @@ func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) {
return
}
ctxutils.SetTokenAndRefreshTokenCookie(c, resp.Token, resp.RefreshToken)
resps.Ok(c, resps.Success, map[string]any{
"token": resp.Token,
"user": resp.User,
})
resps.Redirect(c, redirectUri) // 重定向到前端路由
}
func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) {
userID := c.Param("id")
if userID == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
userIDInt, err := strconv.Atoi(userID)
if err != nil || userIDInt <= 0 {
resps.BadRequest(c, resps.ErrParamInvalid)
return
userIDInt = int(ctxutils.GetCurrentUserID(ctx))
}
resp, err := u.service.GetUser(&dto.GetUserReq{UserID: uint(userIDInt)})
@ -142,7 +140,7 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext)
updateUserReq.ID = uint(userIDInt)
currentUser := ctxutils.GetCurrentUser(ctx)
if currentUser == nil {
resps.UnAuthorized(c, resps.ErrUnauthorized)
resps.Unauthorized(c, resps.ErrUnauthorized)
return
}
if currentUser.ID != updateUserReq.ID {

View File

@ -1 +1,46 @@
package ctxutils
import (
"github.com/cloudwego/hertz/pkg/app"
"strconv"
)
type PaginationParams struct {
Page uint64
Size uint64
OrderedBy string
Reverse bool // 默认是从大值到小值
}
func GetPaginationParams(c *app.RequestContext) *PaginationParams {
page := c.Query("page")
size := c.Query("size")
orderedBy := c.Query("ordered_by")
reverse := c.Query("reverse")
if page == "" {
page = "1"
}
if size == "" {
size = "10"
}
var reverseBool bool
if reverse == "" || reverse == "false" || reverse == "0" {
reverseBool = false
} else {
reverseBool = true
}
pageNum, err := strconv.ParseUint(page, 10, 64)
if err != nil || pageNum < 1 {
pageNum = 1
}
sizeNum, err := strconv.ParseUint(size, 10, 64)
if err != nil || sizeNum < 1 {
sizeNum = 10
}
return &PaginationParams{
Page: pageNum,
Size: sizeNum,
OrderedBy: orderedBy,
Reverse: reverseBool,
}
}

View File

@ -17,3 +17,12 @@ func GetCurrentUser(ctx context.Context) *model.User {
}
return user
}
// GetCurrentUserID 获取当前用户ID如果未认证则返回0
func GetCurrentUserID(ctx context.Context) uint {
user := GetCurrentUser(ctx)
if user == nil {
return 0
}
return user.ID
}

View File

@ -8,5 +8,6 @@ type AdminOidcConfigDto struct {
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
OidcDiscoveryUrl string `json:"oidc_discovery_url"`
Type string `json:"type"` // oauth2 or misskey
Enabled bool `json:"enabled"`
}

View File

@ -1,11 +1,16 @@
package dto
type PostDto struct {
UserID uint `json:"user_id"` // 发布者的用户ID
Title string `json:"title"` // 帖子标题
Content string `json:"content"`
Labels []LabelDto `json:"labels"` // 关联的标签
IsPrivate bool `json:"is_private"` // 是否为私密帖子
ID uint `json:"id"` // 帖子ID
UserID uint `json:"user_id"` // 发布者的用户ID
Title string `json:"title"` // 帖子标题
Content string `json:"content"`
Labels []LabelDto `json:"labels"` // 关联的标签
IsPrivate bool `json:"is_private"` // 是否为私密帖子
LikeCount uint64 `json:"like_count"` // 点赞数
CommentCount uint64 `json:"comment_count"` // 评论数
ViewCount uint64 `json:"view_count"` // 浏览数
Heat uint64 `json:"heat"` // 热度
}
type CreateOrUpdatePostReq struct {
@ -14,3 +19,18 @@ type CreateOrUpdatePostReq struct {
IsPrivate bool `json:"is_private"`
Labels []uint `json:"labels"` // 标签ID列表
}
type ListPostReq struct {
Keywords []string `json:"keywords"` // 关键词列表
OrderedBy string `json:"ordered_by"` // 排序方式
Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"`
Reverse bool `json:"reverse"`
}
type ListPostResp struct {
Posts []PostDto `json:"posts"`
Total uint64 `json:"total"` // 总数
OrderedBy string `json:"ordered_by"` // 排序方式
Reverse bool `json:"reverse"`
}

View File

@ -54,7 +54,7 @@ func UseAuth(block bool) app.HandlerFunc {
// 所有认证方式都失败
if block {
// 若需要阻断,返回未授权错误并中止请求
resps.UnAuthorized(c, resps.ErrUnauthorized)
resps.Unauthorized(c, resps.ErrUnauthorized)
c.Abort()
} else {
// 若不需要阻断继续请求但不设置用户ID

View File

@ -1 +1,9 @@
package model
import "gorm.io/gorm"
type Category struct {
gorm.Model
Name string `gorm:"type:text;not null"`
Description string `gorm:"type:text;not null"` // 分类描述
}

View File

@ -1,11 +1,24 @@
package model
import "gorm.io/gorm"
import (
"github.com/snowykami/neo-blog/internal/dto"
"gorm.io/gorm"
)
type Label struct {
gorm.Model
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
Value string `gorm:"type:text"` // 标签值,描述标签的内容
Color string `gorm:"type:text"` // 前端可用颜色代码
TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
Value string `gorm:"type:text"` // 标签值,描述标签的内容
Color string `gorm:"type:text"` // 前端可用颜色代码
TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式
Posts []Post `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的帖子
}
func (l *Label) ToDto() dto.LabelDto {
return dto.LabelDto{
Key: l.Key,
Value: l.Value,
Color: l.Color,
TailwindClassName: l.TailwindClassName,
}
}

View File

@ -2,6 +2,7 @@ package model
import (
"fmt"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"gorm.io/gorm"
"resty.dev/v3"
@ -17,6 +18,7 @@ type OidcConfig struct {
Icon string // 图标url为空则使用内置默认图标
OidcDiscoveryUrl string // OpenID自动发现URL例如 https://pass.liteyuki.icu/.well-known/openid-configuration
Enabled bool `gorm:"default:true"` // 是否启用
Type string `gorm:"oauth2"` // OIDC类型默认为oauth2,也可以为misskey
// 以下字段为自动获取字段,每次更新配置时自动填充
Issuer string
AuthorizationEndpoint string
@ -42,7 +44,7 @@ type oidcDiscoveryResp struct {
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
}
func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
func updateOidcConfigFromUrl(url string, typ string) (*oidcDiscoveryResp, error) {
client := resty.New()
client.SetTimeout(10 * time.Second) // 设置超时时间
var discovery oidcDiscoveryResp
@ -57,6 +59,11 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
return nil, fmt.Errorf("请求OIDC发现端点失败状态码: %d", resp.StatusCode())
}
// 验证必要字段
if typ == "misskey" {
discovery.UserInfoEndpoint = discovery.Issuer + "/api/users/me" // Misskey的用户信息端点
discovery.JwksUri = discovery.Issuer + "/api/jwks"
}
fmt.Println(discovery)
if discovery.Issuer == "" ||
discovery.AuthorizationEndpoint == "" ||
discovery.TokenEndpoint == "" ||
@ -69,10 +76,12 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) {
// 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息
if tx.Statement.Changed("OidcDiscoveryUrl") {
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl)
if tx.Statement.Changed("OidcDiscoveryUrl") || o.ID == 0 {
logrus.Infof("Updating OIDC config for %s, OidcDiscoveryUrl: %s", o.Name, o.OidcDiscoveryUrl)
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl, o.Type)
if err != nil {
return fmt.Errorf("更新OIDC配置失败: %w", err)
logrus.Error("Updating OIDC config failed: ", err)
return fmt.Errorf("updating OIDC config failed: %w", err)
}
o.Issuer = discoveryResp.Issuer
o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint

View File

@ -1,16 +1,57 @@
package model
import "gorm.io/gorm"
import (
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm"
)
type Post struct {
gorm.Model
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Content string `gorm:"type:text;not null"` // 帖子内容
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsPrivate bool `gorm:"default:false"` // 是否为私密帖子
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Content string `gorm:"type:text;not null"` // 帖子内容
CategoryID uint `gorm:"index"` // 帖子分类ID
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsPrivate bool `gorm:"default:false"` // 是否为私密帖子
LikeCount uint64
CommentCount uint64
VisitorCount uint64
ViewCount uint64
Heat uint64 `gorm:"default:0"`
}
// CalculateHeat 热度计算
func (p *Post) CalculateHeat() float64 {
return float64(
p.LikeCount*constant.HeatFactorLikeWeight +
p.CommentCount*constant.HeatFactorCommentWeight +
p.ViewCount*constant.HeatFactorViewWeight,
)
}
// AfterUpdate 热度指标更新后更新热度
func (p *Post) AfterUpdate(tx *gorm.DB) (err error) {
if tx.Statement.Changed("LikeCount") || tx.Statement.Changed("CommentCount") || tx.Statement.Changed("ViewCount") {
p.Heat = uint64(p.CalculateHeat())
if err := tx.Model(p).Update("heat", p.Heat).Error; err != nil {
return err
}
}
return nil
}
func (p *Post) ToDto() dto.PostDto {
return dto.PostDto{
ID: p.ID,
UserID: p.UserID,
Title: p.Title,
Content: p.Content,
IsPrivate: p.IsPrivate,
LikeCount: p.LikeCount,
CommentCount: p.CommentCount,
ViewCount: p.ViewCount,
Heat: p.Heat,
}
}

View File

@ -124,6 +124,7 @@ func initSQLite(path string, gormConfig *gorm.Config) (*gorm.DB, error) {
func migrate() error {
return GetDB().AutoMigrate(
&model.Category{},
&model.Comment{},
&model.Label{},
&model.Like{},

View File

@ -62,7 +62,7 @@ func (o *oidcRepo) UpdateOidcConfig(oidcConfig *model.OidcConfig) error {
if oidcConfig.ID == 0 {
return errs.New(http.StatusBadRequest, "invalid OIDC config ID", nil)
}
if err := GetDB().Updates(oidcConfig).Error; err != nil {
if err := GetDB().Select("Enabled").Updates(oidcConfig).Error; err != nil {
return err
}
return nil

View File

@ -2,8 +2,10 @@ package repo
import (
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"net/http"
"slices"
)
type postRepo struct{}
@ -45,9 +47,34 @@ func (p *postRepo) UpdatePost(post *model.Post) error {
return nil
}
func (p *postRepo) ListPosts(limit, offset int) ([]model.Post, error) {
func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderedBy string, reverse bool) ([]model.Post, error) {
var posts []model.Post
if err := GetDB().Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
if !slices.Contains(constant.OrderedByEnumPost, orderedBy) {
return nil, errs.New(http.StatusBadRequest, "invalid ordered_by parameter", nil)
}
order := orderedBy
if reverse {
order += " ASC"
} else {
order += " DESC"
}
query := GetDB().Model(&model.Post{}).Preload("User")
if currentUserID > 0 {
query = query.Where("is_private = ? OR (is_private = ? AND user_id = ?)", false, true, currentUserID)
} else {
query = query.Where("is_private = ?", false)
}
if len(keywords) > 0 {
for _, keyword := range keywords {
if keyword != "" {
// 使用LIKE进行模糊匹配搜索标题、内容和标签
query = query.Where("title LIKE ? OR content LIKE ? OR tags LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
}
}
}
query = query.Order(order).Offset(int((page - 1) * size)).Limit(int(size))
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
return posts, nil

View File

@ -18,6 +18,7 @@ func registerUserRoutes(group *route.RouterGroup) {
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
userGroup.GET("/u", userController.GetUser)
userGroup.POST("/logout", userController.Logout)
userGroup.PUT("/u/:id", userController.UpdateUser)
}

View File

@ -23,6 +23,7 @@ func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error {
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.CreateOidcConfig(oidcConfig)
}
@ -70,6 +71,7 @@ func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error {
ClientSecret: req.ClientSecret,
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
Enabled: req.Enabled,
Type: req.Type,
}
return repo.Oidc.UpdateOidcConfig(oidcConfig)
}

View File

@ -7,7 +7,6 @@ import (
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"net/http"
)
type PostService struct{}
@ -21,15 +20,22 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos
if currentUser == nil {
return errs.ErrUnauthorized
}
post := &model.Post{
Title: req.Title,
Content: req.Content,
UserID: currentUser.ID,
Labels: req.Labels,
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(labelID)
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}(),
IsPrivate: req.IsPrivate,
}
if err := repo.Post.CreatePost(post); err != nil {
return err
}
@ -37,10 +43,103 @@ func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePos
}
func (p *PostService) DeletePost(ctx context.Context, id string) error {
currentUser := ctxutils.GetCurrentUser(ctx)
if currentUser == nil {
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) (*model.Post, error) {}
func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) {
currentUser := ctxutils.GetCurrentUser(ctx)
if currentUser == nil {
return nil, errs.ErrUnauthorized
}
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)
}
if post.IsPrivate && post.UserID != currentUser.ID {
return nil, errs.ErrForbidden
}
return &dto.PostDto{
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
Labels: func() []dto.LabelDto {
labelDtos := make([]dto.LabelDto, 0)
for _, label := range post.Labels {
labelDtos = append(labelDtos, label.ToDto())
}
return labelDtos
}(),
}, nil
}
func (p *PostService) UpdatePost(req *dto.CreateOrUpdatePostReq) error {}
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) error {
currentUser := ctxutils.GetCurrentUser(ctx)
if currentUser == nil {
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
}
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(labelID)
if err == nil {
labelModels = append(labelModels, *labelModel)
}
}
return labelModels
}()
if err := repo.Post.UpdatePost(post); err != nil {
return errs.ErrInternalServer
}
return nil
}
func (p *PostService) ListPosts() {}
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) (*dto.ListPostResp, error) {
postDtos := make([]dto.PostDto, 0)
currentUserID := ctxutils.GetCurrentUserID(ctx)
posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderedBy, req.Reverse)
if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
}
for _, post := range posts {
postDtos = append(postDtos, post.ToDto())
}
return &dto.ListPostResp{
Posts: postDtos,
Total: uint64(len(posts)),
OrderedBy: req.OrderedBy,
Reverse: req.Reverse,
}, nil
}

View File

@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
@ -25,6 +26,10 @@ func NewUserService() *UserService {
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound
}
return nil, errs.ErrInternalServer
}
if user == nil {
@ -92,6 +97,15 @@ func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterR
if err != nil {
return nil, errs.ErrInternalServer
}
// 创建默认管理员账户
if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser)
if err != nil {
logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer
}
}
// 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil {
@ -137,17 +151,40 @@ func (s *UserService) ListOidcConfigs() (*dto.ListOidcConfigResp, error) {
state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "openid email profile",
"state": state,
})
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "read:account",
"state": state,
})
}
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon,
LoginUrl: utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/") + constant.OidcUri + oidcConfig.Name,
"response_type": "code",
"scope": "openid email profile",
"state": state,
}),
LoginUrl: loginUrl,
})
}
return &dto.ListOidcConfigResp{
@ -190,7 +227,7 @@ func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, erro
// 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if !errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer
}
if userOpenID != nil {
@ -212,7 +249,7 @@ func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, erro
} else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email)
if !errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer
}

View File

@ -1 +1,4 @@
package tasks
// ClearSessionDaemon 定时任务:清理过期会话
func ClearSessionDaemon() {}

View File

@ -1 +1,6 @@
package tasks
func RunTaskManager() {
// 启动任务管理器
}