741 lines
29 KiB
Go
741 lines
29 KiB
Go
package services
|
|
|
|
import (
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/dto/request"
|
|
"agent-desk/internal/pkg/dto/response"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/errorsx"
|
|
"agent-desk/internal/repositories"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/mlogclub/simple/sqls"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var supportSlugPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
|
|
|
const (
|
|
supportUserContextKey = "supportUser"
|
|
)
|
|
|
|
var SupportService = &supportService{}
|
|
|
|
type supportService struct{}
|
|
|
|
type CommentListResult struct {
|
|
Comments []models.Comment
|
|
Replies map[int64][]models.Comment
|
|
Paging *sqls.Paging
|
|
}
|
|
|
|
func (s *supportService) RegisterUser(req request.SupportCustomerRegisterRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
|
name := strings.TrimSpace(req.Name)
|
|
email := normalizeSupportEmail(req.Email)
|
|
password := strings.TrimSpace(req.Password)
|
|
if name == "" || email == "" || len(password) < 8 {
|
|
return nil, errorsx.InvalidParam("name, email and at least 8 characters password are required")
|
|
}
|
|
if repositories.UserRepository.GetByUsername(sqls.DB(), email) != nil || repositories.UserRepository.GetByEmail(sqls.DB(), email) != nil {
|
|
return nil, errorsx.InvalidParam("email is already registered")
|
|
}
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now()
|
|
user := &models.User{
|
|
Username: email,
|
|
Nickname: name,
|
|
Email: &email,
|
|
Password: string(passwordHash),
|
|
PasswordSalt: "",
|
|
UserType: enums.UserTypeUser,
|
|
Status: enums.StatusOk,
|
|
AuditFields: supportAuditFields(0, name, now),
|
|
}
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil {
|
|
return err
|
|
}
|
|
return repositories.CustomerRepository.Create(ctx.Tx, &models.Customer{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
PrimaryEmail: email,
|
|
Status: enums.StatusOk,
|
|
AuditFields: supportAuditFields(user.ID, name, now),
|
|
})
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return AuthService.Login(request.LoginRequest{Username: email, Password: password}, authCfg, clientIP, userAgent)
|
|
}
|
|
|
|
func (s *supportService) RequireSupportUser(ctx *gin.Context) (*dto.AuthPrincipal, error) {
|
|
if principal := s.GetSupportUser(ctx); principal != nil {
|
|
return principal, nil
|
|
}
|
|
principal, err := AuthService.Authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx.Set(supportUserContextKey, principal)
|
|
return principal, nil
|
|
}
|
|
|
|
func (s *supportService) GetSupportUser(ctx *gin.Context) *dto.AuthPrincipal {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
value, _ := ctx.Get(supportUserContextKey)
|
|
if principal, ok := value.(*dto.AuthPrincipal); ok {
|
|
return principal
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *supportService) FindDocPages(cnd *sqls.Cnd) []models.DocPage {
|
|
return repositories.DocPageRepository.Find(sqls.DB(), cnd)
|
|
}
|
|
|
|
func (s *supportService) FindPublicDocNavigation() []models.DocPage {
|
|
return repositories.DocPageRepository.FindNavigationItems(sqls.DB(), sqls.NewCnd().Eq("status", enums.DocPageStatusPublished).Asc("sort_no").Asc("id"))
|
|
}
|
|
|
|
func (s *supportService) FindDocPageByID(id int64) *models.DocPage {
|
|
return repositories.DocPageRepository.Get(sqls.DB(), id)
|
|
}
|
|
|
|
func (s *supportService) FindDocPagePage(cnd *sqls.Cnd) ([]models.DocPage, *sqls.Paging) {
|
|
return repositories.DocPageRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
}
|
|
|
|
func (s *supportService) SortDocPages(req request.SortDocPagesRequest) error {
|
|
pages := repositories.DocPageRepository.Find(
|
|
sqls.DB(),
|
|
sqls.NewCnd().Eq("parent_id", req.ParentID).Asc("sort_no").Asc("id"),
|
|
)
|
|
if len(req.IDs) != len(pages) {
|
|
return errorsx.InvalidParamI18n("error.docPage.sortScopeMismatch")
|
|
}
|
|
existing := make(map[int64]struct{}, len(pages))
|
|
for _, page := range pages {
|
|
existing[page.ID] = struct{}{}
|
|
}
|
|
seen := make(map[int64]struct{}, len(req.IDs))
|
|
for _, id := range req.IDs {
|
|
if _, ok := existing[id]; !ok {
|
|
return errorsx.InvalidParamI18n("error.docPage.sortScopeMismatch")
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return errorsx.InvalidParamI18n("error.docPage.sortDuplicate")
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
for sortNo, id := range req.IDs {
|
|
if err := repositories.DocPageRepository.UpdateSort(ctx.Tx, id, sortNo); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *supportService) validateDocPageParent(id, parentID int64) error {
|
|
if parentID == 0 {
|
|
return nil
|
|
}
|
|
if id > 0 && id == parentID {
|
|
return errorsx.InvalidParam("page cannot be its own parent")
|
|
}
|
|
parent := repositories.DocPageRepository.Get(sqls.DB(), parentID)
|
|
if parent == nil {
|
|
return errorsx.InvalidParam("parent page not found")
|
|
}
|
|
depth := 2
|
|
visited := map[int64]bool{parentID: true}
|
|
for parent.ParentID > 0 {
|
|
if parent.ParentID == id || visited[parent.ParentID] {
|
|
return errorsx.InvalidParam("page hierarchy contains a cycle")
|
|
}
|
|
visited[parent.ParentID] = true
|
|
parent = repositories.DocPageRepository.Get(sqls.DB(), parent.ParentID)
|
|
if parent == nil {
|
|
return errorsx.InvalidParam("parent page not found")
|
|
}
|
|
depth++
|
|
if depth > 4 {
|
|
return errorsx.InvalidParam("page hierarchy supports at most four levels")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *supportService) SaveDocPage(req request.SaveDocPageRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) {
|
|
title, slug := strings.TrimSpace(req.Title), normalizeSupportSlug(req.Slug)
|
|
if title == "" || slug == "" {
|
|
return nil, errorsx.InvalidParam("title and slug are required")
|
|
}
|
|
if err := validateSupportSlug(slug); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validateDocPageParent(req.ID, req.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
if existing := repositories.DocPageRepository.GetByParentIDAndSlug(sqls.DB(), req.ParentID, slug); existing != nil && existing.ID != req.ID {
|
|
return nil, errorsx.InvalidParam("page slug already exists")
|
|
}
|
|
status := normalizeDocPageStatus(req.Status)
|
|
var current *models.DocPage
|
|
if req.ID > 0 {
|
|
current = repositories.DocPageRepository.Get(sqls.DB(), req.ID)
|
|
if current == nil {
|
|
return nil, errorsx.InvalidParam("page not found")
|
|
}
|
|
// Publication status is changed only by ChangeDocPageStatus so saving
|
|
// content cannot accidentally publish or withdraw a public document.
|
|
status = current.Status
|
|
}
|
|
if status == enums.DocPageStatusPublished && req.ParentID > 0 {
|
|
parent := repositories.DocPageRepository.Get(sqls.DB(), req.ParentID)
|
|
if parent == nil || parent.Status != enums.DocPageStatusPublished {
|
|
return nil, errorsx.InvalidParam("publish the parent page first")
|
|
}
|
|
}
|
|
if req.ID > 0 && status != enums.DocPageStatusPublished {
|
|
publishedChildren := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", req.ID).Eq("status", enums.DocPageStatusPublished).Page(1, 1))
|
|
if len(publishedChildren) > 0 {
|
|
return nil, errorsx.InvalidParam("unpublish child pages first")
|
|
}
|
|
}
|
|
tags, _ := json.Marshal(normalizeTags(req.Tags))
|
|
now := time.Now()
|
|
publishedAt := (*time.Time)(nil)
|
|
if status == enums.DocPageStatusPublished && req.ID == 0 {
|
|
publishedAt = &now
|
|
}
|
|
columns := map[string]any{"parent_id": req.ParentID, "title": title, "slug": slug, "summary": strings.TrimSpace(req.Summary), "content_type": normalizeContentType(req.ContentType), "content": req.Content, "cover_url": strings.TrimSpace(req.CoverURL), "tags_json": string(tags), "status": status, "sort_no": req.SortNo, "remark": strings.TrimSpace(req.Remark), "updated_at": now, "update_user_id": operator.UserID, "update_user_name": operator.Username}
|
|
if publishedAt != nil {
|
|
columns["published_at"] = publishedAt
|
|
}
|
|
if req.ID > 0 {
|
|
if err := repositories.DocPageRepository.Updates(sqls.DB(), req.ID, columns); err != nil {
|
|
return nil, err
|
|
}
|
|
return repositories.DocPageRepository.Get(sqls.DB(), req.ID), nil
|
|
}
|
|
item := &models.DocPage{ParentID: req.ParentID, Title: title, Slug: slug, Summary: strings.TrimSpace(req.Summary), ContentType: normalizeContentType(req.ContentType), Content: req.Content, CoverURL: strings.TrimSpace(req.CoverURL), TagsJSON: string(tags), Status: status, SortNo: req.SortNo, PublishedAt: publishedAt, Remark: strings.TrimSpace(req.Remark), AuditFields: auditFieldsFromOperator(operator, now)}
|
|
if err := repositories.DocPageRepository.Create(sqls.DB(), item); err != nil {
|
|
return nil, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *supportService) UpdateDocPageSettings(req request.UpdateDocPageSettingsRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) {
|
|
item := repositories.DocPageRepository.Get(sqls.DB(), req.ID)
|
|
if item == nil {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.notFound")
|
|
}
|
|
slug := normalizeSupportSlug(req.Slug)
|
|
if slug == "" {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.slugRequired")
|
|
}
|
|
if err := validateSupportSlug(slug); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validateDocPageParent(item.ID, req.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
if existing := repositories.DocPageRepository.GetByParentIDAndSlug(sqls.DB(), req.ParentID, slug); existing != nil && existing.ID != item.ID {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.slugDuplicate")
|
|
}
|
|
if item.Status == enums.DocPageStatusPublished && req.ParentID > 0 {
|
|
parent := repositories.DocPageRepository.Get(sqls.DB(), req.ParentID)
|
|
if parent == nil || parent.Status != enums.DocPageStatusPublished {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.publishParentFirst")
|
|
}
|
|
}
|
|
sortNo := item.SortNo
|
|
if req.ParentID != item.ParentID {
|
|
sortNo = len(repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", req.ParentID)))
|
|
}
|
|
now := time.Now()
|
|
columns := map[string]any{
|
|
"parent_id": req.ParentID,
|
|
"slug": slug,
|
|
"summary": strings.TrimSpace(req.Summary),
|
|
"sort_no": sortNo,
|
|
"updated_at": now,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
}
|
|
if err := repositories.DocPageRepository.Updates(sqls.DB(), item.ID, columns); err != nil {
|
|
return nil, err
|
|
}
|
|
return repositories.DocPageRepository.Get(sqls.DB(), item.ID), nil
|
|
}
|
|
|
|
func (s *supportService) ChangeDocPageStatus(req request.ChangeDocPageStatusRequest, operator *dto.AuthPrincipal) (*models.DocPage, error) {
|
|
item := repositories.DocPageRepository.Get(sqls.DB(), req.ID)
|
|
if item == nil {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.notFound")
|
|
}
|
|
status := req.Status
|
|
if status != enums.DocPageStatusDraft && status != enums.DocPageStatusPublished && status != enums.DocPageStatusHidden {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.invalidStatus")
|
|
}
|
|
if status == item.Status {
|
|
return item, nil
|
|
}
|
|
if status == enums.DocPageStatusPublished && item.ParentID > 0 {
|
|
parent := repositories.DocPageRepository.Get(sqls.DB(), item.ParentID)
|
|
if parent == nil || parent.Status != enums.DocPageStatusPublished {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.publishParentFirst")
|
|
}
|
|
}
|
|
if status != enums.DocPageStatusPublished {
|
|
publishedChildren := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", item.ID).Eq("status", enums.DocPageStatusPublished).Page(1, 1))
|
|
if len(publishedChildren) > 0 {
|
|
return nil, errorsx.InvalidParamI18n("error.docPage.unpublishChildrenFirst")
|
|
}
|
|
}
|
|
now := time.Now()
|
|
columns := map[string]any{
|
|
"status": status,
|
|
"updated_at": now,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
}
|
|
if status == enums.DocPageStatusPublished {
|
|
columns["published_at"] = now
|
|
}
|
|
if err := repositories.DocPageRepository.Updates(sqls.DB(), item.ID, columns); err != nil {
|
|
return nil, err
|
|
}
|
|
return repositories.DocPageRepository.Get(sqls.DB(), item.ID), nil
|
|
}
|
|
|
|
func (s *supportService) DeleteDocPage(id int64) error {
|
|
if repositories.DocPageRepository.Get(sqls.DB(), id) == nil {
|
|
return errorsx.InvalidParam("page not found")
|
|
}
|
|
children := repositories.DocPageRepository.Find(sqls.DB(), sqls.NewCnd().Eq("parent_id", id).Page(1, 1))
|
|
if len(children) > 0 {
|
|
return errorsx.InvalidParam("page still has child pages")
|
|
}
|
|
return repositories.DocPageRepository.Delete(sqls.DB(), id)
|
|
}
|
|
|
|
func (s *supportService) SaveCategory(req request.SaveCategoryRequest, operator *dto.AuthPrincipal) (*models.Category, error) {
|
|
name, slug := strings.TrimSpace(req.Name), normalizeSupportSlug(req.Slug)
|
|
if name == "" || slug == "" {
|
|
return nil, errorsx.InvalidParam("name and slug are required")
|
|
}
|
|
if err := validateSupportSlug(slug); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now()
|
|
if req.ID > 0 {
|
|
if repositories.CategoryRepository.Get(sqls.DB(), req.ID) == nil {
|
|
return nil, errorsx.InvalidParam("category not found")
|
|
}
|
|
if err := repositories.CategoryRepository.Updates(sqls.DB(), req.ID, map[string]any{"name": name, "slug": slug, "description": strings.TrimSpace(req.Description), "status": req.Status, "remark": strings.TrimSpace(req.Remark), "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": now}); err != nil {
|
|
return nil, err
|
|
}
|
|
return repositories.CategoryRepository.Get(sqls.DB(), req.ID), nil
|
|
}
|
|
item := &models.Category{Name: name, Slug: slug, Description: strings.TrimSpace(req.Description), Status: req.Status, Remark: strings.TrimSpace(req.Remark), AuditFields: auditFieldsFromOperator(operator, now)}
|
|
if item.Status == 0 {
|
|
item.Status = enums.StatusOk
|
|
}
|
|
if err := repositories.CategoryRepository.Create(sqls.DB(), item); err != nil {
|
|
return nil, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *supportService) UpdateCategorySort(ids []int64) error {
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
for i, id := range ids {
|
|
if err := repositories.CategoryRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *supportService) DeleteCategory(id int64) error {
|
|
if repositories.CategoryRepository.Get(sqls.DB(), id) == nil {
|
|
return errorsx.InvalidParam("category not found")
|
|
}
|
|
_, paging := repositories.PostRepository.FindPageByCnd(
|
|
sqls.DB(),
|
|
sqls.NewCnd().Eq("category_id", id).Page(1, 1),
|
|
)
|
|
if paging.Total > 0 {
|
|
return errorsx.InvalidParam("category is still used by posts")
|
|
}
|
|
return repositories.CategoryRepository.Delete(sqls.DB(), id)
|
|
}
|
|
|
|
func (s *supportService) CreatePost(req request.CreatePostRequest, principal *dto.AuthPrincipal) (*models.Post, error) {
|
|
title, content := strings.TrimSpace(req.Title), strings.TrimSpace(req.Content)
|
|
if principal == nil || principal.UserID <= 0 {
|
|
return nil, errorsx.Unauthorized("login is required")
|
|
}
|
|
if title == "" || content == "" {
|
|
return nil, errorsx.InvalidParam("title and content are required")
|
|
}
|
|
tags, _ := json.Marshal(normalizeTags(req.Tags))
|
|
now := time.Now()
|
|
item := &models.Post{CategoryID: req.CategoryID, UserID: principal.UserID, Title: title, ContentType: normalizeContentType(req.ContentType), Content: content, TagsJSON: string(tags), Status: enums.PostStatusNormal, AuditFields: supportAuditFields(principal.UserID, supportPrincipalName(principal), now)}
|
|
if err := repositories.PostRepository.Create(sqls.DB(), item); err != nil {
|
|
return nil, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *supportService) UpdatePost(req request.UpdatePostRequest, principal *dto.AuthPrincipal) error {
|
|
item := repositories.PostRepository.Get(sqls.DB(), req.ID)
|
|
if item == nil {
|
|
return errorsx.InvalidParam("post not found")
|
|
}
|
|
if principal == nil || item.UserID != principal.UserID {
|
|
return errorsx.Forbidden("only the post owner can update it")
|
|
}
|
|
if item.Status == enums.PostStatusResolved || item.Status == enums.PostStatusClosed {
|
|
return errorsx.BusinessError(1, "resolved or closed post cannot be edited")
|
|
}
|
|
tags, _ := json.Marshal(normalizeTags(req.Tags))
|
|
return repositories.PostRepository.Updates(sqls.DB(), req.ID, map[string]any{"category_id": req.CategoryID, "title": strings.TrimSpace(req.Title), "content_type": normalizeContentType(req.ContentType), "content": strings.TrimSpace(req.Content), "tags_json": string(tags), "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal), "updated_at": time.Now()})
|
|
}
|
|
|
|
func (s *supportService) CreateCustomerComment(req request.CreateCommentRequest, principal *dto.AuthPrincipal) (*models.Comment, error) {
|
|
if principal == nil {
|
|
return nil, errorsx.Unauthorized("login is required")
|
|
}
|
|
return s.createComment(req.PostID, req.ParentID, normalizeContentType(req.ContentType), strings.TrimSpace(req.Content), supportAuthorType(principal), principal.UserID, supportPrincipalName(principal))
|
|
}
|
|
|
|
func (s *supportService) CreateUserComment(req request.CreateCommentRequest, operator *dto.AuthPrincipal) (*models.Comment, error) {
|
|
if operator == nil {
|
|
return nil, errorsx.Unauthorized("login is required")
|
|
}
|
|
return s.createComment(req.PostID, req.ParentID, normalizeContentType(req.ContentType), strings.TrimSpace(req.Content), supportAuthorType(operator), operator.UserID, supportPrincipalName(operator))
|
|
}
|
|
|
|
func (s *supportService) createComment(postID, parentID int64, contentType, content string, authorType enums.CommentAuthorType, authorID int64, authorName string) (*models.Comment, error) {
|
|
if content == "" {
|
|
return nil, errorsx.InvalidParam("comment content is required")
|
|
}
|
|
post := repositories.PostRepository.Get(sqls.DB(), postID)
|
|
if post == nil || post.Status == enums.PostStatusDeleted || post.Status == enums.PostStatusClosed {
|
|
return nil, errorsx.InvalidParam("post is unavailable")
|
|
}
|
|
var parent *models.Comment
|
|
if parentID > 0 {
|
|
parent = repositories.CommentRepository.Get(sqls.DB(), parentID)
|
|
if parent == nil || parent.PostID != postID || parent.ParentID != 0 || parent.Status != enums.CommentStatusNormal {
|
|
return nil, errorsx.InvalidParam("parent comment is unavailable")
|
|
}
|
|
}
|
|
now := time.Now()
|
|
comment := &models.Comment{PostID: postID, ParentID: parentID, AuthorType: authorType, AuthorID: authorID, ContentType: contentType, Content: content, Status: enums.CommentStatusNormal, AuditFields: supportAuditFields(authorID, authorName, now)}
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if err := repositories.CommentRepository.Create(ctx.Tx, comment); err != nil {
|
|
return err
|
|
}
|
|
if parent != nil {
|
|
if err := repositories.CommentRepository.UpdateColumn(ctx.Tx, parent.ID, "reply_count", gorm.Expr("reply_count + ?", 1)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return repositories.PostRepository.Updates(ctx.Tx, postID, map[string]any{"comment_count": gorm.Expr("comment_count + ?", 1), "last_commented_at": now, "last_comment_user_type": authorType, "last_comment_user_id": authorID, "updated_at": now})
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return comment, nil
|
|
}
|
|
|
|
func (s *supportService) ListPostComments(postID, parentID int64, sort string, page, limit int) (*CommentListResult, error) {
|
|
post := repositories.PostRepository.Get(sqls.DB(), postID)
|
|
if post == nil || post.Status == enums.PostStatusHidden || post.Status == enums.PostStatusDeleted {
|
|
return nil, errorsx.InvalidParam("post not found")
|
|
}
|
|
if parentID > 0 {
|
|
parent := repositories.CommentRepository.Get(sqls.DB(), parentID)
|
|
if parent == nil || parent.PostID != postID || parent.Status == enums.CommentStatusHidden {
|
|
return nil, errorsx.InvalidParam("parent comment not found")
|
|
}
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 20
|
|
}
|
|
cnd := sqls.NewCnd().Eq("post_id", postID).Eq("parent_id", parentID).Where("status IN ?", []enums.CommentStatus{enums.CommentStatusNormal, enums.CommentStatusDeleted}).Page(page, limit)
|
|
if parentID > 0 {
|
|
cnd.Asc("id")
|
|
} else {
|
|
switch sort {
|
|
case "latest":
|
|
cnd.Desc("id")
|
|
case "hot":
|
|
cnd.Desc("reaction_count").Desc("reply_count").Desc("id")
|
|
default:
|
|
cnd.Desc("is_accepted").Asc("id")
|
|
}
|
|
}
|
|
comments, paging := repositories.CommentRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
result := &CommentListResult{Comments: comments, Replies: map[int64][]models.Comment{}, Paging: paging}
|
|
if parentID > 0 || len(comments) == 0 {
|
|
return result, nil
|
|
}
|
|
for _, comment := range comments {
|
|
if comment.ReplyCount <= 0 {
|
|
continue
|
|
}
|
|
result.Replies[comment.ID] = repositories.CommentRepository.Find(sqls.DB(), sqls.NewCnd().Eq("post_id", postID).Eq("parent_id", comment.ID).Where("status IN ?", []enums.CommentStatus{enums.CommentStatusNormal, enums.CommentStatusDeleted}).Asc("id").Page(1, 2))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *supportService) UpdateComment(req request.UpdateCommentRequest, principal *dto.AuthPrincipal) error {
|
|
comment := repositories.CommentRepository.Get(sqls.DB(), req.ID)
|
|
if comment == nil || comment.Status != enums.CommentStatusNormal {
|
|
return errorsx.InvalidParam("comment not found")
|
|
}
|
|
if principal == nil || comment.AuthorID != principal.UserID {
|
|
return errorsx.Forbidden("only the comment author can update it")
|
|
}
|
|
content := strings.TrimSpace(req.Content)
|
|
if content == "" {
|
|
return errorsx.InvalidParam("comment content is required")
|
|
}
|
|
return repositories.CommentRepository.Updates(sqls.DB(), comment.ID, map[string]any{"content_type": normalizeContentType(req.ContentType), "content": content, "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal), "updated_at": time.Now()})
|
|
}
|
|
|
|
func (s *supportService) DeleteComment(commentID int64, principal *dto.AuthPrincipal) error {
|
|
comment := repositories.CommentRepository.Get(sqls.DB(), commentID)
|
|
if comment == nil || comment.Status == enums.CommentStatusDeleted {
|
|
return errorsx.InvalidParam("comment not found")
|
|
}
|
|
if principal == nil || comment.AuthorID != principal.UserID {
|
|
return errorsx.Forbidden("only the comment author can delete it")
|
|
}
|
|
now := time.Now()
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if err := repositories.CommentRepository.Updates(ctx.Tx, comment.ID, map[string]any{"status": enums.CommentStatusDeleted, "is_accepted": false, "updated_at": now, "update_user_id": principal.UserID, "update_user_name": supportPrincipalName(principal)}); err != nil {
|
|
return err
|
|
}
|
|
columns := map[string]any{"updated_at": now}
|
|
if comment.IsAccepted {
|
|
columns["accepted_comment_id"] = int64(0)
|
|
columns["status"] = enums.PostStatusNormal
|
|
}
|
|
return repositories.PostRepository.Updates(ctx.Tx, comment.PostID, columns)
|
|
})
|
|
}
|
|
|
|
func (s *supportService) ReportComment(req request.ReportCommentRequest, principal *dto.AuthPrincipal) error {
|
|
if principal == nil {
|
|
return errorsx.Unauthorized("login is required")
|
|
}
|
|
comment := repositories.CommentRepository.Get(sqls.DB(), req.ID)
|
|
if comment == nil || comment.Status != enums.CommentStatusNormal {
|
|
return errorsx.InvalidParam("comment not found")
|
|
}
|
|
if existing := repositories.CommentReportRepository.Get(sqls.DB(), comment.ID, principal.UserID); existing != nil {
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
reason := strings.TrimSpace(req.Reason)
|
|
if len([]rune(reason)) > 255 {
|
|
reason = string([]rune(reason)[:255])
|
|
}
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if err := repositories.CommentReportRepository.Create(ctx.Tx, &models.CommentReport{CommentID: comment.ID, UserID: principal.UserID, Reason: reason, CreatedAt: now}); err != nil {
|
|
return err
|
|
}
|
|
return repositories.CommentRepository.UpdateColumn(ctx.Tx, comment.ID, "report_count", gorm.Expr("report_count + ?", 1))
|
|
})
|
|
}
|
|
|
|
func (s *supportService) AcceptComment(req request.AcceptCommentRequest, principal *dto.AuthPrincipal, operator *dto.AuthPrincipal) error {
|
|
post := repositories.PostRepository.Get(sqls.DB(), req.PostID)
|
|
comment := repositories.CommentRepository.Get(sqls.DB(), req.CommentID)
|
|
if post == nil || comment == nil || comment.PostID != post.ID {
|
|
return errorsx.InvalidParam("post or comment not found")
|
|
}
|
|
if comment.ParentID != 0 || comment.Status != enums.CommentStatusNormal {
|
|
return errorsx.InvalidParam("only top-level normal comments can be accepted")
|
|
}
|
|
if operator == nil {
|
|
if principal == nil || post.UserID != principal.UserID {
|
|
return errorsx.Forbidden("only owner or admin can accept the best comment")
|
|
}
|
|
}
|
|
now := time.Now()
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if err := ctx.Tx.Model(&models.Comment{}).Where("post_id = ?", post.ID).Update("is_accepted", false).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := repositories.CommentRepository.Updates(ctx.Tx, comment.ID, map[string]any{"is_accepted": true, "updated_at": now}); err != nil {
|
|
return err
|
|
}
|
|
return repositories.PostRepository.Updates(ctx.Tx, post.ID, map[string]any{"accepted_comment_id": comment.ID, "status": enums.PostStatusResolved, "updated_at": now})
|
|
})
|
|
}
|
|
|
|
func (s *supportService) ToggleReaction(targetType enums.ReactionTarget, targetID int64, reactionType enums.ReactionType, principal *dto.AuthPrincipal) error {
|
|
if principal == nil {
|
|
return errorsx.Unauthorized("login is required")
|
|
}
|
|
if reactionType == "" {
|
|
reactionType = enums.ReactionTypeLike
|
|
}
|
|
if reactionType != enums.ReactionTypeLike {
|
|
return errorsx.InvalidParam("reaction type is unsupported")
|
|
}
|
|
updateReactionCount := func(db *gorm.DB, delta int) error {
|
|
switch targetType {
|
|
case enums.ReactionTargetPost:
|
|
if repositories.PostRepository.Get(sqls.DB(), targetID) == nil {
|
|
return errorsx.InvalidParam("post not found")
|
|
}
|
|
return repositories.PostRepository.UpdateColumn(db, targetID, "reaction_count", gorm.Expr("reaction_count + ?", delta))
|
|
case enums.ReactionTargetComment:
|
|
if repositories.CommentRepository.Get(sqls.DB(), targetID) == nil {
|
|
return errorsx.InvalidParam("comment not found")
|
|
}
|
|
return repositories.CommentRepository.Updates(db, targetID, map[string]any{"reaction_count": gorm.Expr("reaction_count + ?", delta), "updated_at": time.Now()})
|
|
default:
|
|
return errorsx.InvalidParam("reaction target type is unsupported")
|
|
}
|
|
}
|
|
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
existing := repositories.ReactionRepository.Get(ctx.Tx, string(targetType), targetID, principal.UserID, string(reactionType))
|
|
delta := 1
|
|
if existing != nil {
|
|
delta = -1
|
|
if err := repositories.ReactionRepository.Delete(ctx.Tx, string(targetType), targetID, principal.UserID, string(reactionType)); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
now := time.Now()
|
|
reaction := &models.Reaction{TargetType: targetType, TargetID: targetID, UserID: principal.UserID, ReactionType: reactionType, CreatedAt: now, UpdatedAt: now}
|
|
if err := repositories.ReactionRepository.Create(ctx.Tx, reaction); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return updateReactionCount(ctx.Tx, delta)
|
|
})
|
|
}
|
|
|
|
func (s *supportService) ModeratePost(req request.ModeratePostRequest) error {
|
|
if repositories.PostRepository.Get(sqls.DB(), req.ID) == nil {
|
|
return errorsx.InvalidParam("post not found")
|
|
}
|
|
return repositories.PostRepository.Updates(sqls.DB(), req.ID, map[string]any{"status": req.Status, "updated_at": time.Now()})
|
|
}
|
|
|
|
func (s *supportService) ModerateComment(req request.ModerateCommentRequest) error {
|
|
if repositories.CommentRepository.Get(sqls.DB(), req.ID) == nil {
|
|
return errorsx.InvalidParam("comment not found")
|
|
}
|
|
return repositories.CommentRepository.Updates(sqls.DB(), req.ID, map[string]any{"status": req.Status, "updated_at": time.Now()})
|
|
}
|
|
|
|
func (s *supportService) FeedbackDocPage(req request.DocPageFeedbackRequest) error {
|
|
column := "unhelpful_count"
|
|
if req.Helpful {
|
|
column = "helpful_count"
|
|
}
|
|
return repositories.DocPageRepository.UpdateColumn(sqls.DB(), req.ID, column, gorm.Expr(column+" + ?", 1))
|
|
}
|
|
|
|
func normalizeSupportEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
func normalizeSupportSlug(slug string) string {
|
|
return strings.ToLower(strings.TrimSpace(slug))
|
|
}
|
|
|
|
func validateSupportSlug(slug string) error {
|
|
if !supportSlugPattern.MatchString(slug) {
|
|
return errorsx.InvalidParamI18n("error.docPage.invalidSlug")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeContentType(contentType string) string {
|
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
|
if contentType == "html" {
|
|
return "html"
|
|
}
|
|
return "markdown"
|
|
}
|
|
|
|
func normalizeDocPageStatus(status enums.DocPageStatus) enums.DocPageStatus {
|
|
if status == "" {
|
|
return enums.DocPageStatusDraft
|
|
}
|
|
return status
|
|
}
|
|
|
|
func normalizeTags(tags []string) []string {
|
|
ret := make([]string, 0, len(tags))
|
|
seen := map[string]struct{}{}
|
|
for _, tag := range tags {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[tag]; ok {
|
|
continue
|
|
}
|
|
seen[tag] = struct{}{}
|
|
ret = append(ret, tag)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func auditFieldsFromOperator(operator *dto.AuthPrincipal, now time.Time) models.AuditFields {
|
|
if operator == nil {
|
|
return supportAuditFields(0, "system", now)
|
|
}
|
|
return supportAuditFields(operator.UserID, operator.Username, now)
|
|
}
|
|
|
|
func supportAuditFields(userID int64, username string, now time.Time) models.AuditFields {
|
|
return models.AuditFields{CreatedAt: now, CreateUserID: userID, CreateUserName: username, UpdatedAt: now, UpdateUserID: userID, UpdateUserName: username}
|
|
}
|
|
|
|
func supportPrincipalName(principal *dto.AuthPrincipal) string {
|
|
if principal == nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(principal.Nickname) != "" {
|
|
return strings.TrimSpace(principal.Nickname)
|
|
}
|
|
return strings.TrimSpace(principal.Username)
|
|
}
|
|
|
|
func supportAuthorType(principal *dto.AuthPrincipal) enums.CommentAuthorType {
|
|
if principal != nil && principal.UserType == enums.UserTypeEmployee {
|
|
return enums.CommentAuthorTypeEmployee
|
|
}
|
|
return enums.CommentAuthorTypeUser
|
|
}
|