Version: 0.9.78.dev.260506
This commit is contained in:
329
backend/services/taskclassforum/adapter/taskclass_rpc.go
Normal file
329
backend/services/taskclassforum/adapter/taskclass_rpc.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
|
||||
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
var errTaskClassRPCAdapterNotReady = errors.New("taskclassforum task-class rpc adapter is not initialized")
|
||||
|
||||
// TaskClassRPCAdapter 负责把 task-class 独立服务适配成计划广场需要的快照端口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只通过 task-class zrpc 读取/创建任务类,不直连 task_classes / task_items 物理表;
|
||||
// 2. 只暴露论坛导入/发布需要的白名单快照语义,不透传 schedule 写入能力;
|
||||
// 3. 论坛业务层只依赖快照端口,后续 task-class 契约继续演进时只改这一层。
|
||||
type TaskClassRPCAdapter struct {
|
||||
client *taskclassclient.Client
|
||||
}
|
||||
|
||||
var _ forumsv.TaskClassSnapshotPort = (*TaskClassRPCAdapter)(nil)
|
||||
|
||||
// NewTaskClassRPCAdapter 创建基于 task-class zrpc 的论坛快照适配器。
|
||||
func NewTaskClassRPCAdapter(client *taskclassclient.Client) *TaskClassRPCAdapter {
|
||||
return &TaskClassRPCAdapter{client: client}
|
||||
}
|
||||
|
||||
// GetOwnedTaskClassSnapshot 读取当前用户自己的 TaskClass,并投影为论坛可分享快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读取当前用户可见的单个 TaskClass;
|
||||
// 2. 只返回论坛白名单字段和条目 source id/order/content;
|
||||
// 3. 不透传 embedded_time、status 和任何 schedule 绑定细节。
|
||||
func (a *TaskClassRPCAdapter) GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*forumsv.TaskClassSnapshot, error) {
|
||||
if err := a.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userIDInt, err := toUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskClassIDInt, err := toTaskClassID(taskClassID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := a.client.GetAgentTaskClasses(ctx, taskclasscontracts.AgentTaskClassesRequest{
|
||||
UserID: userIDInt,
|
||||
TaskClassIDs: []int{taskClassIDInt},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp taskclasscontracts.AgentTaskClassesResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.TaskClasses) == 0 {
|
||||
return nil, respond.UserTaskClassNotFound
|
||||
}
|
||||
|
||||
snapshot, err := snapshotFromTaskClass(resp.TaskClasses[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
// CreateTaskClassFromSnapshot 基于论坛模板快照为当前用户创建 task-class 服务里的副本。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只创建 task-class 主体与 items,不写 forum_imports;
|
||||
// 2. 所有 item 都作为新记录创建,不沿用原任务条目的 ID;
|
||||
// 3. 不写 schedule,导入后仍保持“当前用户自己的未安排副本”语义。
|
||||
func (a *TaskClassRPCAdapter) CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot forumsv.TaskClassSnapshot, targetTitle string) (*forumsv.CreatedTaskClass, error) {
|
||||
if err := a.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userIDInt, err := toUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(targetTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(snapshot.Title)
|
||||
}
|
||||
if title == "" || strings.TrimSpace(snapshot.Mode) == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
if _, _, err := parseSnapshotDateRange(snapshot.Mode, snapshot.StartDate, snapshot.EndDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := a.client.AddTaskClass(ctx, buildUpsertTaskClassRequest(userIDInt, title, snapshot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var created taskclasscontracts.UpsertTaskClassResponse
|
||||
if err := json.Unmarshal(raw, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if created.TaskClassID <= 0 {
|
||||
return nil, respond.InternalError(errors.New("task-class rpc add response missing task_class_id"))
|
||||
}
|
||||
return &forumsv.CreatedTaskClass{
|
||||
TaskClassID: uint64(created.TaskClassID),
|
||||
Title: title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func snapshotFromTaskClass(taskClass taskclasscontracts.AgentTaskClass) (forumsv.TaskClassSnapshot, error) {
|
||||
items := snapshotItemsFromTaskClassItems(taskClass.Items)
|
||||
snapshot := forumsv.TaskClassSnapshot{
|
||||
TaskClassID: uint64(taskClass.ID),
|
||||
Title: strings.TrimSpace(taskClass.Name),
|
||||
Mode: strings.TrimSpace(taskClass.Mode),
|
||||
StartDate: strings.TrimSpace(taskClass.StartDate),
|
||||
EndDate: strings.TrimSpace(taskClass.EndDate),
|
||||
SubjectType: strings.TrimSpace(taskClass.SubjectType),
|
||||
DifficultyLevel: strings.TrimSpace(taskClass.DifficultyLevel),
|
||||
CognitiveIntensity: strings.TrimSpace(taskClass.CognitiveIntensity),
|
||||
TotalSlots: taskClass.TotalSlots,
|
||||
AllowFillerCourse: taskClass.AllowFillerCourse,
|
||||
Strategy: strings.TrimSpace(taskClass.Strategy),
|
||||
ExcludedSlots: cloneIntSlice(taskClass.ExcludedSlots),
|
||||
ExcludedDaysOfWeek: cloneIntSlice(taskClass.ExcludedDaysOfWeek),
|
||||
StrategyLabels: strategyLabels(taskClass.Strategy),
|
||||
Items: items,
|
||||
}
|
||||
|
||||
configJSON, err := buildConfigSnapshotJSON(snapshot)
|
||||
if err != nil {
|
||||
return forumsv.TaskClassSnapshot{}, err
|
||||
}
|
||||
snapshot.ConfigSnapshotJSON = configJSON
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func snapshotItemsFromTaskClassItems(items []taskclasscontracts.AgentTaskClassItem) []forumsv.TaskClassSnapshotItem {
|
||||
if len(items) == 0 {
|
||||
return []forumsv.TaskClassSnapshotItem{}
|
||||
}
|
||||
|
||||
sorted := append([]taskclasscontracts.AgentTaskClassItem(nil), items...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
leftOrder := derefInt(sorted[i].Order)
|
||||
rightOrder := derefInt(sorted[j].Order)
|
||||
if leftOrder != rightOrder {
|
||||
return leftOrder < rightOrder
|
||||
}
|
||||
return sorted[i].ID < sorted[j].ID
|
||||
})
|
||||
|
||||
result := make([]forumsv.TaskClassSnapshotItem, 0, len(sorted))
|
||||
for _, item := range sorted {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, forumsv.TaskClassSnapshotItem{
|
||||
TaskItemID: uint64(item.ID),
|
||||
Order: derefInt(item.Order),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildUpsertTaskClassRequest(userID int, title string, snapshot forumsv.TaskClassSnapshot) taskclasscontracts.UpsertTaskClassRequest {
|
||||
items := make([]taskclasscontracts.UpsertTaskClassItemConfig, 0, len(snapshot.Items))
|
||||
sortedItems := append([]forumsv.TaskClassSnapshotItem(nil), snapshot.Items...)
|
||||
sort.SliceStable(sortedItems, func(i, j int) bool {
|
||||
if sortedItems[i].Order != sortedItems[j].Order {
|
||||
return sortedItems[i].Order < sortedItems[j].Order
|
||||
}
|
||||
return sortedItems[i].TaskItemID < sortedItems[j].TaskItemID
|
||||
})
|
||||
for _, item := range sortedItems {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, taskclasscontracts.UpsertTaskClassItemConfig{
|
||||
Order: item.Order,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return taskclasscontracts.UpsertTaskClassRequest{
|
||||
UserID: userID,
|
||||
Name: title,
|
||||
StartDate: strings.TrimSpace(snapshot.StartDate),
|
||||
EndDate: strings.TrimSpace(snapshot.EndDate),
|
||||
Mode: strings.TrimSpace(snapshot.Mode),
|
||||
SubjectType: strings.TrimSpace(snapshot.SubjectType),
|
||||
DifficultyLevel: strings.TrimSpace(snapshot.DifficultyLevel),
|
||||
CognitiveIntensity: strings.TrimSpace(snapshot.CognitiveIntensity),
|
||||
Config: taskclasscontracts.UpsertTaskClassConfig{
|
||||
TotalSlots: snapshot.TotalSlots,
|
||||
AllowFillerCourse: snapshot.AllowFillerCourse,
|
||||
Strategy: strings.TrimSpace(snapshot.Strategy),
|
||||
ExcludedSlots: cloneIntSlice(snapshot.ExcludedSlots),
|
||||
ExcludedDaysOfWeek: cloneIntSlice(snapshot.ExcludedDaysOfWeek),
|
||||
},
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func strategyLabels(strategy string) []string {
|
||||
trimmed := strings.TrimSpace(strategy)
|
||||
if trimmed == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
|
||||
func (a *TaskClassRPCAdapter) ensureReady() error {
|
||||
if a == nil || a.client == nil {
|
||||
return errTaskClassRPCAdapterNotReady
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toUserID(value uint64) (int, error) {
|
||||
if value == 0 || value > uint64(maxIntValue()) {
|
||||
return 0, respond.WrongUserID
|
||||
}
|
||||
return int(value), nil
|
||||
}
|
||||
|
||||
func toTaskClassID(value uint64) (int, error) {
|
||||
if value == 0 || value > uint64(maxIntValue()) {
|
||||
return 0, respond.WrongTaskClassID
|
||||
}
|
||||
return int(value), nil
|
||||
}
|
||||
|
||||
func maxIntValue() int {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
|
||||
func derefInt(value *int) int {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func parseSnapshotDateRange(mode string, startDate string, endDate string) (*time.Time, *time.Time, error) {
|
||||
parsedStart, err := parseDatePtr(startDate)
|
||||
if err != nil {
|
||||
return nil, nil, respond.WrongParamType
|
||||
}
|
||||
parsedEnd, err := parseDatePtr(endDate)
|
||||
if err != nil {
|
||||
return nil, nil, respond.WrongParamType
|
||||
}
|
||||
|
||||
if strings.TrimSpace(mode) != "auto" {
|
||||
return parsedStart, parsedEnd, nil
|
||||
}
|
||||
if parsedStart == nil || parsedEnd == nil {
|
||||
return nil, nil, respond.MissingParamForAutoScheduling
|
||||
}
|
||||
if parsedStart.After(*parsedEnd) {
|
||||
return nil, nil, respond.InvalidDateRange
|
||||
}
|
||||
return parsedStart, parsedEnd, nil
|
||||
}
|
||||
|
||||
func buildConfigSnapshotJSON(snapshot forumsv.TaskClassSnapshot) (string, error) {
|
||||
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": cloneIntSlice(snapshot.ExcludedSlots),
|
||||
"excluded_days_of_week": cloneIntSlice(snapshot.ExcludedDaysOfWeek),
|
||||
"strategy_labels": append([]string(nil), snapshot.StrategyLabels...),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func parseDatePtr(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", trimmed, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func cloneIntSlice(values []int) []int {
|
||||
if len(values) == 0 {
|
||||
return []int{}
|
||||
}
|
||||
return append([]int(nil), values...)
|
||||
}
|
||||
204
backend/services/taskclassforum/commenttree/tree.go
Normal file
204
backend/services/taskclassforum/commenttree/tree.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package commenttree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
)
|
||||
|
||||
const deletedCommentPlaceholder = "该评论已删除"
|
||||
|
||||
type commentTreeNode struct {
|
||||
comment forummodel.ForumComment
|
||||
parent *commentTreeNode
|
||||
children []*commentTreeNode
|
||||
}
|
||||
|
||||
// BuildForumCommentTree 将扁平评论组装为多层评论树。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责根据 parent_comment_id 组装无限层树结构,并按 CreatedAt 升序稳定排序;
|
||||
// 2. 负责把软删除评论转换成前端展示文案,同时保留 deleted 状态与 deleted_at;
|
||||
// 3. 不负责查询数据库、补充真实昵称头像,也不负责帖子级权限校验。
|
||||
func BuildForumCommentTree(comments []forummodel.ForumComment, actorUserID uint64) []forumcontracts.ForumCommentNode {
|
||||
if len(comments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodesByID := make(map[uint64]*commentTreeNode, len(comments))
|
||||
orderedNodes := make([]*commentTreeNode, 0, len(comments))
|
||||
for i := range comments {
|
||||
node := &commentTreeNode{comment: comments[i]}
|
||||
nodesByID[comments[i].ID] = node
|
||||
orderedNodes = append(orderedNodes, node)
|
||||
}
|
||||
|
||||
roots := attachCommentTreeNodes(orderedNodes, nodesByID)
|
||||
sortCommentTreeChildren(roots)
|
||||
|
||||
result := make([]forumcontracts.ForumCommentNode, 0, len(roots))
|
||||
for i := range roots {
|
||||
result = append(result, buildForumCommentNode(roots[i], actorUserID))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// attachCommentTreeNodes 按原始 parent_comment_id 把评论挂成树。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理节点挂载关系,不做字段格式化;
|
||||
// 2. 缺失父节点、自指向、环引用都回退到根层,避免整棵树丢失;
|
||||
// 3. 根层顺序先保留输入顺序,后续统一由排序函数做稳定排序。
|
||||
func attachCommentTreeNodes(
|
||||
orderedNodes []*commentTreeNode,
|
||||
nodesByID map[uint64]*commentTreeNode,
|
||||
) []*commentTreeNode {
|
||||
roots := make([]*commentTreeNode, 0, len(orderedNodes))
|
||||
for i := range orderedNodes {
|
||||
node := orderedNodes[i]
|
||||
parentID := node.comment.ParentCommentID
|
||||
if parentID == nil {
|
||||
roots = append(roots, node)
|
||||
continue
|
||||
}
|
||||
|
||||
parentNode, ok := nodesByID[*parentID]
|
||||
if !ok || parentNode == nil || parentNode.comment.ID == node.comment.ID {
|
||||
roots = append(roots, node)
|
||||
continue
|
||||
}
|
||||
|
||||
if wouldCreateCommentCycle(nodesByID, node.comment.ID, parentNode) {
|
||||
roots = append(roots, node)
|
||||
continue
|
||||
}
|
||||
|
||||
node.parent = parentNode
|
||||
parentNode.children = append(parentNode.children, node)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// wouldCreateCommentCycle 判断把 child 挂到 parent 下时是否会形成环。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只依赖原始 parent_comment_id 链路判断,不依赖当前挂载顺序;
|
||||
// 2. 一旦发现 child 会回到自己,或父链本身已成环,就返回 true;
|
||||
// 3. 父链中途断开时按“无环”处理,让节点继续挂到可用分支上。
|
||||
func wouldCreateCommentCycle(
|
||||
nodesByID map[uint64]*commentTreeNode,
|
||||
childCommentID uint64,
|
||||
parentNode *commentTreeNode,
|
||||
) bool {
|
||||
visited := make(map[uint64]struct{})
|
||||
current := parentNode
|
||||
for current != nil {
|
||||
if current.comment.ID == childCommentID {
|
||||
return true
|
||||
}
|
||||
if _, seen := visited[current.comment.ID]; seen {
|
||||
return true
|
||||
}
|
||||
visited[current.comment.ID] = struct{}{}
|
||||
|
||||
if current.comment.ParentCommentID == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
nextNode, ok := nodesByID[*current.comment.ParentCommentID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current = nextNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortCommentTreeChildren 对根层以下的兄弟节点做 CreatedAt 升序稳定排序。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 根层顺序来自服务层根评论分页,必须保留 latest/oldest 的查询语义;
|
||||
// 2. 子回复统一按 CreatedAt 升序展示,符合常见对话阅读顺序;
|
||||
// 3. 相同 CreatedAt 依赖稳定排序保留原始输入顺序,避免同秒回复来回跳动。
|
||||
func sortCommentTreeChildren(nodes []*commentTreeNode) {
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range nodes {
|
||||
sort.SliceStable(nodes[i].children, func(left, right int) bool {
|
||||
return nodes[i].children[left].comment.CreatedAt.Before(nodes[i].children[right].comment.CreatedAt)
|
||||
})
|
||||
sortCommentTreeChildren(nodes[i].children)
|
||||
}
|
||||
}
|
||||
|
||||
// buildForumCommentNode 把内部树节点转换成对外契约节点。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责软删除展示文案、CanDelete、时间格式等输出字段整理;
|
||||
// 2. 根节点统一输出 nil parent_comment_id;孤儿兜底到根层后也遵循该规则;
|
||||
// 3. 这里沿用当前服务里的“用户{ID}”占位昵称语义。
|
||||
func buildForumCommentNode(node *commentTreeNode, actorUserID uint64) forumcontracts.ForumCommentNode {
|
||||
children := make([]forumcontracts.ForumCommentNode, 0, len(node.children))
|
||||
for i := range node.children {
|
||||
children = append(children, buildForumCommentNode(node.children[i], actorUserID))
|
||||
}
|
||||
|
||||
// 1. 先基于最终挂载结果回填 parent_comment_id,保证孤儿回退到根层后对外语义一致。
|
||||
// 2. 再处理软删除评论文案:内容固定替换,但 status 仍保留 deleted,便于前端区分。
|
||||
// 3. 最后按“当前用户且评论可见”计算 CanDelete,避免已删除评论被重复展示可删除按钮。
|
||||
parentCommentID := actualParentCommentID(node.parent)
|
||||
content := node.comment.Content
|
||||
if node.comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||
content = deletedCommentPlaceholder
|
||||
}
|
||||
|
||||
return forumcontracts.ForumCommentNode{
|
||||
CommentID: node.comment.ID,
|
||||
PostID: node.comment.PostID,
|
||||
ParentCommentID: parentCommentID,
|
||||
Content: content,
|
||||
Status: node.comment.Status,
|
||||
Author: buildCommentAuthor(node.comment.UserID),
|
||||
CanDelete: node.comment.Status == forummodel.ForumCommentStatusVisible && node.comment.UserID == actorUserID,
|
||||
CreatedAt: formatCommentTime(node.comment.CreatedAt),
|
||||
DeletedAt: formatCommentTimePtr(node.comment.DeletedAt),
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
func actualParentCommentID(parent *commentTreeNode) *uint64 {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
parentID := parent.comment.ID
|
||||
return &parentID
|
||||
}
|
||||
|
||||
func formatCommentTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func formatCommentTimePtr(value *time.Time) *string {
|
||||
if value == nil || value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
formatted := value.Format(time.RFC3339)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
func buildCommentAuthor(userID uint64) forumcontracts.UserBrief {
|
||||
// 由于本轮写入范围被限制在 commenttree/tree.go,暂时无法把 UserBrief 生成逻辑下沉成公共能力;
|
||||
// 这里先与现有服务层保持同一占位昵称语义,避免为了抽公共层去改动 sv/contract 等非授权文件。
|
||||
return forumcontracts.UserBrief{
|
||||
UserID: userID,
|
||||
Nickname: fmt.Sprintf("用户%d", userID),
|
||||
}
|
||||
}
|
||||
146
backend/services/taskclassforum/dao/cache.go
Normal file
146
backend/services/taskclassforum/dao/cache.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
const commentTreeCacheTTL = 2 * time.Minute
|
||||
|
||||
type commentTreeCachePayload struct {
|
||||
Items []forumcontracts.ForumCommentNode `json:"items"`
|
||||
Page forumcontracts.PageResult `json:"page"`
|
||||
}
|
||||
|
||||
// CommentTreeCache 承载计划广场评论树的 Redis 缓存能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责评论树读模型的 JSON 缓存和版本号失效,不读写 MySQL;
|
||||
// 2. 不计算当前用户是否可删除评论,避免把用户视角写进共享缓存;
|
||||
// 3. Redis 异常向上返回,由 service 层决定是否降级回源 DB。
|
||||
type CommentTreeCache struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewCommentTreeCache(client *redis.Client) *CommentTreeCache {
|
||||
return &CommentTreeCache{
|
||||
client: client,
|
||||
ttl: commentTreeCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func commentTreeVersionKey(postID uint64) string {
|
||||
return fmt.Sprintf("forum:comments:%d:version", postID)
|
||||
}
|
||||
|
||||
func commentTreeDataKey(postID uint64, version int64, sort string, page int, pageSize int) string {
|
||||
return fmt.Sprintf(
|
||||
"forum:comments:%d:v%d:sort:%s:page:%d:size:%d",
|
||||
postID,
|
||||
version,
|
||||
strings.TrimSpace(sort),
|
||||
page,
|
||||
pageSize,
|
||||
)
|
||||
}
|
||||
|
||||
// GetCommentTree 读取指定帖子、排序和分页维度下的评论树缓存。
|
||||
//
|
||||
// 返回值语义:
|
||||
// 1. hit=true 表示命中缓存,items/page 可直接用于返回前的用户视角补全;
|
||||
// 2. hit=false 且 error=nil 表示未命中,调用方应回源 DB;
|
||||
// 3. error 非空表示 Redis 或 JSON 异常,调用方应记录日志并回源 DB。
|
||||
func (c *CommentTreeCache) GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) {
|
||||
if c == nil || c.client == nil {
|
||||
return nil, forumcontracts.PageResult{}, false, errors.New("评论树缓存未初始化")
|
||||
}
|
||||
version, err := c.currentCommentTreeVersion(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, false, err
|
||||
}
|
||||
|
||||
raw, err := c.client.Get(ctx, commentTreeDataKey(postID, version, sort, page, pageSize)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, forumcontracts.PageResult{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, forumcontracts.PageResult{}, false, err
|
||||
}
|
||||
|
||||
var payload commentTreeCachePayload
|
||||
if err = json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, forumcontracts.PageResult{}, false, err
|
||||
}
|
||||
if payload.Items == nil {
|
||||
payload.Items = []forumcontracts.ForumCommentNode{}
|
||||
}
|
||||
return payload.Items, payload.Page, true, nil
|
||||
}
|
||||
|
||||
// SetCommentTree 写入指定帖子、排序和分页维度下的评论树缓存。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先读取当前版本号,保证写入 key 与后续读取 key 一致;
|
||||
// 2. 再序列化去个性化后的评论树,避免缓存里带入某个用户的 can_delete;
|
||||
// 3. 最后写入短 TTL,让版本失效失败时也能靠自然过期兜底。
|
||||
func (c *CommentTreeCache) SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error {
|
||||
if c == nil || c.client == nil {
|
||||
return errors.New("评论树缓存未初始化")
|
||||
}
|
||||
version, err := c.currentCommentTreeVersion(ctx, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []forumcontracts.ForumCommentNode{}
|
||||
}
|
||||
data, err := json.Marshal(commentTreeCachePayload{
|
||||
Items: items,
|
||||
Page: pageResult,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.client.Set(ctx, commentTreeDataKey(postID, version, sort, page, pageSize), data, c.ttl).Err()
|
||||
}
|
||||
|
||||
// BumpCommentTreeVersion 递增帖子评论树版本号,让旧分页缓存自然失效。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做版本递增,不扫描删除旧 data key,避免写评论时阻塞 Redis;
|
||||
// 2. 旧 data key 依赖短 TTL 自动回收;
|
||||
// 3. 当 version key 不存在时 INCR 会从 1 开始,能够让默认 v0 缓存失效。
|
||||
func (c *CommentTreeCache) BumpCommentTreeVersion(ctx context.Context, postID uint64) error {
|
||||
if c == nil || c.client == nil {
|
||||
return errors.New("评论树缓存未初始化")
|
||||
}
|
||||
return c.client.Incr(ctx, commentTreeVersionKey(postID)).Err()
|
||||
}
|
||||
|
||||
func (c *CommentTreeCache) currentCommentTreeVersion(ctx context.Context, postID uint64) (int64, error) {
|
||||
raw, err := c.client.Get(ctx, commentTreeVersionKey(postID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if version < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
70
backend/services/taskclassforum/dao/connect.go
Normal file
70
backend/services/taskclassforum/dao/connect.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OpenDBFromConfig 创建计划广场服务自己的数据库句柄,并迁移本服务私有表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只迁移 forum_* 表和本服务 outbox 表,不迁移 task_classes / task_items,避免抢占 task-class 拆分线;
|
||||
// 2. 不负责装配 legacy TaskClass adapter,adapter 在服务实现阶段单独注入;
|
||||
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
|
||||
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||
host := viper.GetString("database.host")
|
||||
port := viper.GetString("database.port")
|
||||
user := viper.GetString("database.user")
|
||||
password := viper.GetString("database.password")
|
||||
dbname := viper.GetString("database.dbname")
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
user, password, host, port, dbname,
|
||||
)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = AutoMigrate(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// AutoMigrate 只迁移计划广场服务拥有的表。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先创建帖子、模板、条目、点赞、评论、导入记录表;
|
||||
// 2. 再按 service catalog 创建 taskclass-forum outbox 表,为后续论坛自身异步事件预留稳定目录;
|
||||
// 3. 迁移期论坛奖励事件直接写 token-store outbox 表,发布端也兜底创建目标表,避免独立启动顺序导致奖励漏表;
|
||||
// 4. 唯一约束交给 GORM tag 生成,保证点赞和导入幂等有数据库兜底;
|
||||
// 5. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("taskclassforum auto migrate failed: db is nil")
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&forummodel.ForumPost{},
|
||||
&forummodel.ForumPostTemplate{},
|
||||
&forummodel.ForumPostTemplateItem{},
|
||||
&forummodel.ForumLike{},
|
||||
&forummodel.ForumComment{},
|
||||
&forummodel.ForumImport{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate taskclassforum tables failed: %w", err)
|
||||
}
|
||||
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTaskClassForum); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
466
backend/services/taskclassforum/dao/forum.go
Normal file
466
backend/services/taskclassforum/dao/forum.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ForumDAO 承载计划广场私有表的持久化访问。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只访问 forum_* 表,不直接读写旧 task_classes / task_items;
|
||||
// 2. 只做查询、事务和基础状态更新,不组装前端 DTO;
|
||||
// 3. 业务规则由 sv 层控制,DAO 仅提供必要的数据原子操作。
|
||||
type ForumDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewForumDAO(db *gorm.DB) *ForumDAO {
|
||||
return &ForumDAO{db: db}
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) WithTx(tx *gorm.DB) *ForumDAO {
|
||||
return &ForumDAO{db: tx}
|
||||
}
|
||||
|
||||
// GormDB 返回当前 DAO 绑定的 GORM 句柄。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只提供给需要和 forum 业务事务同提交的基础设施使用,例如 outbox 入队;
|
||||
// 2. 不鼓励业务层绕过 DAO 任意读写 forum_* 表;
|
||||
// 3. 若当前 DAO 来自 WithTx,返回值就是同一个事务句柄。
|
||||
func (dao *ForumDAO) GormDB() *gorm.DB {
|
||||
if dao == nil {
|
||||
return nil
|
||||
}
|
||||
return dao.db
|
||||
}
|
||||
|
||||
// Transaction 在一个数据库事务内执行计划广场写操作。
|
||||
func (dao *ForumDAO) Transaction(ctx context.Context, fn func(txDAO *ForumDAO) error) error {
|
||||
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(dao.WithTx(tx))
|
||||
})
|
||||
}
|
||||
|
||||
type ListPostsQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Sort string
|
||||
Keyword string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// CreatePostSnapshot 在同一事务中写帖子、模板和模板条目。
|
||||
func (dao *ForumDAO) CreatePostSnapshot(ctx context.Context, post *forummodel.ForumPost, template *forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) error {
|
||||
return dao.Transaction(ctx, func(txDAO *ForumDAO) error {
|
||||
if err := txDAO.db.Create(post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
template.PostID = post.ID
|
||||
if err := txDAO.db.Create(template).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
items[i].PostID = post.ID
|
||||
items[i].TemplateID = template.ID
|
||||
}
|
||||
if len(items) > 0 {
|
||||
if err := txDAO.db.Create(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindPostByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumPost, error) {
|
||||
var post forummodel.ForumPost
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("author_user_id = ? AND idempotency_key = ?", userID, key).
|
||||
First(&post).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ListPosts(ctx context.Context, query ListPostsQuery) ([]forummodel.ForumPost, int64, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumPost{}).
|
||||
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished)
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("title LIKE ? OR summary LIKE ?", like, like)
|
||||
}
|
||||
if tag := strings.TrimSpace(query.Tag); tag != "" {
|
||||
db = db.Where("JSON_CONTAINS(tags_json, JSON_QUOTE(?))", tag)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
orderBy := "created_at DESC"
|
||||
switch strings.TrimSpace(query.Sort) {
|
||||
case "likes":
|
||||
orderBy = "like_count DESC, created_at DESC"
|
||||
case "imports":
|
||||
orderBy = "import_count DESC, created_at DESC"
|
||||
}
|
||||
|
||||
var posts []forummodel.ForumPost
|
||||
err := db.Order(orderBy).
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Find(&posts).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ListPublishedTagJSONs(ctx context.Context) ([]string, error) {
|
||||
var rows []struct {
|
||||
TagsJSON string
|
||||
}
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumPost{}).
|
||||
Select("tags_json").
|
||||
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, row.TagsJSON)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
|
||||
var post forummodel.ForumPost
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
|
||||
First(&post).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) LockPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
|
||||
var post forummodel.ForumPost
|
||||
err := dao.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
|
||||
First(&post).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindTemplateByPostID(ctx context.Context, postID uint64) (*forummodel.ForumPostTemplate, error) {
|
||||
var template forummodel.ForumPostTemplate
|
||||
err := dao.db.WithContext(ctx).Where("post_id = ?", postID).First(&template).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ListTemplateItemsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumPostTemplateItem, error) {
|
||||
var items []forummodel.ForumPostTemplateItem
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("post_id = ?", postID).
|
||||
Order("item_order ASC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindTemplatesByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]forummodel.ForumPostTemplate, error) {
|
||||
var templates []forummodel.ForumPostTemplate
|
||||
err := dao.db.WithContext(ctx).Where("post_id IN ?", postIDs).Find(&templates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]forummodel.ForumPostTemplate, len(templates))
|
||||
for _, template := range templates {
|
||||
result[template.PostID] = template
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CountTemplateItemsByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]int, error) {
|
||||
var rows []struct {
|
||||
PostID uint64
|
||||
Count int
|
||||
}
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumPostTemplateItem{}).
|
||||
Select("post_id, COUNT(*) AS count").
|
||||
Where("post_id IN ?", postIDs).
|
||||
Group("post_id").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]int, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.PostID] = row.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) LikedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
|
||||
var likes []forummodel.ForumLike
|
||||
err := dao.db.WithContext(ctx).
|
||||
Select("post_id").
|
||||
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumLikeStatusActive).
|
||||
Find(&likes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]bool, len(likes))
|
||||
for _, like := range likes {
|
||||
result[like.PostID] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ImportedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
|
||||
var imports []forummodel.ForumImport
|
||||
err := dao.db.WithContext(ctx).
|
||||
Select("post_id").
|
||||
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumImportStatusImported).
|
||||
Find(&imports).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint64]bool, len(imports))
|
||||
for _, item := range imports {
|
||||
result[item.PostID] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindLike(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumLike, error) {
|
||||
var like forummodel.ForumLike
|
||||
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &like, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CreateLike(ctx context.Context, like *forummodel.ForumLike) error {
|
||||
return dao.db.WithContext(ctx).Create(like).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ActivateLike(ctx context.Context, likeID uint64) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumLike{}).
|
||||
Where("id = ?", likeID).
|
||||
Updates(map[string]any{
|
||||
"status": forummodel.ForumLikeStatusActive,
|
||||
"canceled_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CancelLike(ctx context.Context, likeID uint64, now time.Time) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumLike{}).
|
||||
Where("id = ?", likeID).
|
||||
Updates(map[string]any{
|
||||
"status": forummodel.ForumLikeStatusCanceled,
|
||||
"canceled_at": &now,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) AddPostCounter(ctx context.Context, postID uint64, column string, delta int64) error {
|
||||
expr := "CASE WHEN " + column + " + ? < 0 THEN 0 ELSE " + column + " + ? END"
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumPost{}).
|
||||
Where("id = ?", postID).
|
||||
UpdateColumn(column, gorm.Expr(expr, delta, delta)).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
|
||||
var comment forummodel.ForumComment
|
||||
err := dao.db.WithContext(ctx).Where("id = ?", commentID).First(&comment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) LockCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
|
||||
var comment forummodel.ForumComment
|
||||
err := dao.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", commentID).
|
||||
First(&comment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindCommentByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumComment, error) {
|
||||
var comment forummodel.ForumComment
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||
First(&comment).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CreateComment(ctx context.Context, comment *forummodel.ForumComment) error {
|
||||
return dao.db.WithContext(ctx).Create(comment).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CountRootComments(ctx context.Context, postID uint64) (int64, error) {
|
||||
var total int64
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumComment{}).
|
||||
Where("post_id = ? AND parent_comment_id IS NULL", postID).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ListRootComments(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forummodel.ForumComment, error) {
|
||||
orderBy := "created_at ASC"
|
||||
if strings.TrimSpace(sort) == "latest" {
|
||||
orderBy = "created_at DESC"
|
||||
}
|
||||
var comments []forummodel.ForumComment
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("post_id = ? AND parent_comment_id IS NULL", postID).
|
||||
Order(orderBy).
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) ListCommentsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumComment, error) {
|
||||
var comments []forummodel.ForumComment
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("post_id = ?", postID).
|
||||
Order("created_at ASC").
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) SoftDeleteComment(ctx context.Context, commentID uint64, now time.Time) error {
|
||||
tx := dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumComment{}).
|
||||
Where("id = ? AND status = ?", commentID, forummodel.ForumCommentStatusVisible).
|
||||
Updates(map[string]any{
|
||||
"status": forummodel.ForumCommentStatusDeleted,
|
||||
"deleted_at": &now,
|
||||
"updated_at": now,
|
||||
})
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindImport(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumImport, error) {
|
||||
var item forummodel.ForumImport
|
||||
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FindImportByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumImport, error) {
|
||||
var item forummodel.ForumImport
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) CreateImport(ctx context.Context, item *forummodel.ForumImport) error {
|
||||
return dao.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) UpdateImportProcessing(ctx context.Context, importID uint64, title string, now time.Time) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumImport{}).
|
||||
Where("id = ?", importID).
|
||||
Updates(map[string]any{
|
||||
"target_title": title,
|
||||
"status": forummodel.ForumImportStatusPending,
|
||||
"last_error": nil,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) FinalizeImport(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, now time.Time) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumImport{}).
|
||||
Where("id = ?", importID).
|
||||
Updates(map[string]any{
|
||||
"new_task_class_id": &newTaskClassID,
|
||||
"target_title": targetTitle,
|
||||
"status": forummodel.ForumImportStatusImported,
|
||||
"last_error": nil,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) MarkImportFailed(ctx context.Context, importID uint64, message string, now time.Time) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumImport{}).
|
||||
Where("id = ?", importID).
|
||||
Updates(map[string]any{
|
||||
"status": forummodel.ForumImportStatusFailed,
|
||||
"last_error": &message,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (dao *ForumDAO) MarkImportFailedAfterTaskClassCreated(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, message string, now time.Time) error {
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&forummodel.ForumImport{}).
|
||||
Where("id = ?", importID).
|
||||
Updates(map[string]any{
|
||||
"new_task_class_id": &newTaskClassID,
|
||||
"target_title": targetTitle,
|
||||
"status": forummodel.ForumImportStatusFailed,
|
||||
"last_error": &message,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
186
backend/services/taskclassforum/model/forum.go
Normal file
186
backend/services/taskclassforum/model/forum.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// ForumPostStatusPublished 表示帖子已公开展示在计划广场。
|
||||
ForumPostStatusPublished = "published"
|
||||
// ForumPostStatusHidden 表示帖子被作者隐藏或后续治理流程下架。
|
||||
ForumPostStatusHidden = "hidden"
|
||||
// ForumPostStatusDeleted 表示帖子已软删除,P0 暂不对前端展示。
|
||||
ForumPostStatusDeleted = "deleted"
|
||||
// ForumPostStatusPendingReview 预留审核态,P0 不启用审核后台。
|
||||
ForumPostStatusPendingReview = "pending_review"
|
||||
)
|
||||
|
||||
const (
|
||||
// ForumLikeStatusActive 表示当前用户仍保持点赞。
|
||||
ForumLikeStatusActive = "active"
|
||||
// ForumLikeStatusCanceled 表示用户取消点赞,保留记录用于奖励幂等。
|
||||
ForumLikeStatusCanceled = "canceled"
|
||||
)
|
||||
|
||||
const (
|
||||
// ForumCommentStatusVisible 表示评论正常展示。
|
||||
ForumCommentStatusVisible = "visible"
|
||||
// ForumCommentStatusDeleted 表示评论已由本人删除,服务层仍保留子回复结构。
|
||||
ForumCommentStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
const (
|
||||
// ForumImportStatusPending 表示导入记录已占位,正在创建 TaskClass 副本。
|
||||
ForumImportStatusPending = "pending"
|
||||
// ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。
|
||||
ForumImportStatusImported = "imported"
|
||||
// ForumImportStatusFailed 表示导入副本创建或最终确认失败,可由后续重试覆盖。
|
||||
ForumImportStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ForumPost 是计划广场帖子主体表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保存社区帖子可展示信息、作者和计数字段;
|
||||
// 2. 不保存完整 TaskClass 模板,模板快照归 ForumPostTemplate / ForumPostTemplateItem;
|
||||
// 3. 计数字段由服务事务内维护,避免列表页每次做聚合统计。
|
||||
type ForumPost struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_posts_author_status,priority:1;uniqueIndex:uk_forum_posts_author_idem,priority:1;comment:作者用户ID"`
|
||||
SourceTaskClassID uint64 `gorm:"column:source_task_class_id;not null;index:idx_forum_posts_source_task_class;comment:发布时选择的原始TaskClass ID,仅用于审计"`
|
||||
Title string `gorm:"column:title;type:varchar(80);not null;comment:帖子标题"`
|
||||
Summary string `gorm:"column:summary;type:text;comment:帖子简介"`
|
||||
TagsJSON string `gorm:"column:tags_json;type:json;not null;comment:标签JSON数组"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_posts_author_idem,priority:2;comment:发布请求幂等键"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'published';index:idx_forum_posts_status_created,priority:1;index:idx_forum_posts_author_status,priority:2;comment:published/hidden/deleted/pending_review"`
|
||||
LikeCount int64 `gorm:"column:like_count;not null;default:0;index:idx_forum_posts_like_count;comment:点赞数冗余计数"`
|
||||
CommentCount int64 `gorm:"column:comment_count;not null;default:0;comment:评论数冗余计数"`
|
||||
ImportCount int64 `gorm:"column:import_count;not null;default:0;index:idx_forum_posts_import_count;comment:导入数冗余计数"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_forum_posts_status_created,priority:2;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index;comment:软删除时间"`
|
||||
}
|
||||
|
||||
func (ForumPost) TableName() string {
|
||||
return "forum_posts"
|
||||
}
|
||||
|
||||
// ForumPostTemplate 是发布瞬间复制出的 TaskClass 配置快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保存可分享的 TaskClass 配置白名单;
|
||||
// 2. 不保存 embedded_time、schedule 绑定和用户私有排程状态;
|
||||
// 3. 后续原作者修改原 TaskClass 时,本快照不跟随变化。
|
||||
type ForumPostTemplate struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_templates_post;comment:所属帖子ID"`
|
||||
SourceTaskClassID uint64 `gorm:"column:source_task_class_id;not null;comment:原始TaskClass ID"`
|
||||
Mode string `gorm:"column:mode;type:varchar(32);comment:TaskClass 模式"`
|
||||
StartDate *time.Time `gorm:"column:start_date;comment:计划开始日期"`
|
||||
EndDate *time.Time `gorm:"column:end_date;comment:计划结束日期"`
|
||||
SubjectType string `gorm:"column:subject_type;type:varchar(32);comment:学科类型"`
|
||||
DifficultyLevel string `gorm:"column:difficulty_level;type:varchar(16);comment:难度等级"`
|
||||
CognitiveIntensity string `gorm:"column:cognitive_intensity;type:varchar(16);comment:认知强度"`
|
||||
TotalSlots int `gorm:"column:total_slots;comment:分配的总节数"`
|
||||
AllowFillerCourse bool `gorm:"column:allow_filler_course;not null;default:true;comment:是否允许填充课程空隙"`
|
||||
Strategy string `gorm:"column:strategy;type:varchar(32);comment:规划策略"`
|
||||
ExcludedSlotsJSON *string `gorm:"column:excluded_slots_json;type:json;comment:排除节次JSON数组"`
|
||||
ExcludedDaysOfWeekJSON *string `gorm:"column:excluded_days_of_week_json;type:json;comment:排除星期JSON数组"`
|
||||
StrategyLabelsJSON *string `gorm:"column:strategy_labels_json;type:json;comment:前端展示用策略标签JSON数组"`
|
||||
ConfigSnapshotJSON *string `gorm:"column:config_snapshot_json;type:json;comment:过滤后的配置快照,便于后续兼容扩展"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (ForumPostTemplate) TableName() string {
|
||||
return "forum_post_templates"
|
||||
}
|
||||
|
||||
// ForumPostTemplateItem 是 TaskClassItem 的可分享快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保存任务条目的顺序和内容;
|
||||
// 2. 不保存 embedded_time,避免把原作者私有排程状态带给导入用户;
|
||||
// 3. 导入时服务层按这些快照重新创建当前用户自己的 TaskClassItem。
|
||||
type ForumPostTemplateItem struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
TemplateID uint64 `gorm:"column:template_id;not null;uniqueIndex:uk_forum_template_items_order,priority:1;index:idx_forum_template_items_template;comment:模板ID"`
|
||||
PostID uint64 `gorm:"column:post_id;not null;index:idx_forum_template_items_post;comment:帖子ID,便于按帖子直接读取预览"`
|
||||
SourceTaskItemID uint64 `gorm:"column:source_task_item_id;not null;comment:原始TaskClassItem ID,仅用于审计"`
|
||||
Order int `gorm:"column:item_order;not null;uniqueIndex:uk_forum_template_items_order,priority:2;comment:条目顺序"`
|
||||
Content string `gorm:"column:content;type:text;not null;comment:任务条目内容"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
}
|
||||
|
||||
func (ForumPostTemplateItem) TableName() string {
|
||||
return "forum_post_template_items"
|
||||
}
|
||||
|
||||
// ForumLike 是点赞幂等表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 通过 post_id + user_id 唯一约束保证同一用户同一帖子只有一条点赞状态;
|
||||
// 2. 取消点赞只把状态改为 canceled,不删除记录,避免作者奖励被反复触发;
|
||||
// 3. event_id 对应首次点赞奖励事件,供 token-store 账本幂等使用。
|
||||
type ForumLike struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_likes_post_user,priority:1;index:idx_forum_likes_post_status,priority:1;comment:帖子ID"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_likes_post_user,priority:2;comment:点赞用户ID"`
|
||||
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_likes_author;comment:帖子作者ID,便于奖励和审计"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_forum_likes_post_status,priority:2;comment:active/canceled"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_forum_likes_event;comment:首次点赞事件ID"`
|
||||
LikedAt time.Time `gorm:"column:liked_at;autoCreateTime;comment:首次点赞时间"`
|
||||
CanceledAt *time.Time `gorm:"column:canceled_at;comment:最近一次取消点赞时间"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (ForumLike) TableName() string {
|
||||
return "forum_likes"
|
||||
}
|
||||
|
||||
// ForumComment 是评论和多层回复的扁平存储表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 数据库只保存 parent_comment_id,不保存树结构;
|
||||
// 2. 服务层按帖子读取扁平评论后组装评论树;
|
||||
// 3. 删除评论使用 status + deleted_at 软删除,保留子回复链路。
|
||||
type ForumComment struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PostID uint64 `gorm:"column:post_id;not null;index:idx_forum_comments_post_parent_created,priority:1;comment:帖子ID"`
|
||||
ParentCommentID *uint64 `gorm:"column:parent_comment_id;index:idx_forum_comments_post_parent_created,priority:2;comment:父评论ID,根评论为空"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_comments_user_idem,priority:1;index:idx_forum_comments_user;comment:评论用户ID"`
|
||||
Content string `gorm:"column:content;type:text;not null;comment:评论内容"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'visible';index:idx_forum_comments_status;comment:visible/deleted"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_comments_user_idem,priority:2;comment:评论创建幂等键"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_forum_comments_post_parent_created,priority:3;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;comment:用户删除时间"`
|
||||
}
|
||||
|
||||
func (ForumComment) TableName() string {
|
||||
return "forum_comments"
|
||||
}
|
||||
|
||||
// ForumImport 是一键导入记录表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 通过 post_id + user_id 唯一约束保证同一用户同一计划只导入一次;
|
||||
// 2. 只记录导入到 TaskClass 的结果,不写 schedule;
|
||||
// 3. event_id 对应导入奖励事件,供 token-store 账本幂等使用。
|
||||
type ForumImport struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:1;index:idx_forum_imports_post;comment:帖子ID"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:2;uniqueIndex:uk_forum_imports_user_idem,priority:1;index:idx_forum_imports_user;comment:导入用户ID"`
|
||||
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_imports_author;comment:帖子作者ID,便于奖励和审计"`
|
||||
NewTaskClassID *uint64 `gorm:"column:new_task_class_id;comment:导入后创建的当前用户TaskClass ID,pending/failed 时为空"`
|
||||
TargetTitle string `gorm:"column:target_title;type:varchar(80);comment:导入后的TaskClass标题"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';comment:pending/imported/failed"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_forum_imports_event;comment:导入事件ID"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_imports_user_idem,priority:2;comment:导入请求幂等键"`
|
||||
LastError *string `gorm:"column:last_error;type:text;comment:最近一次导入失败原因"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (ForumImport) TableName() string {
|
||||
return "forum_imports"
|
||||
}
|
||||
72
backend/services/taskclassforum/rpc/errors.go
Normal file
72
backend/services/taskclassforum/rpc/errors.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const taskClassForumErrorDomain = "smartflow.taskclassforum"
|
||||
|
||||
// grpcErrorFromServiceError 负责把计划广场内部错误收口成 gRPC status。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做 service error -> gRPC error 的传输适配;
|
||||
// 2. 不负责 HTTP 响应,gateway client 后续会把 gRPC error 反解成 respond.Response;
|
||||
// 3. 普通内部错误只暴露统一文案,避免把 DAO / SQL 细节透给前端。
|
||||
func grpcErrorFromServiceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
return grpcErrorFromResponse(resp)
|
||||
}
|
||||
log.Printf("taskclassforum rpc internal error: %v", err)
|
||||
return status.Error(codes.Internal, "taskclassforum service internal error")
|
||||
}
|
||||
|
||||
func grpcErrorFromResponse(resp respond.Response) error {
|
||||
code := grpcCodeFromRespondStatus(resp.Status)
|
||||
message := strings.TrimSpace(resp.Info)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(resp.Status)
|
||||
}
|
||||
|
||||
st := status.New(code, message)
|
||||
detail := &errdetails.ErrorInfo{
|
||||
Domain: taskClassForumErrorDomain,
|
||||
Reason: resp.Status,
|
||||
Metadata: map[string]string{
|
||||
"info": resp.Info,
|
||||
},
|
||||
}
|
||||
withDetails, err := st.WithDetails(detail)
|
||||
if err != nil {
|
||||
return st.Err()
|
||||
}
|
||||
return withDetails.Err()
|
||||
}
|
||||
|
||||
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||
switch strings.TrimSpace(statusValue) {
|
||||
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status, respond.ErrUnauthorized.Status:
|
||||
return codes.Unauthenticated
|
||||
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status, respond.WrongUserID.Status:
|
||||
return codes.InvalidArgument
|
||||
case respond.UserTaskClassNotFound.Status:
|
||||
return codes.NotFound
|
||||
case respond.UserTaskClassForbidden.Status, respond.TaskClassItemNotBelongToUser.Status:
|
||||
return codes.PermissionDenied
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||
return codes.Internal
|
||||
}
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
412
backend/services/taskclassforum/rpc/handler.go
Normal file
412
backend/services/taskclassforum/rpc/handler.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
pb.UnimplementedTaskClassForumServiceServer
|
||||
svc *forumsv.Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *forumsv.Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// service 负责统一校验 RPC 层依赖是否已经注入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只判断 handler 自身和业务 service 是否可用;
|
||||
// 2. 不负责校验请求参数,也不处理具体业务规则;
|
||||
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
|
||||
func (h *Handler) service() (*forumsv.Service, error) {
|
||||
if h == nil || h.svc == nil {
|
||||
return nil, errors.New("taskclassforum service dependency not initialized")
|
||||
}
|
||||
return h.svc, nil
|
||||
}
|
||||
|
||||
// ListPosts 负责把计划广场列表请求从 gRPC 协议转成内部服务调用。
|
||||
func (h *Handler) ListPosts(ctx context.Context, req *pb.ListForumPostsRequest) (*pb.ListForumPostsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListPosts(ctx, req.ActorUserId, int(req.Page), int(req.PageSize), req.Sort, req.Keyword, req.Tag)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListForumPostsResponse{
|
||||
Items: forumPostBriefsToPB(items),
|
||||
Page: forumPageToPB(page),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListTags(ctx context.Context, req *pb.ListForumTagsRequest) (*pb.ListForumTagsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, err := svc.ListTags(ctx, req.ActorUserId, int(req.Limit))
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListForumTagsResponse{Items: forumTagItemsToPB(items)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreatePost(ctx context.Context, req *pb.CreateForumPostRequest) (*pb.CreateForumPostResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
post, err := svc.CreatePost(ctx, forumcontracts.CreateForumPostRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
TaskClassID: req.TaskClassId,
|
||||
Title: req.Title,
|
||||
Summary: req.Summary,
|
||||
Tags: append([]string(nil), req.Tags...),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.CreateForumPostResponse{Post: forumPostBriefToPB(post)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetPost(ctx context.Context, req *pb.GetForumPostRequest) (*pb.GetForumPostResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
data, err := svc.GetPost(ctx, req.ActorUserId, req.PostId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetForumPostResponse{Data: forumPostDetailToPB(data)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) LikePost(ctx context.Context, req *pb.LikeForumPostRequest) (*pb.LikeForumPostResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
counters, viewerState, err := svc.LikePost(ctx, req.ActorUserId, req.PostId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.LikeForumPostResponse{
|
||||
Counters: forumPostCountersToPB(counters),
|
||||
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) UnlikePost(ctx context.Context, req *pb.UnlikeForumPostRequest) (*pb.UnlikeForumPostResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
counters, viewerState, err := svc.UnlikePost(ctx, req.ActorUserId, req.PostId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.UnlikeForumPostResponse{
|
||||
Counters: forumPostCountersToPB(counters),
|
||||
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListComments(ctx context.Context, req *pb.ListForumCommentsRequest) (*pb.ListForumCommentsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListComments(ctx, req.ActorUserId, req.PostId, int(req.Page), int(req.PageSize), req.Sort)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListForumCommentsResponse{
|
||||
Items: forumCommentNodesToPB(items),
|
||||
Page: forumPageToPB(page),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateComment(ctx context.Context, req *pb.CreateForumCommentRequest) (*pb.CreateForumCommentResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
comment, err := svc.CreateComment(ctx, forumcontracts.CreateForumCommentRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
PostID: req.PostId,
|
||||
Content: req.Content,
|
||||
ParentCommentID: forumUint64PtrFromPositive(req.ParentCommentId),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.CreateForumCommentResponse{Comment: forumCommentNodeToPB(comment)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(ctx context.Context, req *pb.DeleteForumCommentRequest) (*pb.DeleteForumCommentResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
result, err := svc.DeleteComment(ctx, req.ActorUserId, req.CommentId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.DeleteForumCommentResponse{
|
||||
CommentId: result.CommentID,
|
||||
Status: result.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ImportPost(ctx context.Context, req *pb.ImportForumPostRequest) (*pb.ImportForumPostResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
result, err := svc.ImportPost(ctx, forumcontracts.ImportForumPostRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
PostID: req.PostId,
|
||||
TargetTitle: req.TargetTitle,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ImportForumPostResponse{
|
||||
ImportId: result.ImportID,
|
||||
PostId: result.PostID,
|
||||
NewTaskClassId: result.NewTaskClassID,
|
||||
TaskClassTitle: result.TaskClassTitle,
|
||||
ImportCount: result.ImportCount,
|
||||
CreatedAt: result.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func forumPageToPB(page forumcontracts.PageResult) *pb.PageResponse {
|
||||
return &pb.PageResponse{
|
||||
Page: int32(page.Page),
|
||||
PageSize: int32(page.PageSize),
|
||||
Total: int32(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func forumUserToPB(user forumcontracts.UserBrief) *pb.UserBrief {
|
||||
return &pb.UserBrief{
|
||||
UserId: user.UserID,
|
||||
Nickname: user.Nickname,
|
||||
AvatarUrl: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func forumTemplateSummaryToPB(summary forumcontracts.TemplateSummary) *pb.TemplateSummary {
|
||||
return &pb.TemplateSummary{
|
||||
TaskCount: int32(summary.TaskCount),
|
||||
Mode: summary.Mode,
|
||||
StartDate: summary.StartDate,
|
||||
EndDate: summary.EndDate,
|
||||
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostCountersToPB(counters forumcontracts.ForumPostCounters) *pb.ForumPostCounters {
|
||||
return &pb.ForumPostCounters{
|
||||
LikeCount: counters.LikeCount,
|
||||
CommentCount: counters.CommentCount,
|
||||
ImportCount: counters.ImportCount,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostViewerStateToPB(viewerState forumcontracts.ForumPostViewerState) *pb.ForumPostViewerState {
|
||||
return &pb.ForumPostViewerState{
|
||||
Liked: viewerState.Liked,
|
||||
ImportedOnce: viewerState.ImportedOnce,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostBriefToPB(post *forumcontracts.ForumPostBrief) *pb.ForumPostBrief {
|
||||
if post == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.ForumPostBrief{
|
||||
PostId: post.PostID,
|
||||
Title: post.Title,
|
||||
Summary: post.Summary,
|
||||
Tags: append([]string(nil), post.Tags...),
|
||||
Author: forumUserToPB(post.Author),
|
||||
TemplateSummary: forumTemplateSummaryToPB(post.TemplateSummary),
|
||||
Counters: forumPostCountersToPB(post.Counters),
|
||||
ViewerState: forumPostViewerStateToPB(post.ViewerState),
|
||||
Status: post.Status,
|
||||
CreatedAt: post.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostBriefsToPB(items []forumcontracts.ForumPostBrief) []*pb.ForumPostBrief {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.ForumPostBrief, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, forumPostBriefToPB(&item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumTemplateItemPreviewToPB(item forumcontracts.TemplateItemPreview) *pb.TemplateItemPreview {
|
||||
return &pb.TemplateItemPreview{
|
||||
ItemId: item.ItemID,
|
||||
Order: int32(item.Order),
|
||||
Content: item.Content,
|
||||
}
|
||||
}
|
||||
|
||||
func forumTemplateDetailToPB(detail forumcontracts.TemplateDetail) *pb.TemplateDetail {
|
||||
preview := make([]*pb.TemplateItemPreview, 0, len(detail.ItemsPreview))
|
||||
for i := range detail.ItemsPreview {
|
||||
item := detail.ItemsPreview[i]
|
||||
preview = append(preview, forumTemplateItemPreviewToPB(item))
|
||||
}
|
||||
return &pb.TemplateDetail{
|
||||
Mode: detail.Mode,
|
||||
StartDate: detail.StartDate,
|
||||
EndDate: detail.EndDate,
|
||||
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
|
||||
TaskCount: int32(detail.TaskCount),
|
||||
ItemsPreview: preview,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostDetailToPB(detail *forumcontracts.ForumPostDetail) *pb.ForumPostDetail {
|
||||
if detail == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.ForumPostDetail{
|
||||
Post: forumPostBriefToPB(&detail.Post),
|
||||
Template: forumTemplateDetailToPB(detail.Template),
|
||||
}
|
||||
}
|
||||
|
||||
func forumTagItemsToPB(items []forumcontracts.ForumTagItem) []*pb.ForumTagItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.ForumTagItem, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, &pb.ForumTagItem{
|
||||
Tag: item.Tag,
|
||||
PostCount: int32(item.PostCount),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumCommentNodeToPB(node *forumcontracts.ForumCommentNode) *pb.ForumCommentNode {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
children := make([]*pb.ForumCommentNode, 0, len(node.Children))
|
||||
for i := range node.Children {
|
||||
child := node.Children[i]
|
||||
children = append(children, forumCommentNodeToPB(&child))
|
||||
}
|
||||
return &pb.ForumCommentNode{
|
||||
CommentId: node.CommentID,
|
||||
PostId: node.PostID,
|
||||
ParentCommentId: forumUint64FromPtr(node.ParentCommentID),
|
||||
Content: node.Content,
|
||||
Status: node.Status,
|
||||
Author: forumUserToPB(node.Author),
|
||||
CanDelete: node.CanDelete,
|
||||
CreatedAt: node.CreatedAt,
|
||||
DeletedAt: forumStringFromPtr(node.DeletedAt),
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
func forumCommentNodesToPB(items []forumcontracts.ForumCommentNode) []*pb.ForumCommentNode {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.ForumCommentNode, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, forumCommentNodeToPB(&item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumUint64FromPtr(value *uint64) uint64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func forumUint64PtrFromPositive(value uint64) *uint64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
result := value
|
||||
return &result
|
||||
}
|
||||
|
||||
func forumStringFromPtr(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package pb
|
||||
|
||||
import proto "github.com/golang/protobuf/proto"
|
||||
|
||||
var _ = proto.Marshal
|
||||
|
||||
const _ = proto.ProtoPackageIsVersion3
|
||||
|
||||
type PageRequest struct {
|
||||
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
func (m *PageRequest) Reset() { *m = PageRequest{} }
|
||||
func (m *PageRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*PageRequest) ProtoMessage() {}
|
||||
|
||||
type PageResponse struct {
|
||||
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"`
|
||||
HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
func (m *PageResponse) Reset() { *m = PageResponse{} }
|
||||
func (m *PageResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*PageResponse) ProtoMessage() {}
|
||||
|
||||
type UserBrief struct {
|
||||
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Nickname string `protobuf:"bytes,2,opt,name=nickname,proto3" json:"nickname,omitempty"`
|
||||
AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
func (m *UserBrief) Reset() { *m = UserBrief{} }
|
||||
func (m *UserBrief) String() string { return proto.CompactTextString(m) }
|
||||
func (*UserBrief) ProtoMessage() {}
|
||||
|
||||
type TemplateSummary struct {
|
||||
TaskCount int32 `protobuf:"varint,1,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||
Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||
StartDate string `protobuf:"bytes,3,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||
EndDate string `protobuf:"bytes,4,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||
StrategyLabels []string `protobuf:"bytes,5,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||
}
|
||||
|
||||
func (m *TemplateSummary) Reset() { *m = TemplateSummary{} }
|
||||
func (m *TemplateSummary) String() string { return proto.CompactTextString(m) }
|
||||
func (*TemplateSummary) ProtoMessage() {}
|
||||
|
||||
type ForumPostCounters struct {
|
||||
LikeCount int64 `protobuf:"varint,1,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"`
|
||||
CommentCount int64 `protobuf:"varint,2,opt,name=comment_count,json=commentCount,proto3" json:"comment_count,omitempty"`
|
||||
ImportCount int64 `protobuf:"varint,3,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumPostCounters) Reset() { *m = ForumPostCounters{} }
|
||||
func (m *ForumPostCounters) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumPostCounters) ProtoMessage() {}
|
||||
|
||||
type ForumPostViewerState struct {
|
||||
Liked bool `protobuf:"varint,1,opt,name=liked,proto3" json:"liked,omitempty"`
|
||||
ImportedOnce bool `protobuf:"varint,2,opt,name=imported_once,json=importedOnce,proto3" json:"imported_once,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumPostViewerState) Reset() { *m = ForumPostViewerState{} }
|
||||
func (m *ForumPostViewerState) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumPostViewerState) ProtoMessage() {}
|
||||
|
||||
type ForumPostBrief struct {
|
||||
PostId uint64 `protobuf:"varint,1,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||
Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||
Author *UserBrief `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"`
|
||||
TemplateSummary *TemplateSummary `protobuf:"bytes,6,opt,name=template_summary,json=templateSummary,proto3" json:"template_summary,omitempty"`
|
||||
Counters *ForumPostCounters `protobuf:"bytes,7,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||
ViewerState *ForumPostViewerState `protobuf:"bytes,8,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
|
||||
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumPostBrief) Reset() { *m = ForumPostBrief{} }
|
||||
func (m *ForumPostBrief) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumPostBrief) ProtoMessage() {}
|
||||
|
||||
type TemplateItemPreview struct {
|
||||
ItemId uint64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
|
||||
Order int32 `protobuf:"varint,2,opt,name=order,proto3" json:"order,omitempty"`
|
||||
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||
}
|
||||
|
||||
func (m *TemplateItemPreview) Reset() { *m = TemplateItemPreview{} }
|
||||
func (m *TemplateItemPreview) String() string { return proto.CompactTextString(m) }
|
||||
func (*TemplateItemPreview) ProtoMessage() {}
|
||||
|
||||
type TemplateDetail struct {
|
||||
Mode string `protobuf:"bytes,1,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||
StartDate string `protobuf:"bytes,2,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||
EndDate string `protobuf:"bytes,3,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||
StrategyLabels []string `protobuf:"bytes,4,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||
TaskCount int32 `protobuf:"varint,5,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||
ItemsPreview []*TemplateItemPreview `protobuf:"bytes,6,rep,name=items_preview,json=itemsPreview,proto3" json:"items_preview,omitempty"`
|
||||
}
|
||||
|
||||
func (m *TemplateDetail) Reset() { *m = TemplateDetail{} }
|
||||
func (m *TemplateDetail) String() string { return proto.CompactTextString(m) }
|
||||
func (*TemplateDetail) ProtoMessage() {}
|
||||
|
||||
type ForumPostDetail struct {
|
||||
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||
Template *TemplateDetail `protobuf:"bytes,2,opt,name=template,proto3" json:"template,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumPostDetail) Reset() { *m = ForumPostDetail{} }
|
||||
func (m *ForumPostDetail) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumPostDetail) ProtoMessage() {}
|
||||
|
||||
type ForumCommentNode struct {
|
||||
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
ParentCommentId uint64 `protobuf:"varint,3,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||
Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
|
||||
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Author *UserBrief `protobuf:"bytes,6,opt,name=author,proto3" json:"author,omitempty"`
|
||||
CanDelete bool `protobuf:"varint,7,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"`
|
||||
CreatedAt string `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
DeletedAt string `protobuf:"bytes,9,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"`
|
||||
Children []*ForumCommentNode `protobuf:"bytes,10,rep,name=children,proto3" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumCommentNode) Reset() { *m = ForumCommentNode{} }
|
||||
func (m *ForumCommentNode) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumCommentNode) ProtoMessage() {}
|
||||
|
||||
type ListForumPostsRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
Sort string `protobuf:"bytes,4,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||
Keyword string `protobuf:"bytes,5,opt,name=keyword,proto3" json:"keyword,omitempty"`
|
||||
Tag string `protobuf:"bytes,6,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumPostsRequest) Reset() { *m = ListForumPostsRequest{} }
|
||||
func (m *ListForumPostsRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumPostsRequest) ProtoMessage() {}
|
||||
|
||||
type ListForumPostsResponse struct {
|
||||
Items []*ForumPostBrief `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumPostsResponse) Reset() { *m = ListForumPostsResponse{} }
|
||||
func (m *ListForumPostsResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumPostsResponse) ProtoMessage() {}
|
||||
|
||||
type ListForumTagsRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumTagsRequest) Reset() { *m = ListForumTagsRequest{} }
|
||||
func (m *ListForumTagsRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumTagsRequest) ProtoMessage() {}
|
||||
|
||||
type ForumTagItem struct {
|
||||
Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||
PostCount int32 `protobuf:"varint,2,opt,name=post_count,json=postCount,proto3" json:"post_count,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ForumTagItem) Reset() { *m = ForumTagItem{} }
|
||||
func (m *ForumTagItem) String() string { return proto.CompactTextString(m) }
|
||||
func (*ForumTagItem) ProtoMessage() {}
|
||||
|
||||
type ListForumTagsResponse struct {
|
||||
Items []*ForumTagItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumTagsResponse) Reset() { *m = ListForumTagsResponse{} }
|
||||
func (m *ListForumTagsResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumTagsResponse) ProtoMessage() {}
|
||||
|
||||
type CreateForumPostRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
TaskClassId uint64 `protobuf:"varint,2,opt,name=task_class_id,json=taskClassId,proto3" json:"task_class_id,omitempty"`
|
||||
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||
Tags []string `protobuf:"bytes,5,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||
IdempotencyKey string `protobuf:"bytes,6,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
func (m *CreateForumPostRequest) Reset() { *m = CreateForumPostRequest{} }
|
||||
func (m *CreateForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*CreateForumPostRequest) ProtoMessage() {}
|
||||
|
||||
type CreateForumPostResponse struct {
|
||||
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||
}
|
||||
|
||||
func (m *CreateForumPostResponse) Reset() { *m = CreateForumPostResponse{} }
|
||||
func (m *CreateForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*CreateForumPostResponse) ProtoMessage() {}
|
||||
|
||||
type GetForumPostRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *GetForumPostRequest) Reset() { *m = GetForumPostRequest{} }
|
||||
func (m *GetForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*GetForumPostRequest) ProtoMessage() {}
|
||||
|
||||
type GetForumPostResponse struct {
|
||||
Data *ForumPostDetail `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (m *GetForumPostResponse) Reset() { *m = GetForumPostResponse{} }
|
||||
func (m *GetForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*GetForumPostResponse) ProtoMessage() {}
|
||||
|
||||
type LikeForumPostRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *LikeForumPostRequest) Reset() { *m = LikeForumPostRequest{} }
|
||||
func (m *LikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*LikeForumPostRequest) ProtoMessage() {}
|
||||
|
||||
type LikeForumPostResponse struct {
|
||||
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||
}
|
||||
|
||||
func (m *LikeForumPostResponse) Reset() { *m = LikeForumPostResponse{} }
|
||||
func (m *LikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*LikeForumPostResponse) ProtoMessage() {}
|
||||
|
||||
type UnlikeForumPostRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *UnlikeForumPostRequest) Reset() { *m = UnlikeForumPostRequest{} }
|
||||
func (m *UnlikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*UnlikeForumPostRequest) ProtoMessage() {}
|
||||
|
||||
type UnlikeForumPostResponse struct {
|
||||
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||
}
|
||||
|
||||
func (m *UnlikeForumPostResponse) Reset() { *m = UnlikeForumPostResponse{} }
|
||||
func (m *UnlikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*UnlikeForumPostResponse) ProtoMessage() {}
|
||||
|
||||
type ListForumCommentsRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
Sort string `protobuf:"bytes,5,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumCommentsRequest) Reset() { *m = ListForumCommentsRequest{} }
|
||||
func (m *ListForumCommentsRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumCommentsRequest) ProtoMessage() {}
|
||||
|
||||
type ListForumCommentsResponse struct {
|
||||
Items []*ForumCommentNode `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ListForumCommentsResponse) Reset() { *m = ListForumCommentsResponse{} }
|
||||
func (m *ListForumCommentsResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*ListForumCommentsResponse) ProtoMessage() {}
|
||||
|
||||
type CreateForumCommentRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||
ParentCommentId uint64 `protobuf:"varint,4,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||
IdempotencyKey string `protobuf:"bytes,5,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
func (m *CreateForumCommentRequest) Reset() { *m = CreateForumCommentRequest{} }
|
||||
func (m *CreateForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*CreateForumCommentRequest) ProtoMessage() {}
|
||||
|
||||
type CreateForumCommentResponse struct {
|
||||
Comment *ForumCommentNode `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (m *CreateForumCommentResponse) Reset() { *m = CreateForumCommentResponse{} }
|
||||
func (m *CreateForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*CreateForumCommentResponse) ProtoMessage() {}
|
||||
|
||||
type DeleteForumCommentRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
CommentId uint64 `protobuf:"varint,2,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *DeleteForumCommentRequest) Reset() { *m = DeleteForumCommentRequest{} }
|
||||
func (m *DeleteForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*DeleteForumCommentRequest) ProtoMessage() {}
|
||||
|
||||
type DeleteForumCommentResponse struct {
|
||||
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (m *DeleteForumCommentResponse) Reset() { *m = DeleteForumCommentResponse{} }
|
||||
func (m *DeleteForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*DeleteForumCommentResponse) ProtoMessage() {}
|
||||
|
||||
type ImportForumPostRequest struct {
|
||||
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
TargetTitle string `protobuf:"bytes,3,opt,name=target_title,json=targetTitle,proto3" json:"target_title,omitempty"`
|
||||
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ImportForumPostRequest) Reset() { *m = ImportForumPostRequest{} }
|
||||
func (m *ImportForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*ImportForumPostRequest) ProtoMessage() {}
|
||||
|
||||
type ImportForumPostResponse struct {
|
||||
ImportId uint64 `protobuf:"varint,1,opt,name=import_id,json=importId,proto3" json:"import_id,omitempty"`
|
||||
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||
NewTaskClassId uint64 `protobuf:"varint,3,opt,name=new_task_class_id,json=newTaskClassId,proto3" json:"new_task_class_id,omitempty"`
|
||||
TaskClassTitle string `protobuf:"bytes,4,opt,name=task_class_title,json=taskClassTitle,proto3" json:"task_class_title,omitempty"`
|
||||
ImportCount int64 `protobuf:"varint,5,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||
CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func (m *ImportForumPostResponse) Reset() { *m = ImportForumPostResponse{} }
|
||||
func (m *ImportForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*ImportForumPostResponse) ProtoMessage() {}
|
||||
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskClassForumService_ListPosts_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListPosts"
|
||||
TaskClassForumService_ListTags_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListTags"
|
||||
TaskClassForumService_CreatePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreatePost"
|
||||
TaskClassForumService_GetPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/GetPost"
|
||||
TaskClassForumService_LikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/LikePost"
|
||||
TaskClassForumService_UnlikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/UnlikePost"
|
||||
TaskClassForumService_ListComments_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListComments"
|
||||
TaskClassForumService_CreateComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreateComment"
|
||||
TaskClassForumService_DeleteComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/DeleteComment"
|
||||
TaskClassForumService_ImportPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ImportPost"
|
||||
)
|
||||
|
||||
type TaskClassForumServiceClient interface {
|
||||
ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error)
|
||||
ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error)
|
||||
CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error)
|
||||
GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error)
|
||||
LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error)
|
||||
UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error)
|
||||
ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error)
|
||||
CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error)
|
||||
DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error)
|
||||
ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error)
|
||||
}
|
||||
|
||||
type taskClassForumServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewTaskClassForumServiceClient(cc grpc.ClientConnInterface) TaskClassForumServiceClient {
|
||||
return &taskClassForumServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error) {
|
||||
return invokeTaskClassForum[ListForumPostsResponse](ctx, c.cc, TaskClassForumService_ListPosts_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error) {
|
||||
return invokeTaskClassForum[ListForumTagsResponse](ctx, c.cc, TaskClassForumService_ListTags_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error) {
|
||||
return invokeTaskClassForum[CreateForumPostResponse](ctx, c.cc, TaskClassForumService_CreatePost_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error) {
|
||||
return invokeTaskClassForum[GetForumPostResponse](ctx, c.cc, TaskClassForumService_GetPost_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error) {
|
||||
return invokeTaskClassForum[LikeForumPostResponse](ctx, c.cc, TaskClassForumService_LikePost_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error) {
|
||||
return invokeTaskClassForum[UnlikeForumPostResponse](ctx, c.cc, TaskClassForumService_UnlikePost_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error) {
|
||||
return invokeTaskClassForum[ListForumCommentsResponse](ctx, c.cc, TaskClassForumService_ListComments_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error) {
|
||||
return invokeTaskClassForum[CreateForumCommentResponse](ctx, c.cc, TaskClassForumService_CreateComment_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error) {
|
||||
return invokeTaskClassForum[DeleteForumCommentResponse](ctx, c.cc, TaskClassForumService_DeleteComment_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *taskClassForumServiceClient) ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error) {
|
||||
return invokeTaskClassForum[ImportForumPostResponse](ctx, c.cc, TaskClassForumService_ImportPost_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func invokeTaskClassForum[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
|
||||
out := new(Resp)
|
||||
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type TaskClassForumServiceServer interface {
|
||||
ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error)
|
||||
ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error)
|
||||
CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error)
|
||||
GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error)
|
||||
LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error)
|
||||
UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error)
|
||||
ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error)
|
||||
CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error)
|
||||
DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error)
|
||||
ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedTaskClassForumServiceServer struct{}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPosts not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListTags not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreatePost not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPost not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method LikePost not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UnlikePost not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListComments not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteComment not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTaskClassForumServiceServer) ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ImportPost not implemented")
|
||||
}
|
||||
|
||||
func RegisterTaskClassForumServiceServer(s grpc.ServiceRegistrar, srv TaskClassForumServiceServer) {
|
||||
s.RegisterService(&TaskClassForumService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func taskClassForumUnaryHandler[Req any](methodName string, fullMethod string, invoke func(TaskClassForumServiceServer, context.Context, *Req) (interface{}, error)) grpc.MethodDesc {
|
||||
return grpc.MethodDesc{
|
||||
MethodName: methodName,
|
||||
Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Req)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return invoke(srv.(TaskClassForumServiceServer), ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: fullMethod,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return invoke(srv.(TaskClassForumServiceServer), ctx, req.(*Req))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var TaskClassForumService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "smartflow.taskclassforum.TaskClassForumService",
|
||||
HandlerType: (*TaskClassForumServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
taskClassForumUnaryHandler[ListForumPostsRequest]("ListPosts", TaskClassForumService_ListPosts_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumPostsRequest) (interface{}, error) {
|
||||
return s.ListPosts(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[ListForumTagsRequest]("ListTags", TaskClassForumService_ListTags_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumTagsRequest) (interface{}, error) {
|
||||
return s.ListTags(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[CreateForumPostRequest]("CreatePost", TaskClassForumService_CreatePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumPostRequest) (interface{}, error) {
|
||||
return s.CreatePost(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[GetForumPostRequest]("GetPost", TaskClassForumService_GetPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *GetForumPostRequest) (interface{}, error) {
|
||||
return s.GetPost(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[LikeForumPostRequest]("LikePost", TaskClassForumService_LikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *LikeForumPostRequest) (interface{}, error) {
|
||||
return s.LikePost(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[UnlikeForumPostRequest]("UnlikePost", TaskClassForumService_UnlikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *UnlikeForumPostRequest) (interface{}, error) {
|
||||
return s.UnlikePost(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[ListForumCommentsRequest]("ListComments", TaskClassForumService_ListComments_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumCommentsRequest) (interface{}, error) {
|
||||
return s.ListComments(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[CreateForumCommentRequest]("CreateComment", TaskClassForumService_CreateComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumCommentRequest) (interface{}, error) {
|
||||
return s.CreateComment(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[DeleteForumCommentRequest]("DeleteComment", TaskClassForumService_DeleteComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *DeleteForumCommentRequest) (interface{}, error) {
|
||||
return s.DeleteComment(ctx, req)
|
||||
}),
|
||||
taskClassForumUnaryHandler[ImportForumPostRequest]("ImportPost", TaskClassForumService_ImportPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ImportForumPostRequest) (interface{}, error) {
|
||||
return s.ImportPost(ctx, req)
|
||||
}),
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "taskclassforum.proto",
|
||||
}
|
||||
73
backend/services/taskclassforum/rpc/server.go
Normal file
73
backend/services/taskclassforum/rpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||
"github.com/zeromicro/go-zero/core/service"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenOn = "0.0.0.0:9090"
|
||||
defaultTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
ListenOn string
|
||||
Timeout time.Duration
|
||||
Service *forumsv.Service
|
||||
}
|
||||
|
||||
// Start 启动计划广场 zrpc 服务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责装配 go-zero zrpc server 和注册 protobuf service;
|
||||
// 2. 不创建 DB 连接,也不装配 TaskClass legacy adapter,这些依赖由 cmd 入口注入;
|
||||
// 3. 启动后阻塞当前进程,保持后续“一服务一进程”的迁移方向。
|
||||
func Start(opts ServerOptions) {
|
||||
server, listenOn, err := NewServer(opts)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build taskclassforum zrpc server: %v", err)
|
||||
}
|
||||
defer server.Stop()
|
||||
|
||||
log.Printf("taskclassforum zrpc service starting on %s", listenOn)
|
||||
server.Start()
|
||||
}
|
||||
|
||||
// NewServer 负责创建计划广场 RPC server,供 cmd 启动和测试复用。
|
||||
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||
if opts.Service == nil {
|
||||
return nil, "", errors.New("taskclassforum service dependency not initialized")
|
||||
}
|
||||
|
||||
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||
if listenOn == "" {
|
||||
listenOn = defaultListenOn
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||
ServiceConf: service.ServiceConf{
|
||||
Name: "taskclassforum.rpc",
|
||||
Mode: service.DevMode,
|
||||
},
|
||||
ListenOn: listenOn,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
}, func(grpcServer *grpc.Server) {
|
||||
pb.RegisterTaskClassForumServiceServer(grpcServer, NewHandler(opts.Service))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return server, listenOn, nil
|
||||
}
|
||||
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
@@ -0,0 +1,222 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package smartflow.taskclassforum;
|
||||
|
||||
option go_package = "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb";
|
||||
|
||||
service TaskClassForumService {
|
||||
rpc ListPosts(ListForumPostsRequest) returns (ListForumPostsResponse);
|
||||
rpc ListTags(ListForumTagsRequest) returns (ListForumTagsResponse);
|
||||
rpc CreatePost(CreateForumPostRequest) returns (CreateForumPostResponse);
|
||||
rpc GetPost(GetForumPostRequest) returns (GetForumPostResponse);
|
||||
rpc LikePost(LikeForumPostRequest) returns (LikeForumPostResponse);
|
||||
rpc UnlikePost(UnlikeForumPostRequest) returns (UnlikeForumPostResponse);
|
||||
rpc ListComments(ListForumCommentsRequest) returns (ListForumCommentsResponse);
|
||||
rpc CreateComment(CreateForumCommentRequest) returns (CreateForumCommentResponse);
|
||||
rpc DeleteComment(DeleteForumCommentRequest) returns (DeleteForumCommentResponse);
|
||||
rpc ImportPost(ImportForumPostRequest) returns (ImportForumPostResponse);
|
||||
}
|
||||
|
||||
message PageRequest {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
}
|
||||
|
||||
message PageResponse {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
int32 total = 3;
|
||||
bool has_more = 4;
|
||||
}
|
||||
|
||||
message UserBrief {
|
||||
uint64 user_id = 1;
|
||||
string nickname = 2;
|
||||
string avatar_url = 3;
|
||||
}
|
||||
|
||||
message TemplateSummary {
|
||||
int32 task_count = 1;
|
||||
string mode = 2;
|
||||
string start_date = 3;
|
||||
string end_date = 4;
|
||||
repeated string strategy_labels = 5;
|
||||
}
|
||||
|
||||
message ForumPostCounters {
|
||||
int64 like_count = 1;
|
||||
int64 comment_count = 2;
|
||||
int64 import_count = 3;
|
||||
}
|
||||
|
||||
message ForumPostViewerState {
|
||||
bool liked = 1;
|
||||
bool imported_once = 2;
|
||||
}
|
||||
|
||||
message ForumPostBrief {
|
||||
uint64 post_id = 1;
|
||||
string title = 2;
|
||||
string summary = 3;
|
||||
repeated string tags = 4;
|
||||
UserBrief author = 5;
|
||||
TemplateSummary template_summary = 6;
|
||||
ForumPostCounters counters = 7;
|
||||
ForumPostViewerState viewer_state = 8;
|
||||
string status = 9;
|
||||
string created_at = 10;
|
||||
}
|
||||
|
||||
message TemplateItemPreview {
|
||||
uint64 item_id = 1;
|
||||
int32 order = 2;
|
||||
string content = 3;
|
||||
}
|
||||
|
||||
message TemplateDetail {
|
||||
string mode = 1;
|
||||
string start_date = 2;
|
||||
string end_date = 3;
|
||||
repeated string strategy_labels = 4;
|
||||
int32 task_count = 5;
|
||||
repeated TemplateItemPreview items_preview = 6;
|
||||
}
|
||||
|
||||
message ForumPostDetail {
|
||||
ForumPostBrief post = 1;
|
||||
TemplateDetail template = 2;
|
||||
}
|
||||
|
||||
message ForumCommentNode {
|
||||
uint64 comment_id = 1;
|
||||
uint64 post_id = 2;
|
||||
uint64 parent_comment_id = 3;
|
||||
string content = 4;
|
||||
string status = 5;
|
||||
UserBrief author = 6;
|
||||
bool can_delete = 7;
|
||||
string created_at = 8;
|
||||
string deleted_at = 9;
|
||||
repeated ForumCommentNode children = 10;
|
||||
}
|
||||
|
||||
message ListForumPostsRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
int32 page = 2;
|
||||
int32 page_size = 3;
|
||||
string sort = 4;
|
||||
string keyword = 5;
|
||||
string tag = 6;
|
||||
}
|
||||
|
||||
message ListForumPostsResponse {
|
||||
repeated ForumPostBrief items = 1;
|
||||
PageResponse page = 2;
|
||||
}
|
||||
|
||||
message ListForumTagsRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ForumTagItem {
|
||||
string tag = 1;
|
||||
int32 post_count = 2;
|
||||
}
|
||||
|
||||
message ListForumTagsResponse {
|
||||
repeated ForumTagItem items = 1;
|
||||
}
|
||||
|
||||
message CreateForumPostRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 task_class_id = 2;
|
||||
string title = 3;
|
||||
string summary = 4;
|
||||
repeated string tags = 5;
|
||||
string idempotency_key = 6;
|
||||
}
|
||||
|
||||
message CreateForumPostResponse {
|
||||
ForumPostBrief post = 1;
|
||||
}
|
||||
|
||||
message GetForumPostRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
}
|
||||
|
||||
message GetForumPostResponse {
|
||||
ForumPostDetail data = 1;
|
||||
}
|
||||
|
||||
message LikeForumPostRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
}
|
||||
|
||||
message LikeForumPostResponse {
|
||||
ForumPostCounters counters = 1;
|
||||
ForumPostViewerState viewer_state = 2;
|
||||
}
|
||||
|
||||
message UnlikeForumPostRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
}
|
||||
|
||||
message UnlikeForumPostResponse {
|
||||
ForumPostCounters counters = 1;
|
||||
ForumPostViewerState viewer_state = 2;
|
||||
}
|
||||
|
||||
message ListForumCommentsRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
string sort = 5;
|
||||
}
|
||||
|
||||
message ListForumCommentsResponse {
|
||||
repeated ForumCommentNode items = 1;
|
||||
PageResponse page = 2;
|
||||
}
|
||||
|
||||
message CreateForumCommentRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
string content = 3;
|
||||
uint64 parent_comment_id = 4;
|
||||
string idempotency_key = 5;
|
||||
}
|
||||
|
||||
message CreateForumCommentResponse {
|
||||
ForumCommentNode comment = 1;
|
||||
}
|
||||
|
||||
message DeleteForumCommentRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 comment_id = 2;
|
||||
}
|
||||
|
||||
message DeleteForumCommentResponse {
|
||||
uint64 comment_id = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message ImportForumPostRequest {
|
||||
uint64 actor_user_id = 1;
|
||||
uint64 post_id = 2;
|
||||
string target_title = 3;
|
||||
string idempotency_key = 4;
|
||||
}
|
||||
|
||||
message ImportForumPostResponse {
|
||||
uint64 import_id = 1;
|
||||
uint64 post_id = 2;
|
||||
uint64 new_task_class_id = 3;
|
||||
string task_class_title = 4;
|
||||
int64 import_count = 5;
|
||||
string created_at = 6;
|
||||
}
|
||||
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