Files
platform/web/services/article.go

390 lines
9.9 KiB
Go
Raw Normal View History

2026-06-01 15:31:25 +08:00
package services
import (
"errors"
2026-06-06 17:22:01 +08:00
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"platform/pkg/env"
2026-06-01 15:31:25 +08:00
"platform/pkg/u"
"platform/web/core"
m "platform/web/models"
q "platform/web/queries"
2026-06-06 17:22:01 +08:00
"strings"
2026-06-01 15:31:25 +08:00
"time"
2026-06-06 17:22:01 +08:00
"github.com/google/uuid"
2026-06-01 15:31:25 +08:00
"gorm.io/gen/field"
"gorm.io/gorm"
)
var Article = &articleService{}
type articleService struct{}
2026-06-06 17:22:01 +08:00
var articleUploadMimeExt = map[string]string{
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
type ArticleUploadResult struct {
URL string `json:"url"`
Path string `json:"path"`
OriginalName string `json:"original_name"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
}
func (s *articleService) UploadImage(fileHeader *multipart.FileHeader, baseURL string) (*ArticleUploadResult, error) {
if fileHeader == nil {
return nil, core.NewBizErr("缺少上传文件")
}
if fileHeader.Size > int64(env.ArticleUploadMaxBytes) {
return nil, core.NewBizErr(fmt.Sprintf("图片大小不能超过 %s", formatUploadSizeLimit(env.ArticleUploadMaxBytes)))
}
mimeType, ext, err := detectArticleImage(fileHeader)
if err != nil {
return nil, err
}
now := time.Now()
year := now.Format("2006")
month := now.Format("01")
fileName := uuid.NewString() + ext
relativePath := path.Join("/uploads", "article", year, month, fileName)
targetDir := filepath.Join(env.UploadDir, "article", year, month)
finalPath := filepath.Join(targetDir, fileName)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return nil, core.NewServErr("创建上传目录失败", err)
}
src, err := fileHeader.Open()
if err != nil {
return nil, core.NewServErr("打开上传文件失败", err)
}
defer src.Close()
tmp, err := os.CreateTemp(targetDir, "upload-*"+ext)
if err != nil {
return nil, core.NewServErr("创建临时文件失败", err)
}
tmpPath := tmp.Name()
finished := false
defer func() {
if !finished {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
}()
limitedReader := &io.LimitedReader{R: src, N: int64(env.ArticleUploadMaxBytes) + 1}
written, err := io.Copy(tmp, limitedReader)
if err != nil {
return nil, core.NewServErr("保存上传文件失败", err)
}
if written > int64(env.ArticleUploadMaxBytes) {
return nil, core.NewBizErr(fmt.Sprintf("图片大小不能超过 %s", formatUploadSizeLimit(env.ArticleUploadMaxBytes)))
}
if err := tmp.Close(); err != nil {
return nil, core.NewServErr("关闭临时文件失败", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
return nil, core.NewServErr("保存上传文件失败", err)
}
finished = true
cleanBaseURL := strings.TrimRight(baseURL, "/")
url := relativePath
if cleanBaseURL != "" {
url = cleanBaseURL + relativePath
}
return &ArticleUploadResult{
URL: url,
Path: relativePath,
OriginalName: filepath.Base(fileHeader.Filename),
Size: written,
MimeType: mimeType,
}, nil
}
func detectArticleImage(fileHeader *multipart.FileHeader) (string, string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", "", core.NewServErr("打开上传文件失败", err)
}
defer file.Close()
buf := make([]byte, 512)
n, err := io.ReadFull(file, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return "", "", core.NewServErr("读取上传文件失败", err)
}
mimeType := http.DetectContentType(buf[:n])
ext, ok := articleUploadMimeExt[mimeType]
if !ok {
return "", "", core.NewBizErr("仅支持 JPG、PNG、WEBP、GIF 图片")
}
return mimeType, ext, nil
}
func formatUploadSizeLimit(bytes int) string {
if bytes%(1024*1024) == 0 {
return fmt.Sprintf("%d MB", bytes/(1024*1024))
}
if bytes%1024 == 0 {
return fmt.Sprintf("%d KB", bytes/1024)
}
return fmt.Sprintf("%d bytes", bytes)
}
2026-06-01 15:31:25 +08:00
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
do := q.Article.Where()
if req.Keyword != nil && *req.Keyword != "" {
do = do.Where(q.Article.Title.Like("%" + *req.Keyword + "%"))
}
if req.GroupID != nil {
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
}
if req.Status != nil {
do = do.Where(q.Article.Status.Eq(int(*req.Status)))
}
return q.Article.
Preload(q.Article.Group).
Where(do).
Omit(q.Article.Content).
Order(q.Article.Sort, q.Article.CreatedAt).
FindByPage(req.GetOffset(), req.GetLimit())
}
type PageArticleReq struct {
core.PageReq
Keyword *string `json:"keyword,omitempty"`
GroupID *int32 `json:"group_id,omitempty"`
Status *m.ArticleStatus `json:"status,omitempty"`
}
func (s *articleService) GetByAdmin(id int32) (*m.Article, error) {
article, err := q.Article.
Preload(q.Article.Group).
Where(q.Article.ID.Eq(id)).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在")
}
if err != nil {
return nil, err
}
return article, nil
}
func (s *articleService) Create(data CreateArticleData) error {
if err := s.ensureGroupExists(data.GroupID); err != nil {
return err
}
return q.Article.Create(&m.Article{
GroupID: data.GroupID,
Title: data.Title,
Content: data.Content,
Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleStatusEnabled),
})
}
type CreateArticleData struct {
GroupID int32 `json:"group_id" validate:"required"`
Title string `json:"title" validate:"required"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
Status *m.ArticleStatus `json:"status"`
}
func (s *articleService) Update(data UpdateArticleData) error {
if data.GroupID != nil {
if err := s.ensureGroupExists(*data.GroupID); err != nil {
return err
}
}
do := make([]field.AssignExpr, 0)
if data.GroupID != nil {
do = append(do, q.Article.GroupID.Value(*data.GroupID))
}
if data.Title != nil {
do = append(do, q.Article.Title.Value(*data.Title))
}
if data.Content != nil {
do = append(do, q.Article.Content.Value(*data.Content))
}
if data.Sort != nil {
do = append(do, q.Article.Sort.Value(*data.Sort))
}
if data.Status != nil {
do = append(do, q.Article.Status.Value(int(*data.Status)))
}
if len(do) == 0 {
return nil
}
r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...)
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档状态已过期")
}
return nil
}
type UpdateArticleData struct {
ID int32 `json:"id" validate:"required"`
GroupID *int32 `json:"group_id"`
Title *string `json:"title"`
Content *string `json:"content"`
Sort *int32 `json:"sort"`
Status *m.ArticleStatus `json:"status"`
}
func (s *articleService) Delete(id int32) error {
r, err := q.Article.Where(q.Article.ID.Eq(id)).UpdateColumn(q.Article.DeletedAt, time.Now())
if err != nil {
return err
}
if r.RowsAffected == 0 {
return core.NewBizErr("文档状态已过期")
}
return nil
}
func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
groups, err := q.ArticleGroup.
Where(q.ArticleGroup.Status.Eq(int(m.ArticleGroupStatusEnabled))).
Order(q.ArticleGroup.Sort, q.ArticleGroup.CreatedAt).
Find()
if err != nil {
return nil, err
}
if len(groups) == 0 {
return []*ArticleNavGroup{}, nil
}
groupIDs := make([]int32, 0, len(groups))
result := make([]*ArticleNavGroup, 0, len(groups))
groupMap := make(map[int32]*ArticleNavGroup, len(groups))
for _, group := range groups {
groupIDs = append(groupIDs, group.ID)
item := &ArticleNavGroup{
ID: group.ID,
Name: group.Name,
Code: group.Code,
Articles: []*ArticleNavArticle{},
}
result = append(result, item)
groupMap[group.ID] = item
}
articles, err := q.Article.
Where(
q.Article.GroupID.In(groupIDs...),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
).
Omit(q.Article.Content).
Order(q.Article.Sort, q.Article.CreatedAt).
Find()
if err != nil {
return nil, err
}
for _, article := range articles {
group := groupMap[article.GroupID]
if group == nil {
continue
}
group.Articles = append(group.Articles, &ArticleNavArticle{
ID: article.ID,
Title: article.Title,
UpdatedAt: article.UpdatedAt,
})
}
return result, nil
}
type ArticleNavGroup struct {
ID int32 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Articles []*ArticleNavArticle `json:"articles"`
}
type ArticleNavArticle struct {
ID int32 `json:"id"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
article, err := q.Article.
Preload(q.Article.Group).
Where(
q.Article.ID.Eq(id),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
).
Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在")
}
if err != nil {
return nil, err
}
if article.Group == nil || article.Group.Status != m.ArticleGroupStatusEnabled {
return nil, core.NewBizErr("文档不存在")
}
return &ArticlePublicDetail{
ID: article.ID,
Title: article.Title,
Content: article.Content,
UpdatedAt: article.UpdatedAt,
Group: &ArticlePublicGroup{
ID: article.Group.ID,
Name: article.Group.Name,
Code: article.Group.Code,
},
}, nil
}
type ArticlePublicDetail struct {
ID int32 `json:"id"`
Title string `json:"title"`
Content *string `json:"content,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Group *ArticlePublicGroup `json:"group"`
}
type ArticlePublicGroup struct {
ID int32 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
}
func (s *articleService) ensureGroupExists(groupID int32) error {
_, err := q.ArticleGroup.Where(q.ArticleGroup.ID.Eq(groupID)).Take()
if errors.Is(err, gorm.ErrRecordNotFound) {
return core.NewBizErr("文档分组不存在")
}
return err
}