package services import ( "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path" "path/filepath" "platform/pkg/env" "platform/pkg/u" "platform/web/core" m "platform/web/models" q "platform/web/queries" "strings" "time" "github.com/google/uuid" "gorm.io/gen/field" "gorm.io/gorm" ) var Article = &articleService{} type articleService struct{} 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) } 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 }