feat: add token store p0 backend flow
This commit is contained in:
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/forumapi"
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
||||
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore"
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/tokenstoreapi"
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/userapi"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
@@ -57,7 +59,7 @@ func StartEngine(ctx context.Context, r *gin.Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine {
|
||||
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, tokenStoreClient *gatewaytokenstore.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine {
|
||||
r := gin.Default()
|
||||
apiGroup := r.Group("/api/v1")
|
||||
{
|
||||
@@ -70,6 +72,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)
|
||||
tokenstoreapi.RegisterRoutes(apiGroup, tokenstoreapi.NewHandler(tokenStoreClient), authClient, cache, limiter)
|
||||
|
||||
taskGroup := apiGroup.Group("/task")
|
||||
{
|
||||
|
||||
388
backend/gateway/tokenstore/client.go
Normal file
388
backend/gateway/tokenstore/client.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package tokenstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9083"
|
||||
defaultTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
Endpoints []string
|
||||
Target string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ProductSnapshot 是订单详情里内嵌的商品快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载 HTTP gateway 当前需要透出的商品摘要;
|
||||
// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体;
|
||||
// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底。
|
||||
type ProductSnapshot struct {
|
||||
ProductID uint64 `json:"product_id"`
|
||||
Name string `json:"name"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
}
|
||||
|
||||
// OrderView 是 gateway 侧订单展示结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 复用 token-store contract 里已稳定的订单字段;
|
||||
// 2. 为前端 P0 额外承载 product_snapshot / product_name / quantity 三个 HTTP 所需字段;
|
||||
// 3. 不反向影响 shared/contracts,等并行 worker 合入正式字段后可再收敛。
|
||||
type OrderView struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductSnapshot *ProductSnapshot `json:"product_snapshot,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Client 是 gateway 侧访问 token-store zrpc 的适配层。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 HTTP gateway 与 token-store zrpc 之间的协议转译;
|
||||
// 2. 不直连 token_* 表,也不承载订单/支付业务规则;
|
||||
// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。
|
||||
type Client struct {
|
||||
rpc pb.TokenStoreServiceClient
|
||||
}
|
||||
|
||||
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.NewTokenStoreServiceClient(zclient.Conn())}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetSummary(ctx, &pb.GetTokenSummaryRequest{ActorUserId: actorUserID})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty get summary response")
|
||||
}
|
||||
summary := tokenSummaryFromPB(resp.Summary)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ListProducts(ctx, &pb.ListTokenProductsRequest{ActorUserId: actorUserID})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty list products response")
|
||||
}
|
||||
return tokenProductsFromPB(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*OrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CreateOrder(ctx, &pb.CreateTokenOrderRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
ProductId: req.ProductID,
|
||||
Quantity: int32(req.Quantity),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty create order response")
|
||||
}
|
||||
order := tokenOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]OrderView, tokencontracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListOrders(ctx, &pb.ListTokenOrdersRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list orders response")
|
||||
}
|
||||
return tokenOrdersFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*OrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetOrder(ctx, &pb.GetTokenOrderRequest{
|
||||
ActorUserId: actorUserID,
|
||||
OrderId: orderID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty get order response")
|
||||
}
|
||||
order := tokenOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*OrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.MockPaidOrder(ctx, &pb.MockPaidOrderRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
OrderId: req.OrderID,
|
||||
MockChannel: req.MockChannel,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty mock paid response")
|
||||
}
|
||||
order := tokenOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListGrants(ctx, &pb.ListTokenGrantsRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Source: req.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list grants response")
|
||||
}
|
||||
return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureReady() error {
|
||||
if c == nil || c.rpc == nil {
|
||||
return errors.New("tokenstore 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) tokencontracts.PageResult {
|
||||
if page == nil {
|
||||
return tokencontracts.PageResult{}
|
||||
}
|
||||
return tokencontracts.PageResult{
|
||||
Page: int(page.Page),
|
||||
PageSize: int(page.PageSize),
|
||||
Total: int(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenSummaryFromPB(summary *pb.TokenSummary) tokencontracts.TokenSummary {
|
||||
if summary == nil {
|
||||
return tokencontracts.TokenSummary{}
|
||||
}
|
||||
return tokencontracts.TokenSummary{
|
||||
RecordedTokenTotal: summary.RecordedTokenTotal,
|
||||
AppliedTokenTotal: summary.AppliedTokenTotal,
|
||||
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
|
||||
QuotaSyncStatus: summary.QuotaSyncStatus,
|
||||
Tip: summary.Tip,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenProductFromPB(product *pb.TokenProductView) tokencontracts.TokenProductView {
|
||||
if product == nil {
|
||||
return tokencontracts.TokenProductView{}
|
||||
}
|
||||
return tokencontracts.TokenProductView{
|
||||
ProductID: product.ProductId,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
TokenAmount: product.TokenAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
PriceText: product.PriceText,
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: int(product.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
func tokenProductsFromPB(items []*pb.TokenProductView) []tokencontracts.TokenProductView {
|
||||
if len(items) == 0 {
|
||||
return []tokencontracts.TokenProductView{}
|
||||
}
|
||||
result := make([]tokencontracts.TokenProductView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, tokenProductFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenGrantFromPB(grant *pb.TokenGrantView) *tokencontracts.TokenGrantView {
|
||||
if grant == nil {
|
||||
return nil
|
||||
}
|
||||
return &tokencontracts.TokenGrantView{
|
||||
GrantID: grant.GrantId,
|
||||
EventID: grant.EventId,
|
||||
Source: grant.Source,
|
||||
SourceLabel: grant.SourceLabel,
|
||||
Amount: grant.Amount,
|
||||
Status: grant.Status,
|
||||
QuotaApplied: grant.QuotaApplied,
|
||||
Description: grant.Description,
|
||||
CreatedAt: grant.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenGrantsFromPB(items []*pb.TokenGrantView) []tokencontracts.TokenGrantView {
|
||||
if len(items) == 0 {
|
||||
return []tokencontracts.TokenGrantView{}
|
||||
}
|
||||
result := make([]tokencontracts.TokenGrantView, 0, len(items))
|
||||
for _, item := range items {
|
||||
if grant := tokenGrantFromPB(item); grant != nil {
|
||||
result = append(result, *grant)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenOrderFromPB(order *pb.TokenOrderView) OrderView {
|
||||
if order == nil {
|
||||
return OrderView{}
|
||||
}
|
||||
productSnapshot := tokenProductSnapshotFromJSON(order.ProductSnapshot)
|
||||
productName := strings.TrimSpace(order.ProductName)
|
||||
if productName == "" && productSnapshot != nil {
|
||||
productName = productSnapshot.Name
|
||||
}
|
||||
return OrderView{
|
||||
OrderID: order.OrderId,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: productSnapshot,
|
||||
ProductName: productName,
|
||||
Quantity: int(order.Quantity),
|
||||
TokenAmount: order.TokenAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
Grant: tokenGrantFromPB(order.Grant),
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: stringPtrFromNonEmpty(order.PaidAt),
|
||||
GrantedAt: stringPtrFromNonEmpty(order.GrantedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func tokenOrdersFromPB(items []*pb.TokenOrderView) []OrderView {
|
||||
if len(items) == 0 {
|
||||
return []OrderView{}
|
||||
}
|
||||
result := make([]OrderView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, tokenOrderFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// tokenProductSnapshotFromJSON 负责把 RPC 内部快照字符串转成 HTTP 展示对象。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只解析 product_id / name / token_amount 三个前端需要的字段;
|
||||
// 2. 不把解析失败暴露成接口错误,避免历史脏快照影响订单主流程展示;
|
||||
// 3. 不反查商品表,订单详情必须以当时下单快照为准。
|
||||
func tokenProductSnapshotFromJSON(raw string) *ProductSnapshot {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var snapshot ProductSnapshot
|
||||
if err := json.Unmarshal([]byte(trimmed), &snapshot); err != nil {
|
||||
return nil
|
||||
}
|
||||
if snapshot.ProductID == 0 && snapshot.Name == "" && snapshot.TokenAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func stringPtrFromNonEmpty(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
92
backend/gateway/tokenstore/errors.go
Normal file
92
backend/gateway/tokenstore/errors.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package tokenstore
|
||||
|
||||
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 把 token-store zrpc 错误恢复成 HTTP 层可处理的业务错误。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 token-store RPC 写入的 ErrorInfo,恢复 respond.Response;
|
||||
// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error,让 HTTP 层按 500 处理;
|
||||
// 3. 不在这里拼装 HTTP 响应体,handler 仍然统一走 respond.DealWithError。
|
||||
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 = "tokenstore zrpc service internal error"
|
||||
}
|
||||
return wrapRPCError(errors.New(msg))
|
||||
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 = "tokenstore 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("调用 tokenstore zrpc 服务失败: %w", err)
|
||||
}
|
||||
390
backend/gateway/tokenstoreapi/handler.go
Normal file
390
backend/gateway/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/gateway/tokenstore"
|
||||
"github.com/LoveLosita/smartflow/backend/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/tokenstoreapi/routes.go
Normal file
34
backend/gateway/tokenstoreapi/routes.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package tokenstoreapi
|
||||
|
||||
import (
|
||||
"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/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 *pkg.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