feat: 接入计划广场后端主链路
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router"
|
gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router"
|
||||||
|
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
||||||
gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth"
|
gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth"
|
||||||
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
||||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||||
@@ -73,6 +74,7 @@ type appRuntime struct {
|
|||||||
limiter *pkg.RateLimiter
|
limiter *pkg.RateLimiter
|
||||||
handlers *api.ApiHandlers
|
handlers *api.ApiHandlers
|
||||||
userAuthClient *gatewayuserauth.Client
|
userAuthClient *gatewayuserauth.Client
|
||||||
|
taskClassForumClient *gatewaytaskclassforum.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 锻炼?
|
// loadConfig 锻炼?
|
||||||
@@ -215,6 +217,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize userauth zrpc client: %w", err)
|
return nil, fmt.Errorf("failed to initialize userauth zrpc client: %w", err)
|
||||||
}
|
}
|
||||||
|
taskClassForumClient, err := gatewaytaskclassforum.NewClient(gatewaytaskclassforum.ClientConfig{
|
||||||
|
Endpoints: viper.GetStringSlice("taskclassforum.rpc.endpoints"),
|
||||||
|
Target: viper.GetString("taskclassforum.rpc.target"),
|
||||||
|
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err)
|
||||||
|
}
|
||||||
taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus)
|
taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus)
|
||||||
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
|
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
|
||||||
courseService := buildCourseService(llmService, courseRepo, scheduleRepo)
|
courseService := buildCourseService(llmService, courseRepo, scheduleRepo)
|
||||||
@@ -324,6 +334,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
handlers: handlers,
|
handlers: handlers,
|
||||||
userAuthClient: userAuthClient,
|
userAuthClient: userAuthClient,
|
||||||
|
taskClassForumClient: taskClassForumClient,
|
||||||
}
|
}
|
||||||
if runtime.eventBus != nil {
|
if runtime.eventBus != nil {
|
||||||
if err := runtime.registerEventHandlers(); err != nil {
|
if err := runtime.registerEventHandlers(); err != nil {
|
||||||
@@ -904,7 +915,7 @@ func (r *appRuntime) registerEventHandlers() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *appRuntime) startHTTP(ctx context.Context) {
|
func (r *appRuntime) startHTTP(ctx context.Context) {
|
||||||
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.cacheRepo, r.limiter)
|
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.cacheRepo, r.limiter)
|
||||||
gatewayrouter.StartEngine(ctx, router)
|
gatewayrouter.StartEngine(ctx, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
||||||
|
legacydao "github.com/LoveLosita/smartflow/backend/dao"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter"
|
||||||
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc"
|
forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc"
|
||||||
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
@@ -20,10 +22,14 @@ func main() {
|
|||||||
log.Fatalf("failed to connect taskclassforum database: %v", err)
|
log.Fatalf("failed to connect taskclassforum database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 当前阶段只启动计划广场自身 RPC 壳。
|
// 1. 复用同一个 DB 句柄装配 legacy TaskClass DAO,避免本轮抢改 task-class 模块。
|
||||||
// 2. TaskClass legacy adapter 会在第三步业务主链路接入,避免现在抢改 task 模块。
|
// 2. 计划广场只通过快照端口读取和创建 TaskClass,不直接写 schedule。
|
||||||
// 3. 未实现的业务方法会明确返回 Unimplemented,而不是伪装成可用能力。
|
// 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。
|
||||||
svc := forumsv.New(forumsv.Options{DB: db})
|
taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db))
|
||||||
|
svc := forumsv.New(forumsv.Options{
|
||||||
|
DB: db,
|
||||||
|
TaskClassPort: taskClassPort,
|
||||||
|
})
|
||||||
forumrpc.Start(forumrpc.ServerOptions{
|
forumrpc.Start(forumrpc.ServerOptions{
|
||||||
ListenOn: viper.GetString("taskclassforum.rpc.listenOn"),
|
ListenOn: viper.GetString("taskclassforum.rpc.listenOn"),
|
||||||
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ userauth:
|
|||||||
- "127.0.0.1:9081"
|
- "127.0.0.1:9081"
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
|
|
||||||
|
# 计划广场 zrpc 独立服务与网关客户端配置。
|
||||||
|
taskclassforum:
|
||||||
|
rpc:
|
||||||
|
listenOn: "0.0.0.0:9082"
|
||||||
|
endpoints:
|
||||||
|
- "127.0.0.1:9082"
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
# Kafka outbox 事件总线配置。
|
# Kafka outbox 事件总线配置。
|
||||||
kafka:
|
kafka:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
429
backend/gateway/forumapi/handler.go
Normal file
429
backend/gateway/forumapi/handler.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
package forumapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
type ForumClient interface {
|
||||||
|
ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error)
|
||||||
|
ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error)
|
||||||
|
CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error)
|
||||||
|
GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error)
|
||||||
|
LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||||
|
UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||||
|
ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error)
|
||||||
|
CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error)
|
||||||
|
DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error)
|
||||||
|
ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
client ForumClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(client ForumClient) *Handler {
|
||||||
|
return &Handler{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageEnvelope[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type interactionEnvelope struct {
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Liked bool `json:"liked"`
|
||||||
|
LikeCount int64 `json:"like_count"`
|
||||||
|
RewardHint *rewardHint `json:"reward_hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardHint struct {
|
||||||
|
Receiver string `json:"receiver"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nextAction struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importEnvelope struct {
|
||||||
|
ImportID uint64 `json:"import_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
NewTaskClassID uint64 `json:"new_task_class_id"`
|
||||||
|
TaskClassTitle string `json:"task_class_title"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
RewardHint rewardHint `json:"reward_hint"`
|
||||||
|
NextAction nextAction `json:"next_action"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteCommentEnvelope struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPostBody struct {
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createCommentBody struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importPostBody struct {
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPosts(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, page, err := client.ListPosts(
|
||||||
|
ctx,
|
||||||
|
currentUserID(c),
|
||||||
|
pageValue,
|
||||||
|
pageSize,
|
||||||
|
c.Query("sort"),
|
||||||
|
c.Query("keyword"),
|
||||||
|
c.Query("tag"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListTags(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
limit, ok := intQuery(c, "limit")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := client.ListTags(ctx, currentUserID(c), limit)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createPostBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
post, err := client.CreatePost(ctx, contracts.CreateForumPostRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
TaskClassID: body.TaskClassID,
|
||||||
|
Title: body.Title,
|
||||||
|
Summary: body.Summary,
|
||||||
|
Tags: append([]string(nil), body.Tags...),
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, post))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
detail, err := client.GetPost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LikePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
counters, state, err := client.LikePost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||||
|
PostID: postID,
|
||||||
|
Liked: state.Liked,
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
RewardHint: &rewardHint{
|
||||||
|
Receiver: "author",
|
||||||
|
Status: "recorded",
|
||||||
|
Amount: 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnlikePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
counters, state, err := client.UnlikePost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||||
|
PostID: postID,
|
||||||
|
Liked: state.Liked,
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListComments(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, page, err := client.ListComments(ctx, currentUserID(c), postID, pageValue, pageSize, c.Query("sort"))
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateComment(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createCommentBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
comment, err := client.CreateComment(ctx, contracts.CreateForumCommentRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
PostID: postID,
|
||||||
|
Content: body.Content,
|
||||||
|
ParentCommentID: body.ParentCommentID,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, comment))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteComment(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentID, ok := uint64Param(c, "comment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := client.DeleteComment(ctx, currentUserID(c), commentID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, deleteCommentEnvelope{
|
||||||
|
CommentID: result.CommentID,
|
||||||
|
Status: result.Status,
|
||||||
|
Content: result.Content,
|
||||||
|
DeletedAt: result.DeletedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportPost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body importPostBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
result, err := client.ImportPost(ctx, contracts.ImportForumPostRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
PostID: postID,
|
||||||
|
TargetTitle: body.TargetTitle,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, importEnvelope{
|
||||||
|
ImportID: result.ImportID,
|
||||||
|
PostID: result.PostID,
|
||||||
|
NewTaskClassID: result.NewTaskClassID,
|
||||||
|
TaskClassTitle: result.TaskClassTitle,
|
||||||
|
ImportCount: result.ImportCount,
|
||||||
|
RewardHint: rewardHint{
|
||||||
|
Receiver: "author",
|
||||||
|
Status: "recorded",
|
||||||
|
Amount: 2,
|
||||||
|
},
|
||||||
|
NextAction: nextAction{
|
||||||
|
Type: "open_task_class",
|
||||||
|
TaskClassID: result.NewTaskClassID,
|
||||||
|
},
|
||||||
|
CreatedAt: result.CreatedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ready(c *gin.Context) (ForumClient, bool) {
|
||||||
|
if h == nil || h.client == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场 gateway client 未初始化")))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return h.client, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserID(c *gin.Context) uint64 {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
if userID <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPageEnvelope[T any](items []T, page contracts.PageResult) pageEnvelope[T] {
|
||||||
|
return pageEnvelope[T]{
|
||||||
|
Items: items,
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: page.Total,
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intQuery(c *gin.Context, key string) (int, bool) {
|
||||||
|
raw := strings.TrimSpace(c.Query(key))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64Param(c *gin.Context, key string) (uint64, bool) {
|
||||||
|
value, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64)
|
||||||
|
if err != nil || value == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
87
backend/gateway/forumapi/routes.go
Normal file
87
backend/gateway/forumapi/routes.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package forumapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes 把计划广场 HTTP 入口挂到 gateway 路由组。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只注册 /plan-square 下的边缘路由,不承载论坛业务规则;
|
||||||
|
// 2. 公开读接口允许匿名访问,若携带 token 则补齐 viewer_state;
|
||||||
|
// 3. 写接口必须登录,并按既有 Redis 幂等中间件保护重复提交。
|
||||||
|
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *pkg.RateLimiter) {
|
||||||
|
if apiGroup == nil || handler == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
planSquare := apiGroup.Group("/plan-square")
|
||||||
|
{
|
||||||
|
publicGroup := planSquare.Group("")
|
||||||
|
publicGroup.Use(optionalJWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 40, 1))
|
||||||
|
publicGroup.GET("/posts", handler.ListPosts)
|
||||||
|
publicGroup.GET("/tags", handler.ListTags)
|
||||||
|
publicGroup.GET("/posts/:post_id", handler.GetPost)
|
||||||
|
publicGroup.GET("/posts/:post_id/comments", handler.ListComments)
|
||||||
|
|
||||||
|
writeGroup := planSquare.Group("")
|
||||||
|
writeGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||||
|
writeGroup.POST("/posts", rootmiddleware.IdempotencyMiddleware(cache), handler.CreatePost)
|
||||||
|
writeGroup.POST("/posts/:post_id/like", handler.LikePost)
|
||||||
|
writeGroup.DELETE("/posts/:post_id/like", handler.UnlikePost)
|
||||||
|
writeGroup.POST("/posts/:post_id/comments", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateComment)
|
||||||
|
writeGroup.DELETE("/comments/:comment_id", handler.DeleteComment)
|
||||||
|
writeGroup.POST("/posts/:post_id/import", rootmiddleware.IdempotencyMiddleware(cache), handler.ImportPost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalJWTTokenAuth 为计划广场公开读接口提供“可登录增强”。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 没有 Authorization 时直接放行,让匿名用户也能浏览计划广场;
|
||||||
|
// 2. 有 Authorization 时复用 user/auth 校验,并把 user_id 写入上下文;
|
||||||
|
// 3. token 非法时按正常鉴权失败返回,避免前端误以为已登录状态仍可用。
|
||||||
|
func optionalJWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tokenString := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||||
|
if tokenString == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if validator == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场可选鉴权依赖未初始化")))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := validator.ValidateAccessToken(ctx, tokenString)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil || !resp.Valid || resp.UserID <= 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user_id", resp.UserID)
|
||||||
|
c.Set("claims", resp)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/api"
|
"github.com/LoveLosita/smartflow/backend/api"
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/gateway/forumapi"
|
||||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
||||||
"github.com/LoveLosita/smartflow/backend/gateway/userapi"
|
"github.com/LoveLosita/smartflow/backend/gateway/userapi"
|
||||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||||
@@ -55,7 +57,7 @@ func StartEngine(ctx context.Context, r *gin.Engine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine {
|
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
apiGroup := r.Group("/api/v1")
|
apiGroup := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
@@ -67,6 +69,7 @@ func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient,
|
|||||||
})
|
})
|
||||||
|
|
||||||
userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter)
|
userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter)
|
||||||
|
forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter)
|
||||||
|
|
||||||
taskGroup := apiGroup.Group("/task")
|
taskGroup := apiGroup.Group("/task")
|
||||||
{
|
{
|
||||||
|
|||||||
470
backend/gateway/taskclassforum/client.go
Normal file
470
backend/gateway/taskclassforum/client.go
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
package taskclassforum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||||
|
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultEndpoint = "127.0.0.1:9082"
|
||||||
|
defaultTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientConfig struct {
|
||||||
|
Endpoints []string
|
||||||
|
Target string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 是 gateway 侧访问计划广场 zrpc 的适配层。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责 HTTP gateway 与 taskclassforum zrpc 之间的协议转译;
|
||||||
|
// 2. 不直连 forum_* 表,也不读取旧 TaskClass 表,所有业务规则交给 taskclassforum 服务;
|
||||||
|
// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。
|
||||||
|
type Client struct {
|
||||||
|
rpc pb.TaskClassForumServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
endpoints := normalizeEndpoints(cfg.Endpoints)
|
||||||
|
target := strings.TrimSpace(cfg.Target)
|
||||||
|
if len(endpoints) == 0 && target == "" {
|
||||||
|
endpoints = []string{defaultEndpoint}
|
||||||
|
}
|
||||||
|
|
||||||
|
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
|
||||||
|
Endpoints: endpoints,
|
||||||
|
Target: target,
|
||||||
|
NonBlock: true,
|
||||||
|
Timeout: int64(timeout / time.Millisecond),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Client{rpc: pb.NewTaskClassForumServiceClient(zclient.Conn())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, contracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.ListPosts(ctx, &pb.ListForumPostsRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
Page: int32(page),
|
||||||
|
PageSize: int32(pageSize),
|
||||||
|
Sort: sort,
|
||||||
|
Keyword: keyword,
|
||||||
|
Tag: tag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, contracts.PageResult{}, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list posts response")
|
||||||
|
}
|
||||||
|
return forumPostBriefsFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.ListTags(ctx, &pb.ListForumTagsRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty list tags response")
|
||||||
|
}
|
||||||
|
return forumTagItemsFromPB(resp.Items), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.CreatePost(ctx, &pb.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, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty create post response")
|
||||||
|
}
|
||||||
|
post := forumPostBriefFromPB(resp.Post)
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.GetPost(ctx, &pb.GetForumPostRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
PostId: postID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty get post response")
|
||||||
|
}
|
||||||
|
data := forumPostDetailFromPB(resp.Data)
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.LikePost(ctx, &pb.LikeForumPostRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
PostId: postID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty like response")
|
||||||
|
}
|
||||||
|
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.UnlikePost(ctx, &pb.UnlikeForumPostRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
PostId: postID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty unlike response")
|
||||||
|
}
|
||||||
|
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, contracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.ListComments(ctx, &pb.ListForumCommentsRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
PostId: postID,
|
||||||
|
Page: int32(page),
|
||||||
|
PageSize: int32(pageSize),
|
||||||
|
Sort: sort,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, contracts.PageResult{}, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list comments response")
|
||||||
|
}
|
||||||
|
return forumCommentNodesFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.CreateComment(ctx, &pb.CreateForumCommentRequest{
|
||||||
|
ActorUserId: req.ActorUserID,
|
||||||
|
PostId: req.PostID,
|
||||||
|
Content: req.Content,
|
||||||
|
ParentCommentId: uint64FromPtr(req.ParentCommentID),
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty create comment response")
|
||||||
|
}
|
||||||
|
comment := forumCommentNodeFromPB(resp.Comment)
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.DeleteComment(ctx, &pb.DeleteForumCommentRequest{
|
||||||
|
ActorUserId: actorUserID,
|
||||||
|
CommentId: commentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty delete comment response")
|
||||||
|
}
|
||||||
|
deletedAt := time.Now().Format(time.RFC3339)
|
||||||
|
return &contracts.DeleteForumCommentResult{
|
||||||
|
CommentID: resp.CommentId,
|
||||||
|
Status: resp.Status,
|
||||||
|
Content: "",
|
||||||
|
DeletedAt: &deletedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error) {
|
||||||
|
if err := c.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.rpc.ImportPost(ctx, &pb.ImportForumPostRequest{
|
||||||
|
ActorUserId: req.ActorUserID,
|
||||||
|
PostId: req.PostID,
|
||||||
|
TargetTitle: req.TargetTitle,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseFromRPCError(err)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("taskclassforum zrpc service returned empty import post response")
|
||||||
|
}
|
||||||
|
return &contracts.ImportForumPostResult{
|
||||||
|
ImportID: resp.ImportId,
|
||||||
|
PostID: resp.PostId,
|
||||||
|
NewTaskClassID: resp.NewTaskClassId,
|
||||||
|
TaskClassTitle: resp.TaskClassTitle,
|
||||||
|
ImportCount: resp.ImportCount,
|
||||||
|
CreatedAt: resp.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureReady() error {
|
||||||
|
if c == nil || c.rpc == nil {
|
||||||
|
return errors.New("taskclassforum zrpc client is not initialized")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEndpoints(values []string) []string {
|
||||||
|
endpoints := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
endpoints = append(endpoints, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageFromPB(page *pb.PageResponse) contracts.PageResult {
|
||||||
|
if page == nil {
|
||||||
|
return contracts.PageResult{}
|
||||||
|
}
|
||||||
|
return contracts.PageResult{
|
||||||
|
Page: int(page.Page),
|
||||||
|
PageSize: int(page.PageSize),
|
||||||
|
Total: int(page.Total),
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUserFromPB(user *pb.UserBrief) contracts.UserBrief {
|
||||||
|
if user == nil {
|
||||||
|
return contracts.UserBrief{}
|
||||||
|
}
|
||||||
|
return contracts.UserBrief{
|
||||||
|
UserID: user.UserId,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
AvatarURL: user.AvatarUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateSummaryFromPB(summary *pb.TemplateSummary) contracts.TemplateSummary {
|
||||||
|
if summary == nil {
|
||||||
|
return contracts.TemplateSummary{}
|
||||||
|
}
|
||||||
|
return contracts.TemplateSummary{
|
||||||
|
TaskCount: int(summary.TaskCount),
|
||||||
|
Mode: summary.Mode,
|
||||||
|
StartDate: summary.StartDate,
|
||||||
|
EndDate: summary.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostCountersFromPB(counters *pb.ForumPostCounters) contracts.ForumPostCounters {
|
||||||
|
if counters == nil {
|
||||||
|
return contracts.ForumPostCounters{}
|
||||||
|
}
|
||||||
|
return contracts.ForumPostCounters{
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
CommentCount: counters.CommentCount,
|
||||||
|
ImportCount: counters.ImportCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostViewerStateFromPB(state *pb.ForumPostViewerState) contracts.ForumPostViewerState {
|
||||||
|
if state == nil {
|
||||||
|
return contracts.ForumPostViewerState{}
|
||||||
|
}
|
||||||
|
return contracts.ForumPostViewerState{
|
||||||
|
Liked: state.Liked,
|
||||||
|
ImportedOnce: state.ImportedOnce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefFromPB(post *pb.ForumPostBrief) contracts.ForumPostBrief {
|
||||||
|
if post == nil {
|
||||||
|
return contracts.ForumPostBrief{}
|
||||||
|
}
|
||||||
|
return contracts.ForumPostBrief{
|
||||||
|
PostID: post.PostId,
|
||||||
|
Title: post.Title,
|
||||||
|
Summary: post.Summary,
|
||||||
|
Tags: append([]string(nil), post.Tags...),
|
||||||
|
Author: forumUserFromPB(post.Author),
|
||||||
|
TemplateSummary: forumTemplateSummaryFromPB(post.TemplateSummary),
|
||||||
|
Counters: forumPostCountersFromPB(post.Counters),
|
||||||
|
ViewerState: forumPostViewerStateFromPB(post.ViewerState),
|
||||||
|
Status: post.Status,
|
||||||
|
CreatedAt: post.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefsFromPB(items []*pb.ForumPostBrief) []contracts.ForumPostBrief {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []contracts.ForumPostBrief{}
|
||||||
|
}
|
||||||
|
result := make([]contracts.ForumPostBrief, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
result = append(result, forumPostBriefFromPB(item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateDetailFromPB(detail *pb.TemplateDetail) contracts.TemplateDetail {
|
||||||
|
if detail == nil {
|
||||||
|
return contracts.TemplateDetail{}
|
||||||
|
}
|
||||||
|
items := make([]contracts.TemplateItemPreview, 0, len(detail.ItemsPreview))
|
||||||
|
for _, item := range detail.ItemsPreview {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, contracts.TemplateItemPreview{
|
||||||
|
ItemID: item.ItemId,
|
||||||
|
Order: int(item.Order),
|
||||||
|
Content: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return contracts.TemplateDetail{
|
||||||
|
Mode: detail.Mode,
|
||||||
|
StartDate: detail.StartDate,
|
||||||
|
EndDate: detail.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
|
||||||
|
TaskCount: int(detail.TaskCount),
|
||||||
|
ItemsPreview: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostDetailFromPB(detail *pb.ForumPostDetail) contracts.ForumPostDetail {
|
||||||
|
if detail == nil {
|
||||||
|
return contracts.ForumPostDetail{}
|
||||||
|
}
|
||||||
|
return contracts.ForumPostDetail{
|
||||||
|
Post: forumPostBriefFromPB(detail.Post),
|
||||||
|
Template: forumTemplateDetailFromPB(detail.Template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTagItemsFromPB(items []*pb.ForumTagItem) []contracts.ForumTagItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []contracts.ForumTagItem{}
|
||||||
|
}
|
||||||
|
result := make([]contracts.ForumTagItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, contracts.ForumTagItem{
|
||||||
|
Tag: item.Tag,
|
||||||
|
PostCount: int(item.PostCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodeFromPB(node *pb.ForumCommentNode) contracts.ForumCommentNode {
|
||||||
|
if node == nil {
|
||||||
|
return contracts.ForumCommentNode{}
|
||||||
|
}
|
||||||
|
children := make([]contracts.ForumCommentNode, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
children = append(children, forumCommentNodeFromPB(child))
|
||||||
|
}
|
||||||
|
return contracts.ForumCommentNode{
|
||||||
|
CommentID: node.CommentId,
|
||||||
|
PostID: node.PostId,
|
||||||
|
ParentCommentID: uint64PtrFromPositive(node.ParentCommentId),
|
||||||
|
Content: node.Content,
|
||||||
|
Status: node.Status,
|
||||||
|
Author: forumUserFromPB(node.Author),
|
||||||
|
CanDelete: node.CanDelete,
|
||||||
|
CreatedAt: node.CreatedAt,
|
||||||
|
DeletedAt: stringPtrFromNonEmpty(node.DeletedAt),
|
||||||
|
Children: children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodesFromPB(items []*pb.ForumCommentNode) []contracts.ForumCommentNode {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []contracts.ForumCommentNode{}
|
||||||
|
}
|
||||||
|
result := make([]contracts.ForumCommentNode, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
result = append(result, forumCommentNodeFromPB(item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64FromPtr(value *uint64) uint64 {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64PtrFromPositive(value uint64) *uint64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtrFromNonEmpty(value string) *string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
94
backend/gateway/taskclassforum/errors.go
Normal file
94
backend/gateway/taskclassforum/errors.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package taskclassforum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// responseFromRPCError 把计划广场 zrpc 错误恢复成 HTTP 层可处理的业务错误。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 优先读取 taskclassforum RPC 写入的 ErrorInfo,恢复 respond.Response;
|
||||||
|
// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error,让 HTTP 层按 500 处理;
|
||||||
|
// 3. 暂不复用 userauth/errors.go,因为 user/auth 还承担历史 legacy code 兼容,计划广场只消费新 ErrorInfo 协议。
|
||||||
|
func responseFromRPCError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return wrapRPCError(err)
|
||||||
|
}
|
||||||
|
if resp, ok := responseFromStatusDetails(st); ok {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
switch st.Code() {
|
||||||
|
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||||||
|
msg := strings.TrimSpace(st.Message())
|
||||||
|
if msg == "" {
|
||||||
|
msg = "taskclassforum zrpc service internal error"
|
||||||
|
}
|
||||||
|
return wrapRPCError(errors.New(msg))
|
||||||
|
case codes.NotFound:
|
||||||
|
return responseWithFallback(st, respond.UserTaskClassNotFound)
|
||||||
|
case codes.PermissionDenied, codes.Unauthenticated:
|
||||||
|
return responseWithFallback(st, respond.ErrUnauthorized)
|
||||||
|
case codes.InvalidArgument:
|
||||||
|
return responseWithFallback(st, respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.TrimSpace(st.Message())
|
||||||
|
if msg == "" {
|
||||||
|
msg = "taskclassforum zrpc service rejected request"
|
||||||
|
}
|
||||||
|
return respond.Response{Status: "400", Info: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFromStatusDetails(st *status.Status) (respond.Response, bool) {
|
||||||
|
if st == nil {
|
||||||
|
return respond.Response{}, false
|
||||||
|
}
|
||||||
|
for _, detail := range st.Details() {
|
||||||
|
info, ok := detail.(*errdetails.ErrorInfo)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
statusValue := strings.TrimSpace(info.Reason)
|
||||||
|
if statusValue == "" {
|
||||||
|
return respond.Response{}, false
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(st.Message())
|
||||||
|
if message == "" && info.Metadata != nil {
|
||||||
|
message = strings.TrimSpace(info.Metadata["info"])
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = statusValue
|
||||||
|
}
|
||||||
|
return respond.Response{Status: statusValue, Info: message}, true
|
||||||
|
}
|
||||||
|
return respond.Response{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseWithFallback(st *status.Status, fallback respond.Response) respond.Response {
|
||||||
|
msg := strings.TrimSpace(st.Message())
|
||||||
|
if msg == "" {
|
||||||
|
msg = fallback.Info
|
||||||
|
}
|
||||||
|
return respond.Response{Status: fallback.Status, Info: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapRPCError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("调用 taskclassforum zrpc 服务失败: %w", err)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
@@ -39,7 +40,8 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetInt("user_id") // 假设 JWT 已存入
|
userID := c.GetInt("user_id") // 假设 JWT 已存入
|
||||||
redisKey := fmt.Sprintf("idempotency:%d:%s", userID, ikey)
|
routeKey := idempotencyRouteKey(c)
|
||||||
|
redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey)
|
||||||
|
|
||||||
// 2. 查 Redis 缓存
|
// 2. 查 Redis 缓存
|
||||||
cachedData, err := cache.GetRecord(c, redisKey)
|
cachedData, err := cache.GetRecord(c, redisKey)
|
||||||
@@ -94,3 +96,14 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func idempotencyRouteKey(c *gin.Context) string {
|
||||||
|
// 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。
|
||||||
|
// 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。
|
||||||
|
// 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。
|
||||||
|
route := strings.TrimSpace(c.FullPath())
|
||||||
|
if route == "" && c.Request != nil && c.Request.URL != nil {
|
||||||
|
route = strings.TrimSpace(c.Request.URL.Path)
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(route, ":", "_")
|
||||||
|
}
|
||||||
|
|||||||
448
backend/services/taskclassforum/adapter/legacy_taskclass.go
Normal file
448
backend/services/taskclassforum/adapter/legacy_taskclass.go
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
legacydao "github.com/LoveLosita/smartflow/backend/dao"
|
||||||
|
legacymodel "github.com/LoveLosita/smartflow/backend/model"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyTaskClassDateLayout = "2006-01-02"
|
||||||
|
|
||||||
|
var errLegacyTaskClassAdapterNotReady = errors.New("taskclassforum legacy taskclass adapter is not initialized")
|
||||||
|
|
||||||
|
// LegacyTaskClassAdapter 负责把旧 task-class DAO 适配成计划广场需要的快照端口。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只复用旧 TaskClassDAO 读写 task_classes / task_items;
|
||||||
|
// 2. 只产出/消费 TaskClass 白名单快照,不透传 embedded_time 和任何 schedule 绑定;
|
||||||
|
// 3. 不承载论坛帖子、模板、导入记录事务,这些仍由 taskclassforum service 编排。
|
||||||
|
type LegacyTaskClassAdapter struct {
|
||||||
|
taskClassDAO *legacydao.TaskClassDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ forumsv.TaskClassSnapshotPort = (*LegacyTaskClassAdapter)(nil)
|
||||||
|
|
||||||
|
// NewLegacyTaskClassAdapter 创建 legacy TaskClass 适配器。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只做依赖注入,不主动探活数据库;
|
||||||
|
// 2. 不创建 DAO 以外的额外资源;
|
||||||
|
// 3. 若传入 nil,真正报错延后到方法调用时返回,便于上层统一做依赖检查。
|
||||||
|
func NewLegacyTaskClassAdapter(taskClassDAO *legacydao.TaskClassDAO) *LegacyTaskClassAdapter {
|
||||||
|
return &LegacyTaskClassAdapter{
|
||||||
|
taskClassDAO: taskClassDAO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOwnedTaskClassSnapshot 读取当前用户自己的旧 TaskClass,并投影为论坛可分享快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只读取 user_id 归属下的单个 TaskClass;
|
||||||
|
// 2. 只返回白名单字段与条目 source id/order/content;
|
||||||
|
// 3. 不返回 embedded_time、schedule 绑定和其他用户私有排程状态。
|
||||||
|
func (a *LegacyTaskClassAdapter) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyTaskClass, err := a.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassIDInt, userIDInt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, normalizeLegacyTaskClassLookupError(err)
|
||||||
|
}
|
||||||
|
if legacyTaskClass == nil {
|
||||||
|
return nil, respond.UserTaskClassNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := snapshotFromLegacyTaskClass(*legacyTaskClass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskClassFromSnapshot 基于论坛模板快照为当前用户创建旧 TaskClass 副本。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只创建 task_classes / task_items 副本,不写 forum_imports;
|
||||||
|
// 2. 只写白名单字段,所有新建 item 都强制重置为未安排状态;
|
||||||
|
// 3. 不保留原始 item ID,避免误触旧 DAO 的“更新已有记录”分支。
|
||||||
|
func (a *LegacyTaskClassAdapter) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate, endDate, err := parseSnapshotDateRange(snapshot.Mode, snapshot.StartDate, snapshot.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
createTaskClass := buildLegacyTaskClassModel(title, snapshot, userIDInt, startDate, endDate)
|
||||||
|
createItems := buildLegacyTaskClassItems(snapshot.Items)
|
||||||
|
if len(createItems) == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
created := &forumsv.CreatedTaskClass{
|
||||||
|
Title: title,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先在旧 DAO 事务里创建 task_class 主记录,拿到新主键。
|
||||||
|
// 2. 再把所有快照条目改写成“当前用户的新副本条目”,统一挂到新主键下。
|
||||||
|
// 3. 任一步失败都回滚,避免出现“有主表、没子项”的半写状态。
|
||||||
|
if err := a.taskClassDAO.Transaction(func(txDAO *legacydao.TaskClassDAO) error {
|
||||||
|
taskClassID, txErr := txDAO.AddOrUpdateTaskClass(userIDInt, createTaskClass)
|
||||||
|
if txErr != nil {
|
||||||
|
return txErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range createItems {
|
||||||
|
createItems[i].CategoryID = intPtr(taskClassID)
|
||||||
|
}
|
||||||
|
if txErr := txDAO.AddOrUpdateTaskClassItems(userIDInt, createItems); txErr != nil {
|
||||||
|
return txErr
|
||||||
|
}
|
||||||
|
|
||||||
|
created.TaskClassID = uint64(taskClassID)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotFromLegacyTaskClass 把旧 TaskClass 模型转换成论坛白名单快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责字段投影与默认值归一化;
|
||||||
|
// 2. 负责过滤 embedded_time,只保留条目 source id/order/content;
|
||||||
|
// 3. 负责生成与论坛模板同口径的 ConfigSnapshotJSON。
|
||||||
|
func snapshotFromLegacyTaskClass(taskClass legacymodel.TaskClass) (forumsv.TaskClassSnapshot, error) {
|
||||||
|
items := snapshotItemsFromLegacyItems(taskClass.Items)
|
||||||
|
snapshot := forumsv.TaskClassSnapshot{
|
||||||
|
TaskClassID: uint64(taskClass.ID),
|
||||||
|
Title: stringValue(taskClass.Name),
|
||||||
|
Mode: stringValue(taskClass.Mode),
|
||||||
|
StartDate: formatDate(taskClass.StartDate),
|
||||||
|
EndDate: formatDate(taskClass.EndDate),
|
||||||
|
SubjectType: stringValue(taskClass.SubjectType),
|
||||||
|
DifficultyLevel: stringValue(taskClass.DifficultyLevel),
|
||||||
|
CognitiveIntensity: stringValue(taskClass.CognitiveIntensity),
|
||||||
|
TotalSlots: intValue(taskClass.TotalSlots),
|
||||||
|
AllowFillerCourse: boolValue(taskClass.AllowFillerCourse),
|
||||||
|
Strategy: stringValue(taskClass.Strategy),
|
||||||
|
ExcludedSlots: cloneIntSlice([]int(taskClass.ExcludedSlots)),
|
||||||
|
ExcludedDaysOfWeek: cloneIntSlice([]int(taskClass.ExcludedDaysOfWeek)),
|
||||||
|
StrategyLabels: legacyStrategyLabels(stringValue(taskClass.Strategy)),
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := buildConfigSnapshotJSON(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return forumsv.TaskClassSnapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot.ConfigSnapshotJSON = configJSON
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotItemsFromLegacyItems 过滤旧 task_items 的可分享字段。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只保留 source id、order、content;
|
||||||
|
// 2. 不复制 embedded_time、status 等用户私有排程状态;
|
||||||
|
// 3. 输出前按 order、source id 做稳定排序,保证论坛快照可重复。
|
||||||
|
func snapshotItemsFromLegacyItems(items []legacymodel.TaskClassItem) []forumsv.TaskClassSnapshotItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []forumsv.TaskClassSnapshotItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := append([]legacymodel.TaskClassItem(nil), items...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
leftOrder := intValue(sorted[i].Order)
|
||||||
|
rightOrder := intValue(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 {
|
||||||
|
result = append(result, forumsv.TaskClassSnapshotItem{
|
||||||
|
TaskItemID: uint64(item.ID),
|
||||||
|
Order: intValue(item.Order),
|
||||||
|
Content: stringValue(item.Content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLegacyTaskClassModel 把论坛快照转换成旧 task_classes 主表模型。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责主表字段映射;
|
||||||
|
// 2. 不负责 items 生成;
|
||||||
|
// 3. 不负责事务提交,调用方必须交给 DAO.Transaction 执行。
|
||||||
|
func buildLegacyTaskClassModel(title string, snapshot forumsv.TaskClassSnapshot, userID int, startDate *time.Time, endDate *time.Time) *legacymodel.TaskClass {
|
||||||
|
totalSlots := snapshot.TotalSlots
|
||||||
|
allowFillerCourse := snapshot.AllowFillerCourse
|
||||||
|
mode := strings.TrimSpace(snapshot.Mode)
|
||||||
|
strategy := strings.TrimSpace(snapshot.Strategy)
|
||||||
|
|
||||||
|
return &legacymodel.TaskClass{
|
||||||
|
UserID: intPtr(userID),
|
||||||
|
Name: stringPtr(strings.TrimSpace(title)),
|
||||||
|
Mode: stringPtr(mode),
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
SubjectType: optionalStringPtr(snapshot.SubjectType),
|
||||||
|
DifficultyLevel: optionalStringPtr(snapshot.DifficultyLevel),
|
||||||
|
CognitiveIntensity: optionalStringPtr(snapshot.CognitiveIntensity),
|
||||||
|
TotalSlots: &totalSlots,
|
||||||
|
AllowFillerCourse: &allowFillerCourse,
|
||||||
|
Strategy: optionalStringPtr(strategy),
|
||||||
|
ExcludedSlots: legacymodel.IntSlice(cloneIntSlice(snapshot.ExcludedSlots)),
|
||||||
|
ExcludedDaysOfWeek: legacymodel.IntSlice(cloneIntSlice(snapshot.ExcludedDaysOfWeek)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLegacyTaskClassItems 把论坛快照条目改写成旧 task_items 待创建模型。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只构造“新建 item”模型,因此 ID 固定为 0;
|
||||||
|
// 2. 强制清空 EmbeddedTime,并把状态写成未安排;
|
||||||
|
// 3. 跳过纯空白内容,避免把无意义条目写回旧表。
|
||||||
|
func buildLegacyTaskClassItems(snapshotItems []forumsv.TaskClassSnapshotItem) []legacymodel.TaskClassItem {
|
||||||
|
if len(snapshotItems) == 0 {
|
||||||
|
return []legacymodel.TaskClassItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := append([]forumsv.TaskClassSnapshotItem(nil), snapshotItems...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
if sorted[i].Order != sorted[j].Order {
|
||||||
|
return sorted[i].Order < sorted[j].Order
|
||||||
|
}
|
||||||
|
return sorted[i].TaskItemID < sorted[j].TaskItemID
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]legacymodel.TaskClassItem, 0, len(sorted))
|
||||||
|
for _, item := range sorted {
|
||||||
|
if strings.TrimSpace(item.Content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
order := item.Order
|
||||||
|
content := item.Content
|
||||||
|
status := legacymodel.TaskItemStatusUnscheduled
|
||||||
|
|
||||||
|
result = append(result, legacymodel.TaskClassItem{
|
||||||
|
Order: &order,
|
||||||
|
Content: &content,
|
||||||
|
EmbeddedTime: nil,
|
||||||
|
Status: &status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSnapshotDateRange 解析论坛快照中的日期范围。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责 2006-01-02 格式解析;
|
||||||
|
// 2. 只在 mode=auto 时执行起止日期必填和先后顺序校验;
|
||||||
|
// 3. 不负责校验节次、星期等其他业务规则。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConfigSnapshotJSON 生成论坛模板沿用的配置白名单 JSON。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只序列化配置白名单字段;
|
||||||
|
// 2. 不写 items、embedded_time、schedule 相关数据;
|
||||||
|
// 3. 输出键名保持和 taskclassforum 发布链路一致,避免模板口径漂移。
|
||||||
|
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": cloneStringSlice(snapshot.StrategyLabels),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LegacyTaskClassAdapter) ensureReady() error {
|
||||||
|
if a == nil || a.taskClassDAO == nil {
|
||||||
|
return errLegacyTaskClassAdapterNotReady
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLegacyTaskClassLookupError(err error) error {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return respond.UserTaskClassNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDatePtr(value string) (*time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation(legacyTaskClassDateLayout, trimmed, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(value *time.Time) string {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(legacyTaskClassDateLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stringValue(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolValue(value *bool) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyStrategyLabels(strategy string) []string {
|
||||||
|
trimmed := strings.TrimSpace(strategy)
|
||||||
|
if trimmed == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return []string{trimmed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtr(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalStringPtr(value string) *string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIntSlice(values []int) []int {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
return append([]int(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSlice(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return append([]string(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),
|
||||||
|
}
|
||||||
|
}
|
||||||
453
backend/services/taskclassforum/dao/forum.go
Normal file
453
backend/services/taskclassforum/dao/forum.go
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -28,8 +28,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// ForumImportStatusPending 表示导入记录已占位,正在创建 TaskClass 副本。
|
||||||
|
ForumImportStatusPending = "pending"
|
||||||
// ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。
|
// ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。
|
||||||
ForumImportStatusImported = "imported"
|
ForumImportStatusImported = "imported"
|
||||||
|
// ForumImportStatusFailed 表示导入副本创建或最终确认失败,可由后续重试覆盖。
|
||||||
|
ForumImportStatusFailed = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForumPost 是计划广场帖子主体表。
|
// ForumPost 是计划广场帖子主体表。
|
||||||
@@ -40,11 +44,12 @@ const (
|
|||||||
// 3. 计数字段由服务事务内维护,避免列表页每次做聚合统计。
|
// 3. 计数字段由服务事务内维护,避免列表页每次做聚合统计。
|
||||||
type ForumPost struct {
|
type ForumPost struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_posts_author_status,priority:1;comment:作者用户ID"`
|
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,仅用于审计"`
|
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:帖子标题"`
|
Title string `gorm:"column:title;type:varchar(80);not null;comment:帖子标题"`
|
||||||
Summary string `gorm:"column:summary;type:text;comment:帖子简介"`
|
Summary string `gorm:"column:summary;type:text;comment:帖子简介"`
|
||||||
TagsJSON string `gorm:"column:tags_json;type:json;not null;comment:标签JSON数组"`
|
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"`
|
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:点赞数冗余计数"`
|
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:评论数冗余计数"`
|
CommentCount int64 `gorm:"column:comment_count;not null;default:0;comment:评论数冗余计数"`
|
||||||
@@ -166,11 +171,12 @@ type ForumImport struct {
|
|||||||
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:1;index:idx_forum_imports_post;comment:帖子ID"`
|
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"`
|
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,便于奖励和审计"`
|
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_imports_author;comment:帖子作者ID,便于奖励和审计"`
|
||||||
NewTaskClassID uint64 `gorm:"column:new_task_class_id;not null;comment:导入后创建的当前用户TaskClass 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标题"`
|
TargetTitle string `gorm:"column:target_title;type:varchar(80);comment:导入后的TaskClass标题"`
|
||||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'imported';comment:imported"`
|
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"`
|
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:导入请求幂等键"`
|
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:创建时间"`
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/respond"
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
|
||||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -29,9 +28,6 @@ func grpcErrorFromServiceError(err error) error {
|
|||||||
if errors.As(err, &resp) {
|
if errors.As(err, &resp) {
|
||||||
return grpcErrorFromResponse(resp)
|
return grpcErrorFromResponse(resp)
|
||||||
}
|
}
|
||||||
if errors.Is(err, forumsv.ErrNotImplemented) {
|
|
||||||
return status.Error(codes.Unimplemented, err.Error())
|
|
||||||
}
|
|
||||||
log.Printf("taskclassforum rpc internal error: %v", err)
|
log.Printf("taskclassforum rpc internal error: %v", err)
|
||||||
return status.Error(codes.Internal, "taskclassforum service internal error")
|
return status.Error(codes.Internal, "taskclassforum service internal error")
|
||||||
}
|
}
|
||||||
|
|||||||
202
backend/services/taskclassforum/sv/comment.go
Normal file
202
backend/services/taskclassforum/sv/comment.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/commenttree"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListComments 查询评论树。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端;
|
||||||
|
// 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装;
|
||||||
|
// 3. 不做评论缓存,新增、回复、删除后直接读库保持语义简单。
|
||||||
|
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if postID == 0 {
|
||||||
|
return nil, forumcontracts.PageResult{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
page, pageSize = normalizePage(page, pageSize)
|
||||||
|
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := s.forumDAO.CountRootComments(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
roots, err := s.forumDAO.ListRootComments(ctx, postID, page, pageSize, sortBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return []forumcontracts.ForumCommentNode{}, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
|
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID)
|
||||||
|
return nodes, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateComment 创建帖子评论或多层回复。
|
||||||
|
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.ActorUserID == 0 || req.PostID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
if err := validateRuneMax(req.Content, maxCommentLen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
existing, err := s.forumDAO.FindCommentByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return commentModelToNode(*existing, req.ActorUserID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var created forummodel.ForumComment
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
if req.ParentCommentID != nil {
|
||||||
|
parent, err := txDAO.FindCommentByID(ctx, *req.ParentCommentID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||||
|
}
|
||||||
|
if parent.PostID != req.PostID {
|
||||||
|
return respond.MissingParam
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created = forummodel.ForumComment{
|
||||||
|
PostID: req.PostID,
|
||||||
|
ParentCommentID: req.ParentCommentID,
|
||||||
|
UserID: req.ActorUserID,
|
||||||
|
Content: strings.TrimSpace(req.Content),
|
||||||
|
Status: forummodel.ForumCommentStatusVisible,
|
||||||
|
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateComment(ctx, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "comment_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return commentModelToNode(created, req.ActorUserID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment 软删除当前用户自己的评论。
|
||||||
|
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || commentID == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedAt *string
|
||||||
|
status := forummodel.ForumCommentStatusDeleted
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
comment, err := txDAO.LockCommentByID(ctx, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||||
|
}
|
||||||
|
if comment.UserID != actorUserID {
|
||||||
|
return respond.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||||
|
deletedAt = formatTimePtr(comment.DeletedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := txDAO.SoftDeleteComment(ctx, commentID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deletedAt = formatTimePtr(&now)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &forumcontracts.DeleteForumCommentResult{
|
||||||
|
CommentID: commentID,
|
||||||
|
Status: status,
|
||||||
|
Content: "",
|
||||||
|
DeletedAt: deletedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentModelToNode(comment forummodel.ForumComment, actorUserID uint64) *forumcontracts.ForumCommentNode {
|
||||||
|
content := comment.Content
|
||||||
|
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||||
|
content = "该评论已删除"
|
||||||
|
}
|
||||||
|
return &forumcontracts.ForumCommentNode{
|
||||||
|
CommentID: comment.ID,
|
||||||
|
PostID: comment.PostID,
|
||||||
|
ParentCommentID: comment.ParentCommentID,
|
||||||
|
Content: content,
|
||||||
|
Status: comment.Status,
|
||||||
|
Author: userBrief(comment.UserID),
|
||||||
|
CanDelete: comment.Status == forummodel.ForumCommentStatusVisible && comment.UserID == actorUserID,
|
||||||
|
CreatedAt: formatTime(comment.CreatedAt),
|
||||||
|
DeletedAt: formatTimePtr(comment.DeletedAt),
|
||||||
|
Children: []forumcontracts.ForumCommentNode{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCommentsForRoots(allComments []forummodel.ForumComment, roots []forummodel.ForumComment) []forummodel.ForumComment {
|
||||||
|
filtered := make([]forummodel.ForumComment, 0, len(allComments))
|
||||||
|
included := make(map[uint64]struct{}, len(allComments))
|
||||||
|
for _, root := range roots {
|
||||||
|
filtered = append(filtered, root)
|
||||||
|
included[root.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
candidateSet := make(map[uint64]struct{}, len(allComments))
|
||||||
|
for _, root := range roots {
|
||||||
|
collectDescendantCommentIDs(root.ID, allComments, candidateSet)
|
||||||
|
}
|
||||||
|
for _, comment := range allComments {
|
||||||
|
if _, ok := included[comment.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := candidateSet[comment.ID]; ok {
|
||||||
|
filtered = append(filtered, comment)
|
||||||
|
included[comment.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumComment, result map[uint64]struct{}) {
|
||||||
|
for _, comment := range comments {
|
||||||
|
if comment.ParentCommentID == nil || *comment.ParentCommentID != parentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := result[comment.ID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[comment.ID] = struct{}{}
|
||||||
|
collectDescendantCommentIDs(comment.ID, comments, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
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/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
|
||||||
|
}
|
||||||
236
backend/services/taskclassforum/sv/import.go
Normal file
236
backend/services/taskclassforum/sv/import.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 同一用户同一帖子只允许导入一次,由 forum_imports 唯一约束兜底;
|
||||||
|
// 2. 只通过 TaskClassSnapshotPort 创建 TaskClass,不写 schedule;
|
||||||
|
// 3. 只写 forum_imports 和 import_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 importResultFromModel(*existing), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing, err := s.forumDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusImported {
|
||||||
|
return importResultFromModel(*existing), nil
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusFailed && existing.NewTaskClassID != nil {
|
||||||
|
return s.recoverCreatedImport(ctx, req, *existing)
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusPending {
|
||||||
|
return nil, respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
|
||||||
|
post, template, items, err := s.loadPostTemplate(ctx, req.PostID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshot := snapshotFromTemplate(*post, *template, items)
|
||||||
|
targetTitle := strings.TrimSpace(req.TargetTitle)
|
||||||
|
if targetTitle == "" {
|
||||||
|
targetTitle = post.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := s.reserveImport(ctx, req, post.AuthorUserID, targetTitle, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pending.Status == forummodel.ForumImportStatusImported {
|
||||||
|
result := importResultFromModel(*pending)
|
||||||
|
result.ImportCount = post.ImportCount
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.taskClassPort.CreateTaskClassFromSnapshot(ctx, req.ActorUserID, snapshot, targetTitle)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Error(), time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if created == nil {
|
||||||
|
err := respond.InternalError(fmt.Errorf("taskclass adapter returned nil created taskclass"))
|
||||||
|
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Info, time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var imported forummodel.ForumImport
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if again == nil || again.ID != pending.ID {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
if again.Status == forummodel.ForumImportStatusImported {
|
||||||
|
imported = *again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imported = *again
|
||||||
|
imported.NewTaskClassID = &created.TaskClassID
|
||||||
|
imported.TargetTitle = created.Title
|
||||||
|
imported.Status = forummodel.ForumImportStatusImported
|
||||||
|
if again.Status != forummodel.ForumImportStatusImported {
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
_ = s.forumDAO.MarkImportFailedAfterTaskClassCreated(ctx, pending.ID, created.TaskClassID, created.Title, err.Error(), time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := importResultFromModel(imported)
|
||||||
|
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||||
|
result.ImportCount = postAfter.ImportCount
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) reserveImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, authorUserID uint64, targetTitle string, idempotencyKey string) (*forummodel.ForumImport, error) {
|
||||||
|
var reserved *forummodel.ForumImport
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
existing, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
switch existing.Status {
|
||||||
|
case forummodel.ForumImportStatusImported:
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
case forummodel.ForumImportStatusPending:
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
case forummodel.ForumImportStatusFailed:
|
||||||
|
if existing.NewTaskClassID != nil {
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.UpdateImportProcessing(ctx, existing.ID, targetTitle, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing.Status = forummodel.ForumImportStatusPending
|
||||||
|
existing.TargetTitle = targetTitle
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := &forummodel.ForumImport{
|
||||||
|
PostID: req.PostID,
|
||||||
|
UserID: req.ActorUserID,
|
||||||
|
AuthorUserID: authorUserID,
|
||||||
|
TargetTitle: targetTitle,
|
||||||
|
Status: forummodel.ForumImportStatusPending,
|
||||||
|
EventID: forumImportEventID(req.PostID, req.ActorUserID),
|
||||||
|
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateImport(ctx, item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reserved = item
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reserved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, existing forummodel.ForumImport) (*forumcontracts.ImportForumPostResult, error) {
|
||||||
|
if existing.NewTaskClassID == nil {
|
||||||
|
return nil, respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
imported := existing
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if again == nil || again.ID != existing.ID {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
if again.Status == forummodel.ForumImportStatusImported {
|
||||||
|
imported = *again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if again.Status != forummodel.ForumImportStatusFailed || again.NewTaskClassID == nil {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imported = *again
|
||||||
|
imported.Status = forummodel.ForumImportStatusImported
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := importResultFromModel(imported)
|
||||||
|
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||||
|
result.ImportCount = postAfter.ImportCount
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importResultFromModel(item forummodel.ForumImport) *forumcontracts.ImportForumPostResult {
|
||||||
|
var newTaskClassID uint64
|
||||||
|
if item.NewTaskClassID != nil {
|
||||||
|
newTaskClassID = *item.NewTaskClassID
|
||||||
|
}
|
||||||
|
return &forumcontracts.ImportForumPostResult{
|
||||||
|
ImportID: item.ID,
|
||||||
|
PostID: item.PostID,
|
||||||
|
NewTaskClassID: newTaskClassID,
|
||||||
|
TaskClassTitle: item.TargetTitle,
|
||||||
|
CreatedAt: formatTime(item.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumImportEventID(postID uint64, userID uint64) string {
|
||||||
|
return fmt.Sprintf("forum.post.imported:%d:%d", postID, userID)
|
||||||
|
}
|
||||||
111
backend/services/taskclassforum/sv/like.go
Normal file
111
backend/services/taskclassforum/sv/like.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LikePost 点赞计划帖子。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责保证同一用户同一帖子只有一个 active 点赞状态;
|
||||||
|
// 2. 负责维护帖子 like_count 计数字段;
|
||||||
|
// 3. 不直接发放 Token,只写稳定 event_id,后续奖励链路可基于该 ID 幂等消费。
|
||||||
|
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || postID == 0 {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
post, err := txDAO.LockPublishedPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if like == nil {
|
||||||
|
return createActiveLike(ctx, txDAO, post, actorUserID)
|
||||||
|
}
|
||||||
|
if like.Status == forummodel.ForumLikeStatusActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.ActivateLike(ctx, like.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, postID, "like_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
return s.postInteractionState(ctx, actorUserID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlikePost 取消计划帖子点赞。
|
||||||
|
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || postID == 0 {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, postID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if like == nil || like.Status != forummodel.ForumLikeStatusActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.CancelLike(ctx, like.ID, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, postID, "like_count", -1)
|
||||||
|
}); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
return s.postInteractionState(ctx, actorUserID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) error {
|
||||||
|
like := &forummodel.ForumLike{
|
||||||
|
PostID: post.ID,
|
||||||
|
UserID: actorUserID,
|
||||||
|
AuthorUserID: post.AuthorUserID,
|
||||||
|
Status: forummodel.ForumLikeStatusActive,
|
||||||
|
EventID: forumLikeEventID(post.ID, actorUserID),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateLike(ctx, like); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, post.ID, "like_count", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
post, err := s.forumDAO.FindPublishedPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||||
|
if err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
return countersFromPost(*post), viewerState(postID, liked, imported), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumLikeEventID(postID uint64, userID uint64) string {
|
||||||
|
return fmt.Sprintf("forum.post.liked:%d:%d", postID, userID)
|
||||||
|
}
|
||||||
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/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
|
||||||
|
}
|
||||||
@@ -4,13 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
|
|
||||||
var ErrNotImplemented = errors.New("taskclassforum service method not implemented")
|
|
||||||
|
|
||||||
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。
|
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
@@ -71,12 +68,14 @@ type Options struct {
|
|||||||
// 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
|
// 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
forumDAO *forumdao.ForumDAO
|
||||||
taskClassPort TaskClassSnapshotPort
|
taskClassPort TaskClassSnapshotPort
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(opts Options) *Service {
|
func New(opts Options) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: opts.DB,
|
db: opts.DB,
|
||||||
|
forumDAO: forumdao.NewForumDAO(opts.DB),
|
||||||
taskClassPort: opts.TaskClassPort,
|
taskClassPort: opts.TaskClassPort,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,87 +92,3 @@ func (s *Service) Ready() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPosts 是计划列表用例占位,第三步实现真实查询。
|
|
||||||
func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = page
|
|
||||||
_ = pageSize
|
|
||||||
_ = sort
|
|
||||||
_ = keyword
|
|
||||||
_ = tag
|
|
||||||
return nil, forumcontracts.PageResult{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTags 是标签列表用例占位,第三步实现真实聚合查询。
|
|
||||||
func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = limit
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePost 是发布计划用例占位,第三步会通过 TaskClassSnapshotPort 读取旧计划快照。
|
|
||||||
func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = req
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPost 是计划详情用例占位,第三步实现帖子和模板快照读取。
|
|
||||||
func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = postID
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// LikePost 是点赞用例占位,第三步实现唯一约束和计数更新。
|
|
||||||
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = postID
|
|
||||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlikePost 是取消点赞用例占位,第三步实现幂等撤销。
|
|
||||||
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = postID
|
|
||||||
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListComments 是评论树查询用例占位,第三步实现根评论分页和服务层组树。
|
|
||||||
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = postID
|
|
||||||
_ = page
|
|
||||||
_ = pageSize
|
|
||||||
_ = sort
|
|
||||||
return nil, forumcontracts.PageResult{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateComment 是发表评论或回复用例占位,第三步实现父子评论校验和幂等。
|
|
||||||
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = req
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteComment 是删除自己评论用例占位,第三步实现软删除和权限判断。
|
|
||||||
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = actorUserID
|
|
||||||
_ = commentID
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportPost 是一键导入用例占位,第三步会保证同一用户同一帖子只导入一次。
|
|
||||||
func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) {
|
|
||||||
_ = ctx
|
|
||||||
_ = req
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -128,8 +128,10 @@ type ImportForumPostRequest struct {
|
|||||||
|
|
||||||
// DeleteForumCommentResult 是删除评论后的状态回执。
|
// DeleteForumCommentResult 是删除评论后的状态回执。
|
||||||
type DeleteForumCommentResult struct {
|
type DeleteForumCommentResult struct {
|
||||||
CommentID uint64 `json:"comment_id"`
|
CommentID uint64 `json:"comment_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportForumPostResult 是一键导入后的回执。
|
// ImportForumPostResult 是一键导入后的回执。
|
||||||
|
|||||||
Reference in New Issue
Block a user