Files
smartmate/backend/gateway/forumapi/handler.go
2026-05-05 10:44:33 +08:00

435 lines
11 KiB
Go

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
forumLikeRewardAmount = int64(1)
forumImportRewardAmount = int64(5)
rewardHintStatusActive = "rule_active"
)
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: rewardHintStatusActive,
Amount: forumLikeRewardAmount,
},
}))
}
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: rewardHintStatusActive,
Amount: forumImportRewardAmount,
},
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
}