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