feat: 接入计划广场后端主链路
This commit is contained in:
429
backend/gateway/forumapi/handler.go
Normal file
429
backend/gateway/forumapi/handler.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package forumapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const requestTimeout = 2 * time.Second
|
||||
|
||||
type ForumClient interface {
|
||||
ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error)
|
||||
ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error)
|
||||
CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error)
|
||||
GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error)
|
||||
LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||
UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||
ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error)
|
||||
CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error)
|
||||
DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error)
|
||||
ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client ForumClient
|
||||
}
|
||||
|
||||
func NewHandler(client ForumClient) *Handler {
|
||||
return &Handler{client: client}
|
||||
}
|
||||
|
||||
type pageEnvelope[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type interactionEnvelope struct {
|
||||
PostID uint64 `json:"post_id"`
|
||||
Liked bool `json:"liked"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
RewardHint *rewardHint `json:"reward_hint,omitempty"`
|
||||
}
|
||||
|
||||
type rewardHint struct {
|
||||
Receiver string `json:"receiver"`
|
||||
Status string `json:"status"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
type nextAction struct {
|
||||
Type string `json:"type"`
|
||||
TaskClassID uint64 `json:"task_class_id"`
|
||||
}
|
||||
|
||||
type importEnvelope struct {
|
||||
ImportID uint64 `json:"import_id"`
|
||||
PostID uint64 `json:"post_id"`
|
||||
NewTaskClassID uint64 `json:"new_task_class_id"`
|
||||
TaskClassTitle string `json:"task_class_title"`
|
||||
ImportCount int64 `json:"import_count"`
|
||||
RewardHint rewardHint `json:"reward_hint"`
|
||||
NextAction nextAction `json:"next_action"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type deleteCommentEnvelope struct {
|
||||
CommentID uint64 `json:"comment_id"`
|
||||
Status string `json:"status"`
|
||||
Content string `json:"content"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type createPostBody struct {
|
||||
TaskClassID uint64 `json:"task_class_id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type createCommentBody struct {
|
||||
Content string `json:"content"`
|
||||
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||
}
|
||||
|
||||
type importPostBody struct {
|
||||
TargetTitle string `json:"target_title"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListPosts(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
pageValue, ok := intQuery(c, "page")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize, ok := intQuery(c, "page_size")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, page, err := client.ListPosts(
|
||||
ctx,
|
||||
currentUserID(c),
|
||||
pageValue,
|
||||
pageSize,
|
||||
c.Query("sort"),
|
||||
c.Query("keyword"),
|
||||
c.Query("tag"),
|
||||
)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||
}
|
||||
|
||||
func (h *Handler) ListTags(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
limit, ok := intQuery(c, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := client.ListTags(ctx, currentUserID(c), limit)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items}))
|
||||
}
|
||||
|
||||
func (h *Handler) CreatePost(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body createPostBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
post, err := client.CreatePost(ctx, contracts.CreateForumPostRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
TaskClassID: body.TaskClassID,
|
||||
Title: body.Title,
|
||||
Summary: body.Summary,
|
||||
Tags: append([]string(nil), body.Tags...),
|
||||
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, post))
|
||||
}
|
||||
|
||||
func (h *Handler) GetPost(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
detail, err := client.GetPost(ctx, currentUserID(c), postID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail))
|
||||
}
|
||||
|
||||
func (h *Handler) LikePost(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
counters, state, err := client.LikePost(ctx, currentUserID(c), postID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||
PostID: postID,
|
||||
Liked: state.Liked,
|
||||
LikeCount: counters.LikeCount,
|
||||
RewardHint: &rewardHint{
|
||||
Receiver: "author",
|
||||
Status: "recorded",
|
||||
Amount: 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) UnlikePost(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
counters, state, err := client.UnlikePost(ctx, currentUserID(c), postID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||
PostID: postID,
|
||||
Liked: state.Liked,
|
||||
LikeCount: counters.LikeCount,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) ListComments(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
pageValue, ok := intQuery(c, "page")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize, ok := intQuery(c, "page_size")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, page, err := client.ListComments(ctx, currentUserID(c), postID, pageValue, pageSize, c.Query("sort"))
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateComment(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body createCommentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
comment, err := client.CreateComment(ctx, contracts.CreateForumCommentRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
PostID: postID,
|
||||
Content: body.Content,
|
||||
ParentCommentID: body.ParentCommentID,
|
||||
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, comment))
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
commentID, ok := uint64Param(c, "comment_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := client.DeleteComment(ctx, currentUserID(c), commentID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, deleteCommentEnvelope{
|
||||
CommentID: result.CommentID,
|
||||
Status: result.Status,
|
||||
Content: result.Content,
|
||||
DeletedAt: result.DeletedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) ImportPost(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
postID, ok := uint64Param(c, "post_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body importPostBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
result, err := client.ImportPost(ctx, contracts.ImportForumPostRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
PostID: postID,
|
||||
TargetTitle: body.TargetTitle,
|
||||
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, importEnvelope{
|
||||
ImportID: result.ImportID,
|
||||
PostID: result.PostID,
|
||||
NewTaskClassID: result.NewTaskClassID,
|
||||
TaskClassTitle: result.TaskClassTitle,
|
||||
ImportCount: result.ImportCount,
|
||||
RewardHint: rewardHint{
|
||||
Receiver: "author",
|
||||
Status: "recorded",
|
||||
Amount: 2,
|
||||
},
|
||||
NextAction: nextAction{
|
||||
Type: "open_task_class",
|
||||
TaskClassID: result.NewTaskClassID,
|
||||
},
|
||||
CreatedAt: result.CreatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) ready(c *gin.Context) (ForumClient, bool) {
|
||||
if h == nil || h.client == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场 gateway client 未初始化")))
|
||||
return nil, false
|
||||
}
|
||||
return h.client, true
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
userID := c.GetInt("user_id")
|
||||
if userID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(userID)
|
||||
}
|
||||
|
||||
func newPageEnvelope[T any](items []T, page contracts.PageResult) pageEnvelope[T] {
|
||||
return pageEnvelope[T]{
|
||||
Items: items,
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func intQuery(c *gin.Context, key string) (int, bool) {
|
||||
raw := strings.TrimSpace(c.Query(key))
|
||||
if raw == "" {
|
||||
return 0, true
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func uint64Param(c *gin.Context, key string) (uint64, bool) {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
87
backend/gateway/forumapi/routes.go
Normal file
87
backend/gateway/forumapi/routes.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package forumapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 把计划广场 HTTP 入口挂到 gateway 路由组。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只注册 /plan-square 下的边缘路由,不承载论坛业务规则;
|
||||
// 2. 公开读接口允许匿名访问,若携带 token 则补齐 viewer_state;
|
||||
// 3. 写接口必须登录,并按既有 Redis 幂等中间件保护重复提交。
|
||||
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *pkg.RateLimiter) {
|
||||
if apiGroup == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
planSquare := apiGroup.Group("/plan-square")
|
||||
{
|
||||
publicGroup := planSquare.Group("")
|
||||
publicGroup.Use(optionalJWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 40, 1))
|
||||
publicGroup.GET("/posts", handler.ListPosts)
|
||||
publicGroup.GET("/tags", handler.ListTags)
|
||||
publicGroup.GET("/posts/:post_id", handler.GetPost)
|
||||
publicGroup.GET("/posts/:post_id/comments", handler.ListComments)
|
||||
|
||||
writeGroup := planSquare.Group("")
|
||||
writeGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
writeGroup.POST("/posts", rootmiddleware.IdempotencyMiddleware(cache), handler.CreatePost)
|
||||
writeGroup.POST("/posts/:post_id/like", handler.LikePost)
|
||||
writeGroup.DELETE("/posts/:post_id/like", handler.UnlikePost)
|
||||
writeGroup.POST("/posts/:post_id/comments", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateComment)
|
||||
writeGroup.DELETE("/comments/:comment_id", handler.DeleteComment)
|
||||
writeGroup.POST("/posts/:post_id/import", rootmiddleware.IdempotencyMiddleware(cache), handler.ImportPost)
|
||||
}
|
||||
}
|
||||
|
||||
// optionalJWTTokenAuth 为计划广场公开读接口提供“可登录增强”。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 没有 Authorization 时直接放行,让匿名用户也能浏览计划广场;
|
||||
// 2. 有 Authorization 时复用 user/auth 校验,并把 user_id 写入上下文;
|
||||
// 3. token 非法时按正常鉴权失败返回,避免前端误以为已登录状态仍可用。
|
||||
func optionalJWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||
if tokenString == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if validator == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场可选鉴权依赖未初始化")))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := validator.ValidateAccessToken(ctx, tokenString)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if resp == nil || !resp.Valid || resp.UserID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", resp.UserID)
|
||||
c.Set("claims", resp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/api"
|
||||
"github.com/LoveLosita/smartflow/backend/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")
|
||||
{
|
||||
|
||||
470
backend/gateway/taskclassforum/client.go
Normal file
470
backend/gateway/taskclassforum/client.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package taskclassforum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9082"
|
||||
defaultTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
Endpoints []string
|
||||
Target string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client 是 gateway 侧访问计划广场 zrpc 的适配层。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 HTTP gateway 与 taskclassforum zrpc 之间的协议转译;
|
||||
// 2. 不直连 forum_* 表,也不读取旧 TaskClass 表,所有业务规则交给 taskclassforum 服务;
|
||||
// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。
|
||||
type Client struct {
|
||||
rpc pb.TaskClassForumServiceClient
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
endpoints := normalizeEndpoints(cfg.Endpoints)
|
||||
target := strings.TrimSpace(cfg.Target)
|
||||
if len(endpoints) == 0 && target == "" {
|
||||
endpoints = []string{defaultEndpoint}
|
||||
}
|
||||
|
||||
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
|
||||
Endpoints: endpoints,
|
||||
Target: target,
|
||||
NonBlock: true,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{rpc: pb.NewTaskClassForumServiceClient(zclient.Conn())}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, contracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListPosts(ctx, &pb.ListForumPostsRequest{
|
||||
ActorUserId: actorUserID,
|
||||
Page: int32(page),
|
||||
PageSize: int32(pageSize),
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
Tag: tag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, contracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list posts response")
|
||||
}
|
||||
return forumPostBriefsFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ListTags(ctx, &pb.ListForumTagsRequest{
|
||||
ActorUserId: actorUserID,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty list tags response")
|
||||
}
|
||||
return forumTagItemsFromPB(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *Client) CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CreatePost(ctx, &pb.CreateForumPostRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
TaskClassId: req.TaskClassID,
|
||||
Title: req.Title,
|
||||
Summary: req.Summary,
|
||||
Tags: append([]string(nil), req.Tags...),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty create post response")
|
||||
}
|
||||
post := forumPostBriefFromPB(resp.Post)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetPost(ctx, &pb.GetForumPostRequest{
|
||||
ActorUserId: actorUserID,
|
||||
PostId: postID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty get post response")
|
||||
}
|
||||
data := forumPostDetailFromPB(resp.Data)
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (c *Client) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
|
||||
}
|
||||
resp, err := c.rpc.LikePost(ctx, &pb.LikeForumPostRequest{
|
||||
ActorUserId: actorUserID,
|
||||
PostId: postID,
|
||||
})
|
||||
if err != nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty like response")
|
||||
}
|
||||
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
|
||||
}
|
||||
|
||||
func (c *Client) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
|
||||
}
|
||||
resp, err := c.rpc.UnlikePost(ctx, &pb.UnlikeForumPostRequest{
|
||||
ActorUserId: actorUserID,
|
||||
PostId: postID,
|
||||
})
|
||||
if err != nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty unlike response")
|
||||
}
|
||||
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
|
||||
}
|
||||
|
||||
func (c *Client) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, contracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListComments(ctx, &pb.ListForumCommentsRequest{
|
||||
ActorUserId: actorUserID,
|
||||
PostId: postID,
|
||||
Page: int32(page),
|
||||
PageSize: int32(pageSize),
|
||||
Sort: sort,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, contracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list comments response")
|
||||
}
|
||||
return forumCommentNodesFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CreateComment(ctx, &pb.CreateForumCommentRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
PostId: req.PostID,
|
||||
Content: req.Content,
|
||||
ParentCommentId: uint64FromPtr(req.ParentCommentID),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty create comment response")
|
||||
}
|
||||
comment := forumCommentNodeFromPB(resp.Comment)
|
||||
return &comment, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.DeleteComment(ctx, &pb.DeleteForumCommentRequest{
|
||||
ActorUserId: actorUserID,
|
||||
CommentId: commentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty delete comment response")
|
||||
}
|
||||
deletedAt := time.Now().Format(time.RFC3339)
|
||||
return &contracts.DeleteForumCommentResult{
|
||||
CommentID: resp.CommentId,
|
||||
Status: resp.Status,
|
||||
Content: "",
|
||||
DeletedAt: &deletedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ImportPost(ctx, &pb.ImportForumPostRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
PostId: req.PostID,
|
||||
TargetTitle: req.TargetTitle,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("taskclassforum zrpc service returned empty import post response")
|
||||
}
|
||||
return &contracts.ImportForumPostResult{
|
||||
ImportID: resp.ImportId,
|
||||
PostID: resp.PostId,
|
||||
NewTaskClassID: resp.NewTaskClassId,
|
||||
TaskClassTitle: resp.TaskClassTitle,
|
||||
ImportCount: resp.ImportCount,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureReady() error {
|
||||
if c == nil || c.rpc == nil {
|
||||
return errors.New("taskclassforum zrpc client is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEndpoints(values []string) []string {
|
||||
endpoints := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
endpoints = append(endpoints, trimmed)
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func pageFromPB(page *pb.PageResponse) contracts.PageResult {
|
||||
if page == nil {
|
||||
return contracts.PageResult{}
|
||||
}
|
||||
return contracts.PageResult{
|
||||
Page: int(page.Page),
|
||||
PageSize: int(page.PageSize),
|
||||
Total: int(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func forumUserFromPB(user *pb.UserBrief) contracts.UserBrief {
|
||||
if user == nil {
|
||||
return contracts.UserBrief{}
|
||||
}
|
||||
return contracts.UserBrief{
|
||||
UserID: user.UserId,
|
||||
Nickname: user.Nickname,
|
||||
AvatarURL: user.AvatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
func forumTemplateSummaryFromPB(summary *pb.TemplateSummary) contracts.TemplateSummary {
|
||||
if summary == nil {
|
||||
return contracts.TemplateSummary{}
|
||||
}
|
||||
return contracts.TemplateSummary{
|
||||
TaskCount: int(summary.TaskCount),
|
||||
Mode: summary.Mode,
|
||||
StartDate: summary.StartDate,
|
||||
EndDate: summary.EndDate,
|
||||
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostCountersFromPB(counters *pb.ForumPostCounters) contracts.ForumPostCounters {
|
||||
if counters == nil {
|
||||
return contracts.ForumPostCounters{}
|
||||
}
|
||||
return contracts.ForumPostCounters{
|
||||
LikeCount: counters.LikeCount,
|
||||
CommentCount: counters.CommentCount,
|
||||
ImportCount: counters.ImportCount,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostViewerStateFromPB(state *pb.ForumPostViewerState) contracts.ForumPostViewerState {
|
||||
if state == nil {
|
||||
return contracts.ForumPostViewerState{}
|
||||
}
|
||||
return contracts.ForumPostViewerState{
|
||||
Liked: state.Liked,
|
||||
ImportedOnce: state.ImportedOnce,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostBriefFromPB(post *pb.ForumPostBrief) contracts.ForumPostBrief {
|
||||
if post == nil {
|
||||
return contracts.ForumPostBrief{}
|
||||
}
|
||||
return contracts.ForumPostBrief{
|
||||
PostID: post.PostId,
|
||||
Title: post.Title,
|
||||
Summary: post.Summary,
|
||||
Tags: append([]string(nil), post.Tags...),
|
||||
Author: forumUserFromPB(post.Author),
|
||||
TemplateSummary: forumTemplateSummaryFromPB(post.TemplateSummary),
|
||||
Counters: forumPostCountersFromPB(post.Counters),
|
||||
ViewerState: forumPostViewerStateFromPB(post.ViewerState),
|
||||
Status: post.Status,
|
||||
CreatedAt: post.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostBriefsFromPB(items []*pb.ForumPostBrief) []contracts.ForumPostBrief {
|
||||
if len(items) == 0 {
|
||||
return []contracts.ForumPostBrief{}
|
||||
}
|
||||
result := make([]contracts.ForumPostBrief, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, forumPostBriefFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumTemplateDetailFromPB(detail *pb.TemplateDetail) contracts.TemplateDetail {
|
||||
if detail == nil {
|
||||
return contracts.TemplateDetail{}
|
||||
}
|
||||
items := make([]contracts.TemplateItemPreview, 0, len(detail.ItemsPreview))
|
||||
for _, item := range detail.ItemsPreview {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, contracts.TemplateItemPreview{
|
||||
ItemID: item.ItemId,
|
||||
Order: int(item.Order),
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
return contracts.TemplateDetail{
|
||||
Mode: detail.Mode,
|
||||
StartDate: detail.StartDate,
|
||||
EndDate: detail.EndDate,
|
||||
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
|
||||
TaskCount: int(detail.TaskCount),
|
||||
ItemsPreview: items,
|
||||
}
|
||||
}
|
||||
|
||||
func forumPostDetailFromPB(detail *pb.ForumPostDetail) contracts.ForumPostDetail {
|
||||
if detail == nil {
|
||||
return contracts.ForumPostDetail{}
|
||||
}
|
||||
return contracts.ForumPostDetail{
|
||||
Post: forumPostBriefFromPB(detail.Post),
|
||||
Template: forumTemplateDetailFromPB(detail.Template),
|
||||
}
|
||||
}
|
||||
|
||||
func forumTagItemsFromPB(items []*pb.ForumTagItem) []contracts.ForumTagItem {
|
||||
if len(items) == 0 {
|
||||
return []contracts.ForumTagItem{}
|
||||
}
|
||||
result := make([]contracts.ForumTagItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, contracts.ForumTagItem{
|
||||
Tag: item.Tag,
|
||||
PostCount: int(item.PostCount),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func forumCommentNodeFromPB(node *pb.ForumCommentNode) contracts.ForumCommentNode {
|
||||
if node == nil {
|
||||
return contracts.ForumCommentNode{}
|
||||
}
|
||||
children := make([]contracts.ForumCommentNode, 0, len(node.Children))
|
||||
for _, child := range node.Children {
|
||||
children = append(children, forumCommentNodeFromPB(child))
|
||||
}
|
||||
return contracts.ForumCommentNode{
|
||||
CommentID: node.CommentId,
|
||||
PostID: node.PostId,
|
||||
ParentCommentID: uint64PtrFromPositive(node.ParentCommentId),
|
||||
Content: node.Content,
|
||||
Status: node.Status,
|
||||
Author: forumUserFromPB(node.Author),
|
||||
CanDelete: node.CanDelete,
|
||||
CreatedAt: node.CreatedAt,
|
||||
DeletedAt: stringPtrFromNonEmpty(node.DeletedAt),
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
||||
func forumCommentNodesFromPB(items []*pb.ForumCommentNode) []contracts.ForumCommentNode {
|
||||
if len(items) == 0 {
|
||||
return []contracts.ForumCommentNode{}
|
||||
}
|
||||
result := make([]contracts.ForumCommentNode, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, forumCommentNodeFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uint64FromPtr(value *uint64) uint64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func uint64PtrFromPositive(value uint64) *uint64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
result := value
|
||||
return &result
|
||||
}
|
||||
|
||||
func stringPtrFromNonEmpty(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
94
backend/gateway/taskclassforum/errors.go
Normal file
94
backend/gateway/taskclassforum/errors.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package taskclassforum
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// responseFromRPCError 把计划广场 zrpc 错误恢复成 HTTP 层可处理的业务错误。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 taskclassforum RPC 写入的 ErrorInfo,恢复 respond.Response;
|
||||
// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error,让 HTTP 层按 500 处理;
|
||||
// 3. 暂不复用 userauth/errors.go,因为 user/auth 还承担历史 legacy code 兼容,计划广场只消费新 ErrorInfo 协议。
|
||||
func responseFromRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return wrapRPCError(err)
|
||||
}
|
||||
if resp, ok := responseFromStatusDetails(st); ok {
|
||||
return resp
|
||||
}
|
||||
|
||||
switch st.Code() {
|
||||
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "taskclassforum zrpc service internal error"
|
||||
}
|
||||
return wrapRPCError(errors.New(msg))
|
||||
case codes.NotFound:
|
||||
return responseWithFallback(st, respond.UserTaskClassNotFound)
|
||||
case codes.PermissionDenied, codes.Unauthenticated:
|
||||
return responseWithFallback(st, respond.ErrUnauthorized)
|
||||
case codes.InvalidArgument:
|
||||
return responseWithFallback(st, respond.MissingParam)
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "taskclassforum zrpc service rejected request"
|
||||
}
|
||||
return respond.Response{Status: "400", Info: msg}
|
||||
}
|
||||
|
||||
func responseFromStatusDetails(st *status.Status) (respond.Response, bool) {
|
||||
if st == nil {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
for _, detail := range st.Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
statusValue := strings.TrimSpace(info.Reason)
|
||||
if statusValue == "" {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
message := strings.TrimSpace(st.Message())
|
||||
if message == "" && info.Metadata != nil {
|
||||
message = strings.TrimSpace(info.Metadata["info"])
|
||||
}
|
||||
if message == "" {
|
||||
message = statusValue
|
||||
}
|
||||
return respond.Response{Status: statusValue, Info: message}, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func responseWithFallback(st *status.Status, fallback respond.Response) respond.Response {
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = fallback.Info
|
||||
}
|
||||
return respond.Response{Status: fallback.Status, Info: msg}
|
||||
}
|
||||
|
||||
func wrapRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("调用 taskclassforum zrpc 服务失败: %w", err)
|
||||
}
|
||||
Reference in New Issue
Block a user