Version: 0.9.78.dev.260506
This commit is contained in:
284
backend/services/taskclassforum/sv/comment.go
Normal file
284
backend/services/taskclassforum/sv/comment.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/commenttree"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
)
|
||||
|
||||
// ListComments 查询评论树。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端;
|
||||
// 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装;
|
||||
// 3. 采用 cache-aside 缓存去个性化评论树,返回前再补当前用户的删除权限。
|
||||
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
if postID == 0 {
|
||||
return nil, forumcontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
sortBy = normalizeCommentSort(sortBy)
|
||||
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
|
||||
if cachedItems, cachedPage, hit := s.getCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy); hit {
|
||||
return personalizeCommentNodesForActor(cachedItems, actorUserID), cachedPage, nil
|
||||
}
|
||||
|
||||
total, err := s.forumDAO.CountRootComments(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
roots, err := s.forumDAO.ListRootComments(ctx, postID, page, pageSize, sortBy)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
resultPage := pageResult(page, pageSize, total)
|
||||
if len(roots) == 0 {
|
||||
emptyItems := []forumcontracts.ForumCommentNode{}
|
||||
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, emptyItems, resultPage)
|
||||
return emptyItems, resultPage, nil
|
||||
}
|
||||
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0)
|
||||
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage)
|
||||
return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil
|
||||
}
|
||||
|
||||
// CreateComment 创建帖子评论或多层回复。
|
||||
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.PostID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if err := validateRuneMax(req.Content, maxCommentLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.forumDAO.FindCommentByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return commentModelToNode(*existing, req.ActorUserID), nil
|
||||
}
|
||||
}
|
||||
|
||||
var created forummodel.ForumComment
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
if req.ParentCommentID != nil {
|
||||
parent, err := txDAO.FindCommentByID(ctx, *req.ParentCommentID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||
}
|
||||
if parent.PostID != req.PostID {
|
||||
return respond.MissingParam
|
||||
}
|
||||
}
|
||||
created = forummodel.ForumComment{
|
||||
PostID: req.PostID,
|
||||
ParentCommentID: req.ParentCommentID,
|
||||
UserID: req.ActorUserID,
|
||||
Content: strings.TrimSpace(req.Content),
|
||||
Status: forummodel.ForumCommentStatusVisible,
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
}
|
||||
if err := txDAO.CreateComment(ctx, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, req.PostID, "comment_count", 1)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.bumpCommentTreeVersionBestEffort(req.PostID)
|
||||
return commentModelToNode(created, req.ActorUserID), nil
|
||||
}
|
||||
|
||||
// DeleteComment 软删除当前用户自己的评论。
|
||||
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actorUserID == 0 || commentID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
var deletedAt *string
|
||||
var changedPostID uint64
|
||||
status := forummodel.ForumCommentStatusDeleted
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
comment, err := txDAO.LockCommentByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||
}
|
||||
if comment.UserID != actorUserID {
|
||||
return respond.ErrUnauthorized
|
||||
}
|
||||
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||
deletedAt = formatTimePtr(comment.DeletedAt)
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if err := txDAO.SoftDeleteComment(ctx, commentID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil {
|
||||
return err
|
||||
}
|
||||
changedPostID = comment.PostID
|
||||
deletedAt = formatTimePtr(&now)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changedPostID != 0 {
|
||||
s.bumpCommentTreeVersionBestEffort(changedPostID)
|
||||
}
|
||||
return &forumcontracts.DeleteForumCommentResult{
|
||||
CommentID: commentID,
|
||||
Status: status,
|
||||
Content: "",
|
||||
DeletedAt: deletedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func commentModelToNode(comment forummodel.ForumComment, actorUserID uint64) *forumcontracts.ForumCommentNode {
|
||||
content := comment.Content
|
||||
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||
content = "该评论已删除"
|
||||
}
|
||||
return &forumcontracts.ForumCommentNode{
|
||||
CommentID: comment.ID,
|
||||
PostID: comment.PostID,
|
||||
ParentCommentID: comment.ParentCommentID,
|
||||
Content: content,
|
||||
Status: comment.Status,
|
||||
Author: userBrief(comment.UserID),
|
||||
CanDelete: comment.Status == forummodel.ForumCommentStatusVisible && comment.UserID == actorUserID,
|
||||
CreatedAt: formatTime(comment.CreatedAt),
|
||||
DeletedAt: formatTimePtr(comment.DeletedAt),
|
||||
Children: []forumcontracts.ForumCommentNode{},
|
||||
}
|
||||
}
|
||||
|
||||
func filterCommentsForRoots(allComments []forummodel.ForumComment, roots []forummodel.ForumComment) []forummodel.ForumComment {
|
||||
filtered := make([]forummodel.ForumComment, 0, len(allComments))
|
||||
included := make(map[uint64]struct{}, len(allComments))
|
||||
for _, root := range roots {
|
||||
filtered = append(filtered, root)
|
||||
included[root.ID] = struct{}{}
|
||||
}
|
||||
candidateSet := make(map[uint64]struct{}, len(allComments))
|
||||
for _, root := range roots {
|
||||
collectDescendantCommentIDs(root.ID, allComments, candidateSet)
|
||||
}
|
||||
for _, comment := range allComments {
|
||||
if _, ok := included[comment.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := candidateSet[comment.ID]; ok {
|
||||
filtered = append(filtered, comment)
|
||||
included[comment.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumComment, result map[uint64]struct{}) {
|
||||
for _, comment := range comments {
|
||||
if comment.ParentCommentID == nil || *comment.ParentCommentID != parentID {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[comment.ID]; exists {
|
||||
continue
|
||||
}
|
||||
result[comment.ID] = struct{}{}
|
||||
collectDescendantCommentIDs(comment.ID, comments, result)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCommentSort(sortBy string) string {
|
||||
if strings.TrimSpace(sortBy) == "latest" {
|
||||
return "latest"
|
||||
}
|
||||
return "oldest"
|
||||
}
|
||||
|
||||
func (s *Service) getCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool) {
|
||||
if s == nil || s.commentTreeCache == nil {
|
||||
return nil, forumcontracts.PageResult{}, false
|
||||
}
|
||||
items, resultPage, hit, err := s.commentTreeCache.GetCommentTree(ctx, postID, page, pageSize, sortBy)
|
||||
if err != nil {
|
||||
log.Printf("评论树缓存读取失败,已降级回源 DB post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||
return nil, forumcontracts.PageResult{}, false
|
||||
}
|
||||
return items, resultPage, hit
|
||||
}
|
||||
|
||||
func (s *Service) setCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string, items []forumcontracts.ForumCommentNode, resultPage forumcontracts.PageResult) {
|
||||
if s == nil || s.commentTreeCache == nil {
|
||||
return
|
||||
}
|
||||
if err := s.commentTreeCache.SetCommentTree(ctx, postID, page, pageSize, sortBy, items, resultPage); err != nil {
|
||||
log.Printf("评论树缓存写入失败,已保持 DB 结果返回 post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) bumpCommentTreeVersionBestEffort(postID uint64) {
|
||||
if s == nil || s.commentTreeCache == nil || postID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 写库事务已经成功,缓存失效不应再反向影响评论发布/删除结果。
|
||||
// 2. 使用独立短超时 context,避免客户端取消请求后漏掉版本递增。
|
||||
// 3. 失败时只记录日志,旧缓存依靠短 TTL 自然过期作为兜底。
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.commentTreeCache.BumpCommentTreeVersion(cacheCtx, postID); err != nil {
|
||||
log.Printf("评论树缓存版本递增失败,等待短 TTL 自然过期 post_id=%d err=%v", postID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func personalizeCommentNodesForActor(nodes []forumcontracts.ForumCommentNode, actorUserID uint64) []forumcontracts.ForumCommentNode {
|
||||
if nodes == nil {
|
||||
return []forumcontracts.ForumCommentNode{}
|
||||
}
|
||||
result := make([]forumcontracts.ForumCommentNode, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
result = append(result, personalizeCommentNodeForActor(node, actorUserID))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func personalizeCommentNodeForActor(node forumcontracts.ForumCommentNode, actorUserID uint64) forumcontracts.ForumCommentNode {
|
||||
children := make([]forumcontracts.ForumCommentNode, 0, len(node.Children))
|
||||
for _, child := range node.Children {
|
||||
children = append(children, personalizeCommentNodeForActor(child, actorUserID))
|
||||
}
|
||||
node.Children = children
|
||||
node.CanDelete = actorUserID != 0 &&
|
||||
node.Author.UserID == actorUserID &&
|
||||
node.Status == forummodel.ForumCommentStatusVisible
|
||||
return node
|
||||
}
|
||||
8
backend/services/taskclassforum/sv/errors.go
Normal file
8
backend/services/taskclassforum/sv/errors.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package sv
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass,但 adapter 尚未注入。
|
||||
ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil")
|
||||
)
|
||||
294
backend/services/taskclassforum/sv/helpers.go
Normal file
294
backend/services/taskclassforum/sv/helpers.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 50
|
||||
maxPostTitleLen = 40
|
||||
maxSummaryLen = 300
|
||||
maxTagCount = 5
|
||||
maxTagLength = 12
|
||||
maxCommentLen = 500
|
||||
maxImportTitle = 80
|
||||
)
|
||||
|
||||
func normalizePage(page int, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = defaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
func pageResult(page int, pageSize int, total int64) forumcontracts.PageResult {
|
||||
return forumcontracts.PageResult{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: int(total),
|
||||
HasMore: int64(page*pageSize) < total,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTags(tags []string) ([]string, error) {
|
||||
result := make([]string, 0, len(tags))
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
for _, raw := range tags {
|
||||
tag := strings.TrimSpace(raw)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if len([]rune(tag)) > maxTagLength {
|
||||
return nil, respond.ParamTooLong
|
||||
}
|
||||
if _, exists := seen[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
result = append(result, tag)
|
||||
if len(result) > maxTagCount {
|
||||
return nil, respond.ParamTooLong
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateRuneMax(value string, maxLen int) error {
|
||||
if len([]rune(strings.TrimSpace(value))) > maxLen {
|
||||
return respond.ParamTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tagsToJSON(tags []string) (string, error) {
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func tagsFromJSON(raw string) []string {
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func intSliceToJSONPtr(values []int) (*string, error) {
|
||||
if values == nil {
|
||||
values = []int{}
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := string(raw)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func stringSliceToJSONPtr(values []string) (*string, error) {
|
||||
if values == nil {
|
||||
values = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := string(raw)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func intSliceFromJSONPtr(raw *string) []int {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return []int{}
|
||||
}
|
||||
var values []int
|
||||
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
||||
return []int{}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func stringSliceFromJSONPtr(raw *string) []string {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func parseSnapshotDate(value string) *time.Time {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &parsed
|
||||
}
|
||||
|
||||
func formatDate(value *time.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func formatTimePtr(value *time.Time) *string {
|
||||
if value == nil || value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
formatted := value.Format(time.RFC3339)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
func userBrief(userID uint64) forumcontracts.UserBrief {
|
||||
return forumcontracts.UserBrief{
|
||||
UserID: userID,
|
||||
Nickname: fmt.Sprintf("用户%d", userID),
|
||||
}
|
||||
}
|
||||
|
||||
func countersFromPost(post forummodel.ForumPost) forumcontracts.ForumPostCounters {
|
||||
return forumcontracts.ForumPostCounters{
|
||||
LikeCount: post.LikeCount,
|
||||
CommentCount: post.CommentCount,
|
||||
ImportCount: post.ImportCount,
|
||||
}
|
||||
}
|
||||
|
||||
func viewerState(postID uint64, liked map[uint64]bool, imported map[uint64]bool) forumcontracts.ForumPostViewerState {
|
||||
return forumcontracts.ForumPostViewerState{
|
||||
Liked: liked[postID],
|
||||
ImportedOnce: imported[postID],
|
||||
}
|
||||
}
|
||||
|
||||
func templateSummaryFromTemplate(template *forummodel.ForumPostTemplate, itemCount int) forumcontracts.TemplateSummary {
|
||||
if template == nil {
|
||||
return forumcontracts.TemplateSummary{}
|
||||
}
|
||||
return forumcontracts.TemplateSummary{
|
||||
TaskCount: itemCount,
|
||||
Mode: template.Mode,
|
||||
StartDate: formatDate(template.StartDate),
|
||||
EndDate: formatDate(template.EndDate),
|
||||
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||
}
|
||||
}
|
||||
|
||||
func postBriefFromModel(post forummodel.ForumPost, template *forummodel.ForumPostTemplate, itemCount int, state forumcontracts.ForumPostViewerState) forumcontracts.ForumPostBrief {
|
||||
return forumcontracts.ForumPostBrief{
|
||||
PostID: post.ID,
|
||||
Title: post.Title,
|
||||
Summary: post.Summary,
|
||||
Tags: tagsFromJSON(post.TagsJSON),
|
||||
Author: userBrief(post.AuthorUserID),
|
||||
TemplateSummary: templateSummaryFromTemplate(template, itemCount),
|
||||
Counters: countersFromPost(post),
|
||||
ViewerState: state,
|
||||
Status: post.Status,
|
||||
CreatedAt: formatTime(post.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func templateDetailFromModel(template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) forumcontracts.TemplateDetail {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].Order < items[j].Order
|
||||
})
|
||||
preview := make([]forumcontracts.TemplateItemPreview, 0, len(items))
|
||||
for _, item := range items {
|
||||
preview = append(preview, forumcontracts.TemplateItemPreview{
|
||||
ItemID: item.ID,
|
||||
Order: item.Order,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
return forumcontracts.TemplateDetail{
|
||||
Mode: template.Mode,
|
||||
StartDate: formatDate(template.StartDate),
|
||||
EndDate: formatDate(template.EndDate),
|
||||
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||
TaskCount: len(items),
|
||||
ItemsPreview: preview,
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFromTemplate(post forummodel.ForumPost, template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) TaskClassSnapshot {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].Order < items[j].Order
|
||||
})
|
||||
snapshotItems := make([]TaskClassSnapshotItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
snapshotItems = append(snapshotItems, TaskClassSnapshotItem{
|
||||
TaskItemID: item.SourceTaskItemID,
|
||||
Order: item.Order,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
return TaskClassSnapshot{
|
||||
TaskClassID: template.SourceTaskClassID,
|
||||
Title: post.Title,
|
||||
Mode: template.Mode,
|
||||
StartDate: formatDate(template.StartDate),
|
||||
EndDate: formatDate(template.EndDate),
|
||||
SubjectType: template.SubjectType,
|
||||
DifficultyLevel: template.DifficultyLevel,
|
||||
CognitiveIntensity: template.CognitiveIntensity,
|
||||
TotalSlots: template.TotalSlots,
|
||||
AllowFillerCourse: template.AllowFillerCourse,
|
||||
Strategy: template.Strategy,
|
||||
ExcludedSlots: intSliceFromJSONPtr(template.ExcludedSlotsJSON),
|
||||
ExcludedDaysOfWeek: intSliceFromJSONPtr(template.ExcludedDaysOfWeekJSON),
|
||||
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||
Items: snapshotItems,
|
||||
ConfigSnapshotJSON: stringFromPtr(template.ConfigSnapshotJSON),
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromPtr(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func stringPtrFromNonEmpty(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
283
backend/services/taskclassforum/sv/import.go
Normal file
283
backend/services/taskclassforum/sv/import.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
)
|
||||
|
||||
// ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 同一用户同一帖子只允许导入一次,由 forum_imports 唯一约束兜底;
|
||||
// 2. 只通过 TaskClassSnapshotPort 创建 TaskClass,不写 schedule;
|
||||
// 3. 只写 forum_imports 和 import_count,Token 奖励后续基于 event_id 消费。
|
||||
func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.PostID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if strings.TrimSpace(req.TargetTitle) != "" {
|
||||
if err := validateRuneMax(req.TargetTitle, maxImportTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.taskClassPort == nil {
|
||||
return nil, ErrTaskClassPortMissing
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.forumDAO.FindImportByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && existing.Status == forummodel.ForumImportStatusImported {
|
||||
return s.importResultWithCurrentImportCount(ctx, *existing), nil
|
||||
}
|
||||
}
|
||||
existing, err := s.forumDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && existing.Status == forummodel.ForumImportStatusImported {
|
||||
return s.importResultWithCurrentImportCount(ctx, *existing), nil
|
||||
}
|
||||
if existing != nil && existing.Status == forummodel.ForumImportStatusFailed && existing.NewTaskClassID != nil {
|
||||
return s.recoverCreatedImport(ctx, req, *existing)
|
||||
}
|
||||
if existing != nil && existing.Status == forummodel.ForumImportStatusPending {
|
||||
return nil, respond.RequestIsProcessing
|
||||
}
|
||||
|
||||
post, template, items, err := s.loadPostTemplate(ctx, req.PostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot := snapshotFromTemplate(*post, *template, items)
|
||||
targetTitle := strings.TrimSpace(req.TargetTitle)
|
||||
if targetTitle == "" {
|
||||
targetTitle = post.Title
|
||||
}
|
||||
|
||||
pending, err := s.reserveImport(ctx, req, post.AuthorUserID, targetTitle, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending.Status == forummodel.ForumImportStatusImported {
|
||||
return s.importResultWithCurrentImportCount(ctx, *pending), nil
|
||||
}
|
||||
|
||||
created, err := s.taskClassPort.CreateTaskClassFromSnapshot(ctx, req.ActorUserID, snapshot, targetTitle)
|
||||
if err != nil {
|
||||
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Error(), time.Now())
|
||||
return nil, err
|
||||
}
|
||||
if created == nil {
|
||||
err := respond.InternalError(fmt.Errorf("taskclass adapter returned nil created taskclass"))
|
||||
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Info, time.Now())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imported forummodel.ForumImport
|
||||
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if again == nil || again.ID != pending.ID {
|
||||
return respond.RequestIsProcessing
|
||||
}
|
||||
if again.Status == forummodel.ForumImportStatusImported {
|
||||
imported = *again
|
||||
return nil
|
||||
}
|
||||
finalizedAt := time.Now()
|
||||
if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, finalizedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
imported = *again
|
||||
imported.NewTaskClassID = &created.TaskClassID
|
||||
imported.TargetTitle = created.Title
|
||||
imported.Status = forummodel.ForumImportStatusImported
|
||||
if again.Status != forummodel.ForumImportStatusImported {
|
||||
payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt)
|
||||
if again.EventID != "" {
|
||||
payload.EventID = again.EventID
|
||||
}
|
||||
// 调用目的:导入成功和作者奖励事件必须同事务提交,避免只创建副本却永久漏发奖励。
|
||||
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||
if publishErr != nil {
|
||||
return publishErr
|
||||
}
|
||||
if !handled {
|
||||
rewardPayload = &payload
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
_ = s.forumDAO.MarkImportFailedAfterTaskClassCreated(ctx, pending.ID, created.TaskClassID, created.Title, err.Error(), time.Now())
|
||||
return nil, err
|
||||
}
|
||||
if rewardPayload != nil {
|
||||
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||
}
|
||||
result := importResultFromModel(imported)
|
||||
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||
result.ImportCount = postAfter.ImportCount
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) reserveImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, authorUserID uint64, targetTitle string, idempotencyKey string) (*forummodel.ForumImport, error) {
|
||||
var reserved *forummodel.ForumImport
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
existing, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
switch existing.Status {
|
||||
case forummodel.ForumImportStatusImported:
|
||||
reserved = existing
|
||||
return nil
|
||||
case forummodel.ForumImportStatusPending:
|
||||
return respond.RequestIsProcessing
|
||||
case forummodel.ForumImportStatusFailed:
|
||||
if existing.NewTaskClassID != nil {
|
||||
reserved = existing
|
||||
return nil
|
||||
}
|
||||
if err := txDAO.UpdateImportProcessing(ctx, existing.ID, targetTitle, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
existing.Status = forummodel.ForumImportStatusPending
|
||||
existing.TargetTitle = targetTitle
|
||||
reserved = existing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
item := &forummodel.ForumImport{
|
||||
PostID: req.PostID,
|
||||
UserID: req.ActorUserID,
|
||||
AuthorUserID: authorUserID,
|
||||
TargetTitle: targetTitle,
|
||||
Status: forummodel.ForumImportStatusPending,
|
||||
EventID: forumImportEventID(req.PostID, req.ActorUserID),
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
}
|
||||
if err := txDAO.CreateImport(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
reserved = item
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reserved, nil
|
||||
}
|
||||
|
||||
func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, existing forummodel.ForumImport) (*forumcontracts.ImportForumPostResult, error) {
|
||||
if existing.NewTaskClassID == nil {
|
||||
return nil, respond.RequestIsProcessing
|
||||
}
|
||||
imported := existing
|
||||
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if again == nil || again.ID != existing.ID {
|
||||
return respond.RequestIsProcessing
|
||||
}
|
||||
if again.Status == forummodel.ForumImportStatusImported {
|
||||
imported = *again
|
||||
return nil
|
||||
}
|
||||
if again.Status != forummodel.ForumImportStatusFailed || again.NewTaskClassID == nil {
|
||||
return respond.RequestIsProcessing
|
||||
}
|
||||
finalizedAt := time.Now()
|
||||
if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, finalizedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
imported = *again
|
||||
imported.Status = forummodel.ForumImportStatusImported
|
||||
payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt)
|
||||
if again.EventID != "" {
|
||||
payload.EventID = again.EventID
|
||||
}
|
||||
// 调用目的:恢复已创建副本的导入记录时,同步补齐奖励 outbox,保证恢复路径和首次成功路径一致。
|
||||
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||
if publishErr != nil {
|
||||
return publishErr
|
||||
}
|
||||
if !handled {
|
||||
rewardPayload = &payload
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rewardPayload != nil {
|
||||
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||
}
|
||||
result := importResultFromModel(imported)
|
||||
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||
result.ImportCount = postAfter.ImportCount
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func importResultFromModel(item forummodel.ForumImport) *forumcontracts.ImportForumPostResult {
|
||||
var newTaskClassID uint64
|
||||
if item.NewTaskClassID != nil {
|
||||
newTaskClassID = *item.NewTaskClassID
|
||||
}
|
||||
return &forumcontracts.ImportForumPostResult{
|
||||
ImportID: item.ID,
|
||||
PostID: item.PostID,
|
||||
NewTaskClassID: newTaskClassID,
|
||||
TaskClassTitle: item.TargetTitle,
|
||||
CreatedAt: formatTime(item.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// importResultWithCurrentImportCount 复用已有导入记录时补齐帖子当前导入计数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只补齐响应展示用的 import_count,不改变 forum_imports 状态;
|
||||
// 2. 查询帖子失败时保留基础导入回执,避免幂等重放因为展示字段失败而误报导入失败;
|
||||
// 3. 新导入路径仍以事务内 AddPostCounter 为准,这里只处理已导入短路路径。
|
||||
func (s *Service) importResultWithCurrentImportCount(ctx context.Context, item forummodel.ForumImport) *forumcontracts.ImportForumPostResult {
|
||||
result := importResultFromModel(item)
|
||||
if post, err := s.forumDAO.FindPublishedPost(ctx, item.PostID); err == nil {
|
||||
result.ImportCount = post.ImportCount
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumImportEventID(postID uint64, userID uint64) string {
|
||||
return sharedevents.ForumRewardEventID(sharedevents.ForumPostImportedEventType, postID, userID)
|
||||
}
|
||||
140
backend/services/taskclassforum/sv/like.go
Normal file
140
backend/services/taskclassforum/sv/like.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
)
|
||||
|
||||
// LikePost 点赞计划帖子。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 保证同一用户同一帖子只有一个 active 点赞状态;
|
||||
// 2. 维护帖子 like_count 计数字段;
|
||||
// 3. 只在首次创建 like 记录时补发 outbox 事件,取消后重新激活旧记录不重复发奖励。
|
||||
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||
}
|
||||
if actorUserID == 0 || postID == 0 {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||
}
|
||||
|
||||
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
post, err := txDAO.LockPublishedPost(ctx, postID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if like == nil {
|
||||
payload, createErr := createActiveLike(ctx, txDAO, post, actorUserID)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
// 调用目的:优先把首次点赞奖励事件写入当前事务,保证点赞记录和 outbox 入队原子提交。
|
||||
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||
if publishErr != nil {
|
||||
return publishErr
|
||||
}
|
||||
if !handled {
|
||||
rewardPayload = &payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if like.Status == forummodel.ForumLikeStatusActive {
|
||||
return nil
|
||||
}
|
||||
if err := txDAO.ActivateLike(ctx, like.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, postID, "like_count", 1)
|
||||
}); err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||
}
|
||||
|
||||
if rewardPayload != nil {
|
||||
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||
}
|
||||
return s.postInteractionState(ctx, actorUserID, postID)
|
||||
}
|
||||
|
||||
// UnlikePost 取消计划帖子点赞。
|
||||
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||
}
|
||||
if actorUserID == 0 || postID == 0 {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||
}
|
||||
|
||||
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||
if _, err := txDAO.LockPublishedPost(ctx, postID); err != nil {
|
||||
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if like == nil || like.Status != forummodel.ForumLikeStatusActive {
|
||||
return nil
|
||||
}
|
||||
if err := txDAO.CancelLike(ctx, like.ID, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return txDAO.AddPostCounter(ctx, postID, "like_count", -1)
|
||||
}); err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||
}
|
||||
return s.postInteractionState(ctx, actorUserID, postID)
|
||||
}
|
||||
|
||||
func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) (sharedevents.ForumPostRewardPayload, error) {
|
||||
like := &forummodel.ForumLike{
|
||||
PostID: post.ID,
|
||||
UserID: actorUserID,
|
||||
AuthorUserID: post.AuthorUserID,
|
||||
Status: forummodel.ForumLikeStatusActive,
|
||||
EventID: forumLikeEventID(post.ID, actorUserID),
|
||||
}
|
||||
if err := txDAO.CreateLike(ctx, like); err != nil {
|
||||
return sharedevents.ForumPostRewardPayload{}, err
|
||||
}
|
||||
if err := txDAO.AddPostCounter(ctx, post.ID, "like_count", 1); err != nil {
|
||||
return sharedevents.ForumPostRewardPayload{}, err
|
||||
}
|
||||
|
||||
likedAt := like.LikedAt
|
||||
if likedAt.IsZero() {
|
||||
likedAt = time.Now()
|
||||
}
|
||||
payload := sharedevents.NewForumPostLikedPayload(post.ID, post.AuthorUserID, actorUserID, likedAt)
|
||||
if like.EventID != "" {
|
||||
payload.EventID = like.EventID
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||
post, err := s.forumDAO.FindPublishedPost(ctx, postID)
|
||||
if err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||
if err != nil {
|
||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||
}
|
||||
return countersFromPost(*post), viewerState(postID, liked, imported), nil
|
||||
}
|
||||
|
||||
func forumLikeEventID(postID uint64, userID uint64) string {
|
||||
return sharedevents.ForumRewardEventID(sharedevents.ForumPostLikedEventType, postID, userID)
|
||||
}
|
||||
339
backend/services/taskclassforum/sv/post.go
Normal file
339
backend/services/taskclassforum/sv/post.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListPosts 查询计划广场帖子列表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责分页、排序、关键词和标签筛选的业务口径;
|
||||
// 2. 负责补齐模板摘要、当前用户点赞/导入状态;
|
||||
// 3. 不读取原作者当前 TaskClass,列表只基于论坛快照表。
|
||||
func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sortBy string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
|
||||
posts, total, err := s.forumDAO.ListPosts(ctx, forumdao.ListPostsQuery{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Sort: sortBy,
|
||||
Keyword: keyword,
|
||||
Tag: tag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return []forumcontracts.ForumPostBrief{}, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
postIDs := collectPostIDs(posts)
|
||||
templates, err := s.forumDAO.FindTemplatesByPostIDs(ctx, postIDs)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
itemCounts, err := s.forumDAO.CountTemplateItemsByPostIDs(ctx, postIDs)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
liked, imported, err := s.viewerStateSets(ctx, actorUserID, postIDs)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, err
|
||||
}
|
||||
|
||||
result := make([]forumcontracts.ForumPostBrief, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
template, ok := templates[post.ID]
|
||||
var templatePtr *forummodel.ForumPostTemplate
|
||||
if ok {
|
||||
templateCopy := template
|
||||
templatePtr = &templateCopy
|
||||
}
|
||||
result = append(result, postBriefFromModel(post, templatePtr, itemCounts[post.ID], viewerState(post.ID, liked, imported)))
|
||||
}
|
||||
return result, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
// ListTags 聚合计划广场已发布帖子的标签。
|
||||
func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) {
|
||||
_ = actorUserID
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counter := make(map[string]int)
|
||||
for _, raw := range rawTags {
|
||||
for _, tag := range tagsFromJSON(raw) {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
continue
|
||||
}
|
||||
counter[tag]++
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]forumcontracts.ForumTagItem, 0, len(counter))
|
||||
for tag, count := range counter {
|
||||
items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count})
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].PostCount == items[j].PostCount {
|
||||
return items[i].Tag < items[j].Tag
|
||||
}
|
||||
return items[i].PostCount > items[j].PostCount
|
||||
})
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CreatePost 发布计划,并把旧 TaskClass 复制为论坛快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 通过 TaskClassSnapshotPort 获取当前用户自己的 TaskClass 快照;
|
||||
// 2. 在论坛私有表写帖子、模板和模板条目;
|
||||
// 3. 不修改旧 TaskClass,也不写 schedule。
|
||||
func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.TaskClassID == 0 || strings.TrimSpace(req.Title) == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if err := validateRuneMax(req.Title, maxPostTitleLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRuneMax(req.Summary, maxSummaryLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.taskClassPort == nil {
|
||||
return nil, ErrTaskClassPortMissing
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.forumDAO.FindPostByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return s.postBriefByID(ctx, req.ActorUserID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := normalizeTags(req.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagsJSON, err := tagsToJSON(tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := s.taskClassPort.GetOwnedTaskClassSnapshot(ctx, req.ActorUserID, req.TaskClassID)
|
||||
if err != nil {
|
||||
return nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil, respond.UserTaskClassNotFound
|
||||
}
|
||||
|
||||
post, template, items, err := buildPostSnapshotModels(req, idempotencyKey, tagsJSON, *snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.forumDAO.CreatePostSnapshot(ctx, &post, &template, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.postBriefByID(ctx, req.ActorUserID, post.ID)
|
||||
}
|
||||
|
||||
// GetPost 查询帖子详情和模板快照。
|
||||
func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if postID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
post, template, items, err := s.loadPostTemplate(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := viewerState(postID, liked, imported)
|
||||
return &forumcontracts.ForumPostDetail{
|
||||
Post: postBriefFromModel(*post, template, len(items), state),
|
||||
Template: templateDetailFromModel(*template, items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) postBriefByID(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostBrief, error) {
|
||||
post, template, items, err := s.loadPostTemplate(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
brief := postBriefFromModel(*post, template, len(items), viewerState(postID, liked, imported))
|
||||
return &brief, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadPostTemplate(ctx context.Context, postID uint64) (*forummodel.ForumPost, *forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) {
|
||||
post, err := s.forumDAO.FindPublishedPost(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
template, err := s.forumDAO.FindTemplateByPostID(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||
}
|
||||
items, err := s.forumDAO.ListTemplateItemsByPostID(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return post, template, items, nil
|
||||
}
|
||||
|
||||
func (s *Service) viewerStateSets(ctx context.Context, actorUserID uint64, postIDs []uint64) (map[uint64]bool, map[uint64]bool, error) {
|
||||
if actorUserID == 0 || len(postIDs) == 0 {
|
||||
return map[uint64]bool{}, map[uint64]bool{}, nil
|
||||
}
|
||||
liked, err := s.forumDAO.LikedPostIDSet(ctx, actorUserID, postIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
imported, err := s.forumDAO.ImportedPostIDSet(ctx, actorUserID, postIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return liked, imported, nil
|
||||
}
|
||||
|
||||
func collectPostIDs(posts []forummodel.ForumPost) []uint64 {
|
||||
result := make([]uint64, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
result = append(result, post.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildPostSnapshotModels(req forumcontracts.CreateForumPostRequest, idempotencyKey string, tagsJSON string, snapshot TaskClassSnapshot) (forummodel.ForumPost, forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) {
|
||||
configJSON, err := configSnapshotJSON(snapshot)
|
||||
if err != nil {
|
||||
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||
}
|
||||
excludedSlotsJSON, err := intSliceToJSONPtr(snapshot.ExcludedSlots)
|
||||
if err != nil {
|
||||
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||
}
|
||||
excludedDaysJSON, err := intSliceToJSONPtr(snapshot.ExcludedDaysOfWeek)
|
||||
if err != nil {
|
||||
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||
}
|
||||
labelsJSON, err := stringSliceToJSONPtr(snapshot.StrategyLabels)
|
||||
if err != nil {
|
||||
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||
}
|
||||
|
||||
post := forummodel.ForumPost{
|
||||
AuthorUserID: req.ActorUserID,
|
||||
SourceTaskClassID: req.TaskClassID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Summary: strings.TrimSpace(req.Summary),
|
||||
TagsJSON: tagsJSON,
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
Status: forummodel.ForumPostStatusPublished,
|
||||
}
|
||||
template := forummodel.ForumPostTemplate{
|
||||
SourceTaskClassID: snapshot.TaskClassID,
|
||||
Mode: snapshot.Mode,
|
||||
StartDate: parseSnapshotDate(snapshot.StartDate),
|
||||
EndDate: parseSnapshotDate(snapshot.EndDate),
|
||||
SubjectType: snapshot.SubjectType,
|
||||
DifficultyLevel: snapshot.DifficultyLevel,
|
||||
CognitiveIntensity: snapshot.CognitiveIntensity,
|
||||
TotalSlots: snapshot.TotalSlots,
|
||||
AllowFillerCourse: snapshot.AllowFillerCourse,
|
||||
Strategy: snapshot.Strategy,
|
||||
ExcludedSlotsJSON: excludedSlotsJSON,
|
||||
ExcludedDaysOfWeekJSON: excludedDaysJSON,
|
||||
StrategyLabelsJSON: labelsJSON,
|
||||
ConfigSnapshotJSON: &configJSON,
|
||||
}
|
||||
snapshotItems := append([]TaskClassSnapshotItem(nil), snapshot.Items...)
|
||||
sort.SliceStable(snapshotItems, func(i, j int) bool {
|
||||
if snapshotItems[i].Order != snapshotItems[j].Order {
|
||||
return snapshotItems[i].Order < snapshotItems[j].Order
|
||||
}
|
||||
return snapshotItems[i].TaskItemID < snapshotItems[j].TaskItemID
|
||||
})
|
||||
items := make([]forummodel.ForumPostTemplateItem, 0, len(snapshotItems))
|
||||
for _, item := range snapshotItems {
|
||||
if strings.TrimSpace(item.Content) == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, forummodel.ForumPostTemplateItem{
|
||||
SourceTaskItemID: item.TaskItemID,
|
||||
Order: len(items) + 1,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
return post, template, items, nil
|
||||
}
|
||||
|
||||
func configSnapshotJSON(snapshot TaskClassSnapshot) (string, error) {
|
||||
if strings.TrimSpace(snapshot.ConfigSnapshotJSON) != "" {
|
||||
return snapshot.ConfigSnapshotJSON, nil
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"mode": snapshot.Mode,
|
||||
"start_date": snapshot.StartDate,
|
||||
"end_date": snapshot.EndDate,
|
||||
"subject_type": snapshot.SubjectType,
|
||||
"difficulty_level": snapshot.DifficultyLevel,
|
||||
"cognitive_intensity": snapshot.CognitiveIntensity,
|
||||
"total_slots": snapshot.TotalSlots,
|
||||
"allow_filler_course": snapshot.AllowFillerCourse,
|
||||
"strategy": snapshot.Strategy,
|
||||
"excluded_slots": snapshot.ExcludedSlots,
|
||||
"excluded_days_of_week": snapshot.ExcludedDaysOfWeek,
|
||||
"strategy_labels": snapshot.StrategyLabels,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func normalizeRecordNotFound(err error, fallback error) error {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fallback
|
||||
}
|
||||
return err
|
||||
}
|
||||
214
backend/services/taskclassforum/sv/service.go
Normal file
214
backend/services/taskclassforum/sv/service.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const forumRewardPublishTimeout = 800 * time.Millisecond
|
||||
|
||||
type transactionalEventPublisher interface {
|
||||
PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error
|
||||
}
|
||||
|
||||
// CommentTreeCachePort 是计划广场评论树缓存端口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只暴露“读分页树、写分页树、递增版本”三个能力,避免 service 依赖 Redis 细节;
|
||||
// 2. 缓存内容必须是去个性化读模型,不能带入当前用户的 can_delete;
|
||||
// 3. Redis 异常不应影响主链路,service 层会降级回源 DB。
|
||||
type CommentTreeCachePort interface {
|
||||
GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error)
|
||||
SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error
|
||||
BumpCommentTreeVersion(ctx context.Context, postID uint64) error
|
||||
}
|
||||
|
||||
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. P0 先由 legacy adapter 适配旧 TaskClass DAO / Service;
|
||||
// 2. 业务层只依赖快照语义,不关心底层来自旧表、旧服务还是后续 RPC;
|
||||
// 3. 不负责写 schedule,一键导入只创建当前用户自己的 TaskClass 副本。
|
||||
type TaskClassSnapshotPort interface {
|
||||
GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*TaskClassSnapshot, error)
|
||||
CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot TaskClassSnapshot, targetTitle string) (*CreatedTaskClass, error)
|
||||
}
|
||||
|
||||
// TaskClassSnapshot 是可分享的 TaskClass 白名单快照。
|
||||
//
|
||||
// 注意:这里刻意不包含 embedded_time、schedule 绑定和用户私有排程状态。
|
||||
type TaskClassSnapshot struct {
|
||||
TaskClassID uint64
|
||||
Title string
|
||||
Mode string
|
||||
StartDate string
|
||||
EndDate string
|
||||
SubjectType string
|
||||
DifficultyLevel string
|
||||
CognitiveIntensity string
|
||||
TotalSlots int
|
||||
AllowFillerCourse bool
|
||||
Strategy string
|
||||
ExcludedSlots []int
|
||||
ExcludedDaysOfWeek []int
|
||||
StrategyLabels []string
|
||||
Items []TaskClassSnapshotItem
|
||||
ConfigSnapshotJSON string
|
||||
}
|
||||
|
||||
// TaskClassSnapshotItem 是 TaskClassItem 的可分享条目快照。
|
||||
type TaskClassSnapshotItem struct {
|
||||
TaskItemID uint64
|
||||
Order int
|
||||
Content string
|
||||
}
|
||||
|
||||
// CreatedTaskClass 是导入后创建出的当前用户 TaskClass。
|
||||
type CreatedTaskClass struct {
|
||||
TaskClassID uint64
|
||||
Title string
|
||||
}
|
||||
|
||||
// Options 是计划广场服务的依赖注入参数。
|
||||
type Options struct {
|
||||
DB *gorm.DB
|
||||
TaskClassPort TaskClassSnapshotPort
|
||||
EventPublisher outboxinfra.EventPublisher
|
||||
CommentTreeCache CommentTreeCachePort
|
||||
}
|
||||
|
||||
// Service 承载计划广场服务内部业务编排。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责帖子、模板快照、点赞、评论、导入记录的事务编排;
|
||||
// 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response;
|
||||
// 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
forumDAO *forumdao.ForumDAO
|
||||
taskClassPort TaskClassSnapshotPort
|
||||
eventPublisher outboxinfra.EventPublisher
|
||||
commentTreeCache CommentTreeCachePort
|
||||
}
|
||||
|
||||
func New(opts Options) *Service {
|
||||
return &Service{
|
||||
db: opts.DB,
|
||||
forumDAO: forumdao.NewForumDAO(opts.DB),
|
||||
taskClassPort: opts.TaskClassPort,
|
||||
eventPublisher: opts.EventPublisher,
|
||||
commentTreeCache: opts.CommentTreeCache,
|
||||
}
|
||||
}
|
||||
|
||||
// Ready 用于第二步骨架阶段的依赖检查。
|
||||
//
|
||||
// 后续实现真实用例时,具体方法会做更细的参数校验;这里只先帮助 cmd / 测试快速发现依赖未注入。
|
||||
func (s *Service) Ready() error {
|
||||
if s == nil {
|
||||
return errors.New("taskclassforum service is nil")
|
||||
}
|
||||
if s.db == nil {
|
||||
return errors.New("taskclassforum db is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishForumRewardEventBestEffort 在主事务成功后补发论坛奖励 outbox 事件。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只处理“事务已经成功提交后的补发”,不再回头影响点赞/导入接口的成功结果;
|
||||
// 2. 改用独立短超时 context,避免客户端断开直接打断补发,也避免 outbox 写入长时间拖慢接口尾部;
|
||||
// 3. 发布失败时只记日志不返回 error,这是 P0 的明确取舍:先保住主链路,再靠日志和稳定 event_id 排障/补偿。
|
||||
func (s *Service) publishForumRewardEventBestEffort(payload sharedevents.ForumPostRewardPayload) {
|
||||
if s == nil || s.eventPublisher == nil {
|
||||
return
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
log.Printf(
|
||||
"forum reward outbox payload 非法,跳过发布: event_id=%s post_id=%d import_id=%d source=%s err=%v",
|
||||
payload.EventID,
|
||||
payload.PostID,
|
||||
payload.ImportID,
|
||||
payload.Source,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
eventType := strings.TrimSpace(payload.EventType())
|
||||
if eventType == "" {
|
||||
log.Printf(
|
||||
"forum reward outbox 事件类型为空,跳过发布: event_id=%s post_id=%d import_id=%d source=%s",
|
||||
payload.EventID,
|
||||
payload.PostID,
|
||||
payload.ImportID,
|
||||
payload.Source,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), forumRewardPublishTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.eventPublisher.Publish(publishCtx, outboxinfra.PublishRequest{
|
||||
EventType: eventType,
|
||||
EventVersion: sharedevents.ForumRewardEventVersion,
|
||||
MessageKey: payload.MessageKey(),
|
||||
AggregateID: payload.AggregateID(),
|
||||
EventID: payload.EventID,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
log.Printf(
|
||||
"forum reward outbox 发布失败,按 P0 约定忽略主链路错误: event_type=%s event_id=%s post_id=%d import_id=%d actor_user_id=%d err=%v",
|
||||
eventType,
|
||||
payload.EventID,
|
||||
payload.PostID,
|
||||
payload.ImportID,
|
||||
payload.ActorUserID,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// publishForumRewardEventInTx 尝试把论坛奖励事件写进当前业务事务。
|
||||
//
|
||||
// 返回值说明:
|
||||
// 1. handled=true 表示发布器支持事务写入,调用方不需要再做事务后 best-effort 补发;
|
||||
// 2. handled=false 表示当前发布器不支持事务写入,调用方可退回旧的事务后补发路径;
|
||||
// 3. error 非空表示 outbox 入队失败,业务事务应一起回滚,避免成功互动永久漏奖。
|
||||
func (s *Service) publishForumRewardEventInTx(ctx context.Context, tx *gorm.DB, payload sharedevents.ForumPostRewardPayload) (bool, error) {
|
||||
if s == nil || s.eventPublisher == nil {
|
||||
return false, nil
|
||||
}
|
||||
publisher, ok := s.eventPublisher.(transactionalEventPublisher)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
eventType := strings.TrimSpace(payload.EventType())
|
||||
if eventType == "" {
|
||||
return true, errors.New("论坛奖励事件类型为空")
|
||||
}
|
||||
|
||||
return true, publisher.PublishWithTx(ctx, tx, outboxinfra.PublishRequest{
|
||||
EventType: eventType,
|
||||
EventVersion: sharedevents.ForumRewardEventVersion,
|
||||
MessageKey: payload.MessageKey(),
|
||||
AggregateID: payload.AggregateID(),
|
||||
EventID: payload.EventID,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user