Version: 0.9.78.dev.260506
This commit is contained in:
434
backend/gateway/api/forumapi/handler.go
Normal file
434
backend/gateway/api/forumapi/handler.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package forumapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/shared/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
|
||||
}
|
||||
87
backend/gateway/api/forumapi/routes.go
Normal file
87
backend/gateway/api/forumapi/routes.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package forumapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||
ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit"
|
||||
"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 *ratelimit.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()
|
||||
}
|
||||
}
|
||||
390
backend/gateway/api/tokenstoreapi/handler.go
Normal file
390
backend/gateway/api/tokenstoreapi/handler.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package tokenstoreapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/client/tokenstore"
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const requestTimeout = 2 * time.Second
|
||||
|
||||
type TokenStoreClient interface {
|
||||
GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error)
|
||||
ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error)
|
||||
CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*gatewaytokenstore.OrderView, error)
|
||||
ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]gatewaytokenstore.OrderView, tokencontracts.PageResult, error)
|
||||
GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*gatewaytokenstore.OrderView, error)
|
||||
MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*gatewaytokenstore.OrderView, error)
|
||||
ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client TokenStoreClient
|
||||
}
|
||||
|
||||
func NewHandler(client TokenStoreClient) *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 paymentAction struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type orderCreateEnvelope struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"`
|
||||
Quantity int `json:"quantity"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
PaymentAction paymentAction `json:"payment_action"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type orderListItemEnvelope struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductName string `json:"product_name"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
PriceText string `json:"price_text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PaidAt *string `json:"paid_at"`
|
||||
GrantedAt *string `json:"granted_at"`
|
||||
}
|
||||
|
||||
type orderDetailEnvelope struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"`
|
||||
Quantity int `json:"quantity"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
Grant *tokencontracts.TokenGrantView `json:"grant"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PaidAt *string `json:"paid_at"`
|
||||
GrantedAt *string `json:"granted_at"`
|
||||
}
|
||||
|
||||
type createOrderBody struct {
|
||||
ProductID uint64 `json:"product_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type mockPaidBody struct {
|
||||
MockChannel string `json:"mock_channel"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetSummary(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
summary, err := client.GetSummary(ctx, currentUserID(c))
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, summary))
|
||||
}
|
||||
|
||||
func (h *Handler) ListProducts(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
items, err := client.ListProducts(ctx, currentUserID(c))
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items}))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateOrder(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body createOrderBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
order, err := client.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
ProductID: body.ProductID,
|
||||
Quantity: body.Quantity,
|
||||
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderCreateEnvelope(order)))
|
||||
}
|
||||
|
||||
func (h *Handler) ListOrders(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageValue, ok := intQuery(c, "page")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize, ok := intQuery(c, "page_size")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
items, page, err := client.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
Page: pageValue,
|
||||
PageSize: pageSize,
|
||||
Status: c.Query("status"),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(newOrderListItemEnvelopes(items), page)))
|
||||
}
|
||||
|
||||
func (h *Handler) GetOrder(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := uint64Param(c, "order_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
order, err := client.GetOrder(ctx, currentUserID(c), orderID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order)))
|
||||
}
|
||||
|
||||
func (h *Handler) MockPaidOrder(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := uint64Param(c, "order_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body mockPaidBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
order, err := client.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
OrderID: orderID,
|
||||
MockChannel: body.MockChannel,
|
||||
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order)))
|
||||
}
|
||||
|
||||
func (h *Handler) ListGrants(c *gin.Context) {
|
||||
client, ok := h.ready(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageValue, ok := intQuery(c, "page")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize, ok := intQuery(c, "page_size")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||
defer cancel()
|
||||
items, page, err := client.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
|
||||
ActorUserID: currentUserID(c),
|
||||
Page: pageValue,
|
||||
PageSize: pageSize,
|
||||
Source: c.Query("source"),
|
||||
})
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||
}
|
||||
|
||||
func (h *Handler) ready(c *gin.Context) (TokenStoreClient, bool) {
|
||||
if h == nil || h.client == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token-store 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 newOrderCreateEnvelope(order *gatewaytokenstore.OrderView) orderCreateEnvelope {
|
||||
if order == nil {
|
||||
return orderCreateEnvelope{
|
||||
PaymentAction: paymentAction{
|
||||
Type: "mock_paid",
|
||||
Label: "确认支付",
|
||||
},
|
||||
}
|
||||
}
|
||||
return orderCreateEnvelope{
|
||||
OrderID: order.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
Quantity: order.Quantity,
|
||||
TokenAmount: order.TokenAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
PaymentAction: paymentAction{
|
||||
Type: "mock_paid",
|
||||
Label: "确认支付",
|
||||
},
|
||||
CreatedAt: order.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func newOrderListItemEnvelopes(items []gatewaytokenstore.OrderView) []orderListItemEnvelope {
|
||||
if len(items) == 0 {
|
||||
return []orderListItemEnvelope{}
|
||||
}
|
||||
result := make([]orderListItemEnvelope, 0, len(items))
|
||||
for _, item := range items {
|
||||
productName := item.ProductName
|
||||
if productName == "" && item.ProductSnapshot != nil {
|
||||
productName = item.ProductSnapshot.Name
|
||||
}
|
||||
result = append(result, orderListItemEnvelope{
|
||||
OrderID: item.OrderID,
|
||||
OrderNo: item.OrderNo,
|
||||
Status: item.Status,
|
||||
ProductName: productName,
|
||||
TokenAmount: item.TokenAmount,
|
||||
PriceText: item.PriceText,
|
||||
CreatedAt: item.CreatedAt,
|
||||
PaidAt: item.PaidAt,
|
||||
GrantedAt: item.GrantedAt,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newOrderDetailEnvelope(order *gatewaytokenstore.OrderView) orderDetailEnvelope {
|
||||
if order == nil {
|
||||
return orderDetailEnvelope{}
|
||||
}
|
||||
return orderDetailEnvelope{
|
||||
OrderID: order.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
Quantity: order.Quantity,
|
||||
TokenAmount: order.TokenAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
Grant: order.Grant,
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: order.PaidAt,
|
||||
GrantedAt: order.GrantedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func newPageEnvelope[T any](items []T, page tokencontracts.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
|
||||
}
|
||||
34
backend/gateway/api/tokenstoreapi/routes.go
Normal file
34
backend/gateway/api/tokenstoreapi/routes.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package tokenstoreapi
|
||||
|
||||
import (
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 把 Token 商店 HTTP 入口挂到 gateway 路由组。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只注册 /token-store 下的边缘路由,不承载订单和 grant 业务规则;
|
||||
// 2. P0 全部接口都要求登录,并统一走限流保护;
|
||||
// 3. 只有创建订单与 mock paid 需要幂等键,避免重复下单或重复确认支付。
|
||||
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *ratelimit.RateLimiter) {
|
||||
if apiGroup == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tokenStoreGroup := apiGroup.Group("/token-store")
|
||||
tokenStoreGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
{
|
||||
tokenStoreGroup.GET("/summary", handler.GetSummary)
|
||||
tokenStoreGroup.GET("/products", handler.ListProducts)
|
||||
tokenStoreGroup.POST("/orders", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateOrder)
|
||||
tokenStoreGroup.GET("/orders", handler.ListOrders)
|
||||
tokenStoreGroup.GET("/orders/:order_id", handler.GetOrder)
|
||||
tokenStoreGroup.POST("/orders/:order_id/mock-paid", rootmiddleware.IdempotencyMiddleware(cache), handler.MockPaidOrder)
|
||||
tokenStoreGroup.GET("/grants", handler.ListGrants)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user