diff --git a/backend/cmd/start.go b/backend/cmd/start.go index ae98449..9aafda7 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -24,6 +24,7 @@ import ( "github.com/LoveLosita/smartflow/backend/bootstrap" "github.com/LoveLosita/smartflow/backend/dao" gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router" + gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -73,6 +74,7 @@ type appRuntime struct { limiter *pkg.RateLimiter handlers *api.ApiHandlers userAuthClient *gatewayuserauth.Client + taskClassForumClient *gatewaytaskclassforum.Client } // loadConfig 锻炼? @@ -215,6 +217,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if err != nil { 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.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(llmService, courseRepo, scheduleRepo) @@ -324,6 +334,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { limiter: limiter, handlers: handlers, userAuthClient: userAuthClient, + taskClassForumClient: taskClassForumClient, } if runtime.eventBus != nil { if err := runtime.registerEventHandlers(); err != nil { @@ -904,7 +915,7 @@ func (r *appRuntime) registerEventHandlers() error { } 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) } diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go index ddf8f48..6230532 100644 --- a/backend/cmd/taskclassforum/main.go +++ b/backend/cmd/taskclassforum/main.go @@ -4,6 +4,8 @@ import ( "log" "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" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv" @@ -20,10 +22,14 @@ func main() { log.Fatalf("failed to connect taskclassforum database: %v", err) } - // 1. 当前阶段只启动计划广场自身 RPC 壳。 - // 2. TaskClass legacy adapter 会在第三步业务主链路接入,避免现在抢改 task 模块。 - // 3. 未实现的业务方法会明确返回 Unimplemented,而不是伪装成可用能力。 - svc := forumsv.New(forumsv.Options{DB: db}) + // 1. 复用同一个 DB 句柄装配 legacy TaskClass DAO,避免本轮抢改 task-class 模块。 + // 2. 计划广场只通过快照端口读取和创建 TaskClass,不直接写 schedule。 + // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 + taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) + svc := forumsv.New(forumsv.Options{ + DB: db, + TaskClassPort: taskClassPort, + }) forumrpc.Start(forumrpc.ServerOptions{ ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), Timeout: viper.GetDuration("taskclassforum.rpc.timeout"), diff --git a/backend/config.example.yaml b/backend/config.example.yaml index e6ea27a..db3ab4d 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -37,6 +37,14 @@ userauth: - "127.0.0.1:9081" timeout: 2s +# 计划广场 zrpc 独立服务与网关客户端配置。 +taskclassforum: + rpc: + listenOn: "0.0.0.0:9082" + endpoints: + - "127.0.0.1:9082" + timeout: 2s + # Kafka outbox 事件总线配置。 kafka: enabled: true diff --git a/backend/gateway/forumapi/handler.go b/backend/gateway/forumapi/handler.go new file mode 100644 index 0000000..7564ec4 --- /dev/null +++ b/backend/gateway/forumapi/handler.go @@ -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 +} diff --git a/backend/gateway/forumapi/routes.go b/backend/gateway/forumapi/routes.go new file mode 100644 index 0000000..c33902e --- /dev/null +++ b/backend/gateway/forumapi/routes.go @@ -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() + } +} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 0185881..8136c1c 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -9,7 +9,9 @@ import ( "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/gateway/forumapi" gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" + gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" "github.com/LoveLosita/smartflow/backend/gateway/userapi" rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" "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() 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) + forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter) taskGroup := apiGroup.Group("/task") { diff --git a/backend/gateway/taskclassforum/client.go b/backend/gateway/taskclassforum/client.go new file mode 100644 index 0000000..45c9504 --- /dev/null +++ b/backend/gateway/taskclassforum/client.go @@ -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 +} diff --git a/backend/gateway/taskclassforum/errors.go b/backend/gateway/taskclassforum/errors.go new file mode 100644 index 0000000..fd975bc --- /dev/null +++ b/backend/gateway/taskclassforum/errors.go @@ -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) +} diff --git a/backend/middleware/idempotency.go b/backend/middleware/idempotency.go index e8ec515..c376b56 100644 --- a/backend/middleware/idempotency.go +++ b/backend/middleware/idempotency.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "strings" "time" "github.com/LoveLosita/smartflow/backend/dao" @@ -39,7 +40,8 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } 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 缓存 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, ":", "_") +} diff --git a/backend/services/taskclassforum/adapter/legacy_taskclass.go b/backend/services/taskclassforum/adapter/legacy_taskclass.go new file mode 100644 index 0000000..39cbd0a --- /dev/null +++ b/backend/services/taskclassforum/adapter/legacy_taskclass.go @@ -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...) +} diff --git a/backend/services/taskclassforum/commenttree/tree.go b/backend/services/taskclassforum/commenttree/tree.go new file mode 100644 index 0000000..873f98e --- /dev/null +++ b/backend/services/taskclassforum/commenttree/tree.go @@ -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), + } +} diff --git a/backend/services/taskclassforum/dao/forum.go b/backend/services/taskclassforum/dao/forum.go new file mode 100644 index 0000000..faeabed --- /dev/null +++ b/backend/services/taskclassforum/dao/forum.go @@ -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 +} diff --git a/backend/services/taskclassforum/model/forum.go b/backend/services/taskclassforum/model/forum.go index f179a29..3c357bd 100644 --- a/backend/services/taskclassforum/model/forum.go +++ b/backend/services/taskclassforum/model/forum.go @@ -28,8 +28,12 @@ const ( ) const ( + // ForumImportStatusPending 表示导入记录已占位,正在创建 TaskClass 副本。 + ForumImportStatusPending = "pending" // ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。 ForumImportStatusImported = "imported" + // ForumImportStatusFailed 表示导入副本创建或最终确认失败,可由后续重试覆盖。 + ForumImportStatusFailed = "failed" ) // ForumPost 是计划广场帖子主体表。 @@ -40,11 +44,12 @@ const ( // 3. 计数字段由服务事务内维护,避免列表页每次做聚合统计。 type ForumPost struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` - AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_posts_author_status,priority:1;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,仅用于审计"` Title string `gorm:"column:title;type:varchar(80);not null;comment:帖子标题"` Summary string `gorm:"column:summary;type:text;comment:帖子简介"` TagsJSON string `gorm:"column:tags_json;type:json;not null;comment:标签JSON数组"` + IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_posts_author_idem,priority:2;comment:发布请求幂等键"` Status string `gorm:"column:status;type:varchar(32);not null;default:'published';index:idx_forum_posts_status_created,priority:1;index:idx_forum_posts_author_status,priority:2;comment:published/hidden/deleted/pending_review"` LikeCount int64 `gorm:"column:like_count;not null;default:0;index:idx_forum_posts_like_count;comment:点赞数冗余计数"` CommentCount int64 `gorm:"column:comment_count;not null;default:0;comment:评论数冗余计数"` @@ -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"` UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:2;uniqueIndex:uk_forum_imports_user_idem,priority:1;index:idx_forum_imports_user;comment:导入用户ID"` AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_imports_author;comment:帖子作者ID,便于奖励和审计"` - NewTaskClassID uint64 `gorm:"column:new_task_class_id;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标题"` - 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"` IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_imports_user_idem,priority:2;comment:导入请求幂等键"` + LastError *string `gorm:"column:last_error;type:text;comment:最近一次导入失败原因"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"` } diff --git a/backend/services/taskclassforum/rpc/errors.go b/backend/services/taskclassforum/rpc/errors.go index 2305fe1..175f8d4 100644 --- a/backend/services/taskclassforum/rpc/errors.go +++ b/backend/services/taskclassforum/rpc/errors.go @@ -6,7 +6,6 @@ import ( "strings" "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/grpc/codes" "google.golang.org/grpc/status" @@ -29,9 +28,6 @@ func grpcErrorFromServiceError(err error) error { if errors.As(err, &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) return status.Error(codes.Internal, "taskclassforum service internal error") } diff --git a/backend/services/taskclassforum/sv/comment.go b/backend/services/taskclassforum/sv/comment.go new file mode 100644 index 0000000..6f1333b --- /dev/null +++ b/backend/services/taskclassforum/sv/comment.go @@ -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) + } +} diff --git a/backend/services/taskclassforum/sv/errors.go b/backend/services/taskclassforum/sv/errors.go new file mode 100644 index 0000000..c5e4ec9 --- /dev/null +++ b/backend/services/taskclassforum/sv/errors.go @@ -0,0 +1,8 @@ +package sv + +import "errors" + +var ( + // ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass,但 adapter 尚未注入。 + ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil") +) diff --git a/backend/services/taskclassforum/sv/helpers.go b/backend/services/taskclassforum/sv/helpers.go new file mode 100644 index 0000000..f08a4e7 --- /dev/null +++ b/backend/services/taskclassforum/sv/helpers.go @@ -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 +} diff --git a/backend/services/taskclassforum/sv/import.go b/backend/services/taskclassforum/sv/import.go new file mode 100644 index 0000000..6172a9e --- /dev/null +++ b/backend/services/taskclassforum/sv/import.go @@ -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) +} diff --git a/backend/services/taskclassforum/sv/like.go b/backend/services/taskclassforum/sv/like.go new file mode 100644 index 0000000..26b1212 --- /dev/null +++ b/backend/services/taskclassforum/sv/like.go @@ -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) +} diff --git a/backend/services/taskclassforum/sv/post.go b/backend/services/taskclassforum/sv/post.go new file mode 100644 index 0000000..3a22fe0 --- /dev/null +++ b/backend/services/taskclassforum/sv/post.go @@ -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 +} diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go index 0b0ca8c..bceaffe 100644 --- a/backend/services/taskclassforum/sv/service.go +++ b/backend/services/taskclassforum/sv/service.go @@ -4,13 +4,10 @@ import ( "context" "errors" - forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" "gorm.io/gorm" ) -// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。 -var ErrNotImplemented = errors.New("taskclassforum service method not implemented") - // TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。 // // 职责边界: @@ -71,12 +68,14 @@ type Options struct { // 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 type Service struct { db *gorm.DB + forumDAO *forumdao.ForumDAO taskClassPort TaskClassSnapshotPort } func New(opts Options) *Service { return &Service{ db: opts.DB, + forumDAO: forumdao.NewForumDAO(opts.DB), taskClassPort: opts.TaskClassPort, } } @@ -93,87 +92,3 @@ func (s *Service) Ready() error { } return nil } - -// ListPosts 是计划列表用例占位,第三步实现真实查询。 -func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) { - _ = ctx - _ = actorUserID - _ = page - _ = pageSize - _ = sort - _ = keyword - _ = tag - return nil, forumcontracts.PageResult{}, ErrNotImplemented -} - -// ListTags 是标签列表用例占位,第三步实现真实聚合查询。 -func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) { - _ = ctx - _ = actorUserID - _ = limit - return nil, ErrNotImplemented -} - -// CreatePost 是发布计划用例占位,第三步会通过 TaskClassSnapshotPort 读取旧计划快照。 -func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// GetPost 是计划详情用例占位,第三步实现帖子和模板快照读取。 -func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) { - _ = ctx - _ = actorUserID - _ = postID - return nil, ErrNotImplemented -} - -// LikePost 是点赞用例占位,第三步实现唯一约束和计数更新。 -func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { - _ = ctx - _ = actorUserID - _ = postID - return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented -} - -// UnlikePost 是取消点赞用例占位,第三步实现幂等撤销。 -func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { - _ = ctx - _ = actorUserID - _ = postID - return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented -} - -// ListComments 是评论树查询用例占位,第三步实现根评论分页和服务层组树。 -func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) { - _ = ctx - _ = actorUserID - _ = postID - _ = page - _ = pageSize - _ = sort - return nil, forumcontracts.PageResult{}, ErrNotImplemented -} - -// CreateComment 是发表评论或回复用例占位,第三步实现父子评论校验和幂等。 -func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// DeleteComment 是删除自己评论用例占位,第三步实现软删除和权限判断。 -func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) { - _ = ctx - _ = actorUserID - _ = commentID - return nil, ErrNotImplemented -} - -// ImportPost 是一键导入用例占位,第三步会保证同一用户同一帖子只导入一次。 -func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} diff --git a/backend/shared/contracts/taskclassforum/types.go b/backend/shared/contracts/taskclassforum/types.go index f7007d9..9cde528 100644 --- a/backend/shared/contracts/taskclassforum/types.go +++ b/backend/shared/contracts/taskclassforum/types.go @@ -128,8 +128,10 @@ type ImportForumPostRequest struct { // DeleteForumCommentResult 是删除评论后的状态回执。 type DeleteForumCommentResult struct { - CommentID uint64 `json:"comment_id"` - Status string `json:"status"` + CommentID uint64 `json:"comment_id"` + Status string `json:"status"` + Content string `json:"content"` + DeletedAt *string `json:"deleted_at"` } // ImportForumPostResult 是一键导入后的回执。