feat: 接入计划广场后端主链路

This commit is contained in:
Losita
2026-05-04 20:38:49 +08:00
parent 786c8925a0
commit 46874f0806
22 changed files with 3439 additions and 104 deletions

View File

@@ -0,0 +1,202 @@
package sv
import (
"context"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/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. 不做评论缓存,新增、回复、删除后直接读库保持语义简单。
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)
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
}
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
}
if len(roots) == 0 {
return []forumcontracts.ForumCommentNode{}, pageResult(page, pageSize, total), nil
}
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
if err != nil {
return nil, forumcontracts.PageResult{}, err
}
nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID)
return nodes, pageResult(page, pageSize, total), 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
}
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
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
}
deletedAt = formatTimePtr(&now)
return nil
}); err != nil {
return nil, err
}
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)
}
}

View File

@@ -0,0 +1,8 @@
package sv
import "errors"
var (
// ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass但 adapter 尚未注入。
ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil")
)

View File

@@ -0,0 +1,294 @@
package sv
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/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
}

View File

@@ -0,0 +1,236 @@
package sv
import (
"context"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/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"
)
// ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。
//
// 职责边界:
// 1. 同一用户同一帖子只允许导入一次,由 forum_imports 唯一约束兜底;
// 2. 只通过 TaskClassSnapshotPort 创建 TaskClass不写 schedule
// 3. 只写 forum_imports 和 import_countToken 奖励后续基于 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 importResultFromModel(*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 importResultFromModel(*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 {
result := importResultFromModel(*pending)
result.ImportCount = post.ImportCount
return result, 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
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
}
if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, time.Now()); err != nil {
return err
}
imported = *again
imported.NewTaskClassID = &created.TaskClassID
imported.TargetTitle = created.Title
imported.Status = forummodel.ForumImportStatusImported
if again.Status != forummodel.ForumImportStatusImported {
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
}
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
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
}
if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, time.Now()); err != nil {
return err
}
imported = *again
imported.Status = forummodel.ForumImportStatusImported
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
}); err != nil {
return nil, err
}
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),
}
}
func forumImportEventID(postID uint64, userID uint64) string {
return fmt.Sprintf("forum.post.imported:%d:%d", postID, userID)
}

View File

@@ -0,0 +1,111 @@
package sv
import (
"context"
"fmt"
"time"
"github.com/LoveLosita/smartflow/backend/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"
)
// LikePost 点赞计划帖子。
//
// 职责边界:
// 1. 负责保证同一用户同一帖子只有一个 active 点赞状态;
// 2. 负责维护帖子 like_count 计数字段;
// 3. 不直接发放 Token只写稳定 event_id后续奖励链路可基于该 ID 幂等消费。
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
}
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 {
return createActiveLike(ctx, txDAO, post, actorUserID)
}
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
}
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) 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 err
}
return txDAO.AddPostCounter(ctx, post.ID, "like_count", 1)
}
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 fmt.Sprintf("forum.post.liked:%d:%d", postID, userID)
}

View File

@@ -0,0 +1,339 @@
package sv
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/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
}

View File

@@ -4,13 +4,10 @@ import (
"context"
"errors"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
"gorm.io/gorm"
)
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
var ErrNotImplemented = errors.New("taskclassforum service method not implemented")
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。
//
// 职责边界:
@@ -71,12 +68,14 @@ type Options struct {
// 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
type Service struct {
db *gorm.DB
forumDAO *forumdao.ForumDAO
taskClassPort TaskClassSnapshotPort
}
func New(opts Options) *Service {
return &Service{
db: opts.DB,
forumDAO: forumdao.NewForumDAO(opts.DB),
taskClassPort: opts.TaskClassPort,
}
}
@@ -93,87 +92,3 @@ func (s *Service) Ready() error {
}
return nil
}
// ListPosts 是计划列表用例占位,第三步实现真实查询。
func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) {
_ = ctx
_ = actorUserID
_ = page
_ = pageSize
_ = sort
_ = keyword
_ = tag
return nil, forumcontracts.PageResult{}, ErrNotImplemented
}
// ListTags 是标签列表用例占位,第三步实现真实聚合查询。
func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) {
_ = ctx
_ = actorUserID
_ = limit
return nil, ErrNotImplemented
}
// CreatePost 是发布计划用例占位,第三步会通过 TaskClassSnapshotPort 读取旧计划快照。
func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// GetPost 是计划详情用例占位,第三步实现帖子和模板快照读取。
func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) {
_ = ctx
_ = actorUserID
_ = postID
return nil, ErrNotImplemented
}
// LikePost 是点赞用例占位,第三步实现唯一约束和计数更新。
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
_ = ctx
_ = actorUserID
_ = postID
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
}
// UnlikePost 是取消点赞用例占位,第三步实现幂等撤销。
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
_ = ctx
_ = actorUserID
_ = postID
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
}
// ListComments 是评论树查询用例占位,第三步实现根评论分页和服务层组树。
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
_ = ctx
_ = actorUserID
_ = postID
_ = page
_ = pageSize
_ = sort
return nil, forumcontracts.PageResult{}, ErrNotImplemented
}
// CreateComment 是发表评论或回复用例占位,第三步实现父子评论校验和幂等。
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// DeleteComment 是删除自己评论用例占位,第三步实现软删除和权限判断。
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
_ = ctx
_ = actorUserID
_ = commentID
return nil, ErrNotImplemented
}
// ImportPost 是一键导入用例占位,第三步会保证同一用户同一帖子只导入一次。
func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}