feat: add token store p0 backend flow
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router"
|
||||
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
||||
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore"
|
||||
gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth"
|
||||
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||
@@ -75,9 +76,11 @@ type appRuntime struct {
|
||||
handlers *api.ApiHandlers
|
||||
userAuthClient *gatewayuserauth.Client
|
||||
taskClassForumClient *gatewaytaskclassforum.Client
|
||||
tokenStoreClient *gatewaytokenstore.Client
|
||||
}
|
||||
|
||||
// loadConfig 锻炼?
|
||||
// loadConfig 负责装载全局配置。
|
||||
// 职责边界:只封装配置读取入口,不做服务装配和运行时初始化。
|
||||
func loadConfig() error {
|
||||
return bootstrap.LoadConfig()
|
||||
}
|
||||
@@ -225,6 +228,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err)
|
||||
}
|
||||
tokenStoreClient, err := gatewaytokenstore.NewClient(gatewaytokenstore.ClientConfig{
|
||||
Endpoints: viper.GetStringSlice("tokenstore.rpc.endpoints"),
|
||||
Target: viper.GetString("tokenstore.rpc.target"),
|
||||
Timeout: viper.GetDuration("tokenstore.rpc.timeout"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize tokenstore zrpc client: %w", err)
|
||||
}
|
||||
taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus)
|
||||
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
|
||||
courseService := buildCourseService(llmService, courseRepo, scheduleRepo)
|
||||
@@ -335,6 +346,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
||||
handlers: handlers,
|
||||
userAuthClient: userAuthClient,
|
||||
taskClassForumClient: taskClassForumClient,
|
||||
tokenStoreClient: tokenStoreClient,
|
||||
}
|
||||
if runtime.eventBus != nil {
|
||||
if err := runtime.registerEventHandlers(); err != nil {
|
||||
@@ -915,7 +927,7 @@ func (r *appRuntime) registerEventHandlers() error {
|
||||
}
|
||||
|
||||
func (r *appRuntime) startHTTP(ctx context.Context) {
|
||||
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.cacheRepo, r.limiter)
|
||||
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.tokenStoreClient, r.cacheRepo, r.limiter)
|
||||
gatewayrouter.StartEngine(ctx, router)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@ taskclassforum:
|
||||
- "127.0.0.1:9082"
|
||||
timeout: 2s
|
||||
|
||||
# Token 商店 zrpc 独立服务与网关客户端配置。
|
||||
tokenstore:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9083"
|
||||
endpoints:
|
||||
- "127.0.0.1:9083"
|
||||
timeout: 2s
|
||||
|
||||
# Kafka outbox 事件总线配置。
|
||||
kafka:
|
||||
enabled: true
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
userID := c.GetInt("user_id") // 假设 JWT 已存入
|
||||
routeKey := idempotencyRouteKey(c)
|
||||
routeKey := idempotencyScopeKey(c)
|
||||
redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey)
|
||||
|
||||
// 2. 查 Redis 缓存
|
||||
@@ -97,13 +97,17 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func idempotencyRouteKey(c *gin.Context) string {
|
||||
// 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。
|
||||
// 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。
|
||||
// 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。
|
||||
func idempotencyScopeKey(c *gin.Context) string {
|
||||
// 1. 路由模板用于区分接口语义,避免同一路径被不同 handler 复用时串缓存。
|
||||
// 2. 实际路径用于区分资源实例,避免 /orders/1/mock-paid 和 /orders/2/mock-paid 复用同一个幂等响应。
|
||||
// 3. 若异常情况下拿不到模板,则退回实际路径,保证至少不会跨资源串响应。
|
||||
route := strings.TrimSpace(c.FullPath())
|
||||
if route == "" && c.Request != nil && c.Request.URL != nil {
|
||||
route = strings.TrimSpace(c.Request.URL.Path)
|
||||
actualPath := ""
|
||||
if c.Request != nil && c.Request.URL != nil {
|
||||
actualPath = strings.TrimSpace(c.Request.URL.Path)
|
||||
}
|
||||
return strings.ReplaceAll(route, ":", "_")
|
||||
if route == "" {
|
||||
route = actualPath
|
||||
}
|
||||
return strings.ReplaceAll(route+"|"+actualPath, ":", "_")
|
||||
}
|
||||
|
||||
260
backend/services/tokenstore/dao/tokenstore.go
Normal file
260
backend/services/tokenstore/dao/tokenstore.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// TokenStoreDAO 承载 token-store 私有表的持久化访问。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只访问 token_products、token_orders、token_grants、token_reward_rules。
|
||||
// 2. 只提供查询、事务和原子状态更新,不组装 RPC/HTTP 视图。
|
||||
// 3. 业务状态机、幂等回退和提示文案由 sv 层负责。
|
||||
type TokenStoreDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTokenStoreDAO(db *gorm.DB) *TokenStoreDAO {
|
||||
return &TokenStoreDAO{db: db}
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) WithTx(tx *gorm.DB) *TokenStoreDAO {
|
||||
return &TokenStoreDAO{db: tx}
|
||||
}
|
||||
|
||||
// Transaction 在一个数据库事务内执行 token-store 写操作。
|
||||
func (dao *TokenStoreDAO) Transaction(ctx context.Context, fn func(txDAO *TokenStoreDAO) error) error {
|
||||
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(dao.WithTx(tx))
|
||||
})
|
||||
}
|
||||
|
||||
type ListTokenOrdersQuery struct {
|
||||
UserID uint64
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
}
|
||||
|
||||
type ListTokenGrantsQuery struct {
|
||||
UserID uint64
|
||||
Page int
|
||||
PageSize int
|
||||
Source string
|
||||
}
|
||||
|
||||
type TokenGrantSummary struct {
|
||||
RecordedTokenTotal int64
|
||||
AppliedTokenTotal int64
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) ListActiveProducts(ctx context.Context) ([]tokenmodel.TokenProduct, error) {
|
||||
var products []tokenmodel.TokenProduct
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("status = ?", tokenmodel.TokenProductStatusActive).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) {
|
||||
var product tokenmodel.TokenProduct
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id = ? AND status = ?", productID, tokenmodel.TokenProductStatusActive).
|
||||
First(&product).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*tokenmodel.TokenOrder, error) {
|
||||
var order tokenmodel.TokenOrder
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||
First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) CreateOrder(ctx context.Context, order *tokenmodel.TokenOrder) error {
|
||||
return dao.db.WithContext(ctx).Create(order).Error
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) CountOrders(ctx context.Context, query ListTokenOrdersQuery) (int64, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&tokenmodel.TokenOrder{}).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) ListOrders(ctx context.Context, query ListTokenOrdersQuery) ([]tokenmodel.TokenOrder, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var orders []tokenmodel.TokenOrder
|
||||
err := db.Order("created_at DESC, id DESC").
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
|
||||
var order tokenmodel.TokenOrder
|
||||
err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
|
||||
var order tokenmodel.TokenOrder
|
||||
err := dao.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", orderID).
|
||||
First(&order).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// UpdateOrderState 只负责把订单持久化到最新状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 调用方必须先完成状态机判断,并决定最终 status/paid_at/granted_at。
|
||||
// 2. 这里不做“是否允许从 A -> B”校验,避免 DAO 层承载业务规则。
|
||||
// 3. payment_mode 允许调用方显式回填,保证 mock paid 后订单快照完整。
|
||||
func (dao *TokenStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, grantedAt *time.Time, paymentMode string) error {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"paid_at": paidAt,
|
||||
"granted_at": grantedAt,
|
||||
"payment_mode": paymentMode,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&tokenmodel.TokenOrder{}).
|
||||
Where("id = ?", orderID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) FindGrantByEventID(ctx context.Context, eventID string) (*tokenmodel.TokenGrant, error) {
|
||||
var grant tokenmodel.TokenGrant
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("event_id = ?", eventID).
|
||||
First(&grant).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &grant, nil
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) FindGrantByOrderID(ctx context.Context, orderID uint64) (*tokenmodel.TokenGrant, error) {
|
||||
var grant tokenmodel.TokenGrant
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("order_id = ?", orderID).
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&grant).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &grant, nil
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) ListGrantsByOrderIDs(ctx context.Context, orderIDs []uint64) ([]tokenmodel.TokenGrant, error) {
|
||||
if len(orderIDs) == 0 {
|
||||
return []tokenmodel.TokenGrant{}, nil
|
||||
}
|
||||
|
||||
var grants []tokenmodel.TokenGrant
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("order_id IN ?", orderIDs).
|
||||
Order("created_at DESC, id DESC").
|
||||
Find(&grants).Error
|
||||
return grants, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) CreateGrant(ctx context.Context, grant *tokenmodel.TokenGrant) error {
|
||||
return dao.db.WithContext(ctx).Create(grant).Error
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) CountGrants(ctx context.Context, query ListTokenGrantsQuery) (int64, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&tokenmodel.TokenGrant{}).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if source := strings.TrimSpace(query.Source); source != "" {
|
||||
db = db.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) ListGrants(ctx context.Context, query ListTokenGrantsQuery) ([]tokenmodel.TokenGrant, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if source := strings.TrimSpace(query.Source); source != "" {
|
||||
db = db.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var grants []tokenmodel.TokenGrant
|
||||
err := db.Order("created_at DESC, id DESC").
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Find(&grants).Error
|
||||
return grants, err
|
||||
}
|
||||
|
||||
func (dao *TokenStoreDAO) SummarizePositiveGrants(ctx context.Context, userID uint64) (TokenGrantSummary, error) {
|
||||
var summary TokenGrantSummary
|
||||
err := dao.db.WithContext(ctx).
|
||||
Model(&tokenmodel.TokenGrant{}).
|
||||
Select(
|
||||
`COALESCE(SUM(CASE WHEN amount > 0 AND status IN (?, ?) THEN amount ELSE 0 END), 0) AS recorded_token_total,
|
||||
COALESCE(SUM(CASE WHEN amount > 0 AND (quota_applied = ? OR status = ?) THEN amount ELSE 0 END), 0) AS applied_token_total`,
|
||||
tokenmodel.TokenGrantStatusRecorded,
|
||||
tokenmodel.TokenGrantStatusApplied,
|
||||
true,
|
||||
tokenmodel.TokenGrantStatusApplied,
|
||||
).
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&summary).Error
|
||||
return summary, err
|
||||
}
|
||||
@@ -265,6 +265,9 @@ func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: tokenStringFromPtr(order.PaidAt),
|
||||
GrantedAt: tokenStringFromPtr(order.GrantedAt),
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: int32(order.Quantity),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ type TokenOrderView struct {
|
||||
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
PaidAt string `protobuf:"bytes,11,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"`
|
||||
GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"`
|
||||
ProductSnapshot string `protobuf:"bytes,13,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"`
|
||||
ProductName string `protobuf:"bytes,14,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"`
|
||||
Quantity int32 `protobuf:"varint,15,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||
}
|
||||
|
||||
func (m *TokenOrderView) Reset() { *m = TokenOrderView{} }
|
||||
|
||||
@@ -67,6 +67,9 @@ message TokenOrderView {
|
||||
string created_at = 10;
|
||||
string paid_at = 11;
|
||||
string granted_at = 12;
|
||||
string product_snapshot = 13;
|
||||
string product_name = 14;
|
||||
int32 quantity = 15;
|
||||
}
|
||||
|
||||
message GetTokenSummaryRequest {
|
||||
|
||||
84
backend/services/tokenstore/sv/grant.go
Normal file
84
backend/services/tokenstore/sv/grant.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
)
|
||||
|
||||
// GetSummary 聚合当前用户在 token-store 账本中的获得记录。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只统计 token_grants,不读取 user/auth 的权威额度。
|
||||
// 2. 只汇总正向获取额度,避免把未来的冲正或补偿误算进 P0 展示口径。
|
||||
// 3. quota_sync_status 在 P0 固定为 not_connected,明确告知尚未打通权威额度。
|
||||
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actorUserID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
summary, err := s.tokenDAO.SummarizePositiveGrants(ctx, actorUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pending := summary.RecordedTokenTotal - summary.AppliedTokenTotal
|
||||
if pending < 0 {
|
||||
pending = 0
|
||||
}
|
||||
|
||||
return &tokencontracts.TokenSummary{
|
||||
RecordedTokenTotal: summary.RecordedTokenTotal,
|
||||
AppliedTokenTotal: summary.AppliedTokenTotal,
|
||||
PendingApplyTokenTotal: pending,
|
||||
QuotaSyncStatus: tokenSummaryQuotaStatusNotConnected,
|
||||
Tip: tokenSummaryTipP0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListGrants 按用户分页查询 Token 获得记录。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只支持 user_id 维度分页和 source 过滤,不做跨用户检索。
|
||||
// 2. 负责把空 source 归一化为“不筛选”,避免 DAO 层重复处理入口噪音。
|
||||
// 3. 结果只来自账本事实,不推导 user/auth 可用额度。
|
||||
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
return nil, tokencontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
|
||||
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||
query := tokenstoredao.ListTokenGrantsQuery{
|
||||
UserID: req.ActorUserID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Source: strings.TrimSpace(req.Source),
|
||||
}
|
||||
|
||||
total, err := s.tokenDAO.CountGrants(ctx, query)
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
grants, err := s.tokenDAO.ListGrants(ctx, query)
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
if len(grants) == 0 {
|
||||
return []tokencontracts.TokenGrantView{}, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
result := make([]tokencontracts.TokenGrantView, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
result = append(result, grantViewFromModel(grant))
|
||||
}
|
||||
return result, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
238
backend/services/tokenstore/sv/helpers.go
Normal file
238
backend/services/tokenstore/sv/helpers.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 50
|
||||
|
||||
tokenSummaryQuotaStatusNotConnected = "not_connected"
|
||||
tokenSummaryTipP0 = "当前仅统计 Token 商店已记录的获得记录,尚未同步到 user/auth 可用额度。"
|
||||
)
|
||||
|
||||
type productSnapshot struct {
|
||||
ProductID uint64 `json:"product_id"`
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
Badge string `json:"badge"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func normalizePage(page int, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = defaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
func pageResult(page int, pageSize int, total int64) tokencontracts.PageResult {
|
||||
return tokencontracts.PageResult{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: int(total),
|
||||
HasMore: int64(page*pageSize) < total,
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func formatTimePtr(value *time.Time) *string {
|
||||
if value == nil || value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
formatted := value.Format(time.RFC3339)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
func formatPriceText(currency string, amountCent int64) string {
|
||||
if strings.EqualFold(strings.TrimSpace(currency), "CNY") {
|
||||
return fmt.Sprintf("¥%.2f", float64(amountCent)/100)
|
||||
}
|
||||
return fmt.Sprintf("%s %.2f", strings.ToUpper(strings.TrimSpace(currency)), float64(amountCent)/100)
|
||||
}
|
||||
|
||||
func stringPtrFromNonEmpty(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func productViewFromModel(product tokenmodel.TokenProduct) tokencontracts.TokenProductView {
|
||||
return tokencontracts.TokenProductView{
|
||||
ProductID: product.ID,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
TokenAmount: product.TokenAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: product.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func grantViewFromModel(grant tokenmodel.TokenGrant) tokencontracts.TokenGrantView {
|
||||
return tokencontracts.TokenGrantView{
|
||||
GrantID: grant.ID,
|
||||
EventID: grant.EventID,
|
||||
Source: grant.Source,
|
||||
SourceLabel: grantSourceLabel(grant.Source, grant.SourceLabel),
|
||||
Amount: grant.Amount,
|
||||
Status: grant.Status,
|
||||
QuotaApplied: grant.QuotaApplied,
|
||||
Description: grant.Description,
|
||||
CreatedAt: formatTime(grant.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func orderViewFromModel(order tokenmodel.TokenOrder, grant *tokenmodel.TokenGrant) tokencontracts.TokenOrderView {
|
||||
var grantView *tokencontracts.TokenGrantView
|
||||
if grant != nil {
|
||||
view := grantViewFromModel(*grant)
|
||||
grantView = &view
|
||||
}
|
||||
|
||||
return tokencontracts.TokenOrderView{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshotJSON,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: order.Quantity,
|
||||
TokenAmount: order.TokenAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: formatPriceText(order.Currency, order.AmountCent),
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
Grant: grantView,
|
||||
CreatedAt: formatTime(order.CreatedAt),
|
||||
PaidAt: formatTimePtr(order.PaidAt),
|
||||
GrantedAt: formatTimePtr(order.GrantedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func grantSourceLabel(source string, fallback string) string {
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
switch strings.TrimSpace(source) {
|
||||
case tokenmodel.TokenGrantSourcePurchase:
|
||||
return "购买充值"
|
||||
case tokenmodel.TokenGrantSourceForumLike:
|
||||
return "计划被点赞"
|
||||
case tokenmodel.TokenGrantSourceForumImport:
|
||||
return "计划被导入"
|
||||
case tokenmodel.TokenGrantSourceManual:
|
||||
return "人工补发"
|
||||
default:
|
||||
return "Token 获得记录"
|
||||
}
|
||||
}
|
||||
|
||||
func buildProductSnapshot(product tokenmodel.TokenProduct) (string, error) {
|
||||
snapshot := productSnapshot{
|
||||
ProductID: product.ID,
|
||||
SKU: product.SKU,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
TokenAmount: product.TokenAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: product.SortOrder,
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func newOrderNo() string {
|
||||
return fmt.Sprintf(
|
||||
"TS%s%s",
|
||||
time.Now().Format("20060102150405"),
|
||||
strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
)
|
||||
}
|
||||
|
||||
func purchaseGrantEventID(orderID uint64) string {
|
||||
return fmt.Sprintf("order:%d:paid", orderID)
|
||||
}
|
||||
|
||||
func purchaseGrantDescription(productName string) string {
|
||||
trimmed := strings.TrimSpace(productName)
|
||||
if trimmed == "" {
|
||||
return "购买 Token 商品"
|
||||
}
|
||||
return fmt.Sprintf("购买%s", trimmed)
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
return strings.Contains(lower, "duplicate entry") ||
|
||||
strings.Contains(lower, "duplicate key") ||
|
||||
strings.Contains(lower, "unique constraint") ||
|
||||
strings.Contains(lower, "unique violation") ||
|
||||
strings.Contains(lower, "error 1062")
|
||||
}
|
||||
|
||||
func normalizeRecordNotFound(err error, fallback error) error {
|
||||
if errorsIsRecordNotFound(err) {
|
||||
return fallback
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func errorsIsRecordNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。
|
||||
// 具体错误原因仍放在 Info,避免为每个商品/订单校验分支提前扩散大量细分码。
|
||||
const tokenStoreBadRequestStatus = "40067"
|
||||
|
||||
func tokenStoreBadRequest(message string) respond.Response {
|
||||
return respond.Response{
|
||||
Status: tokenStoreBadRequestStatus,
|
||||
Info: strings.TrimSpace(message),
|
||||
}
|
||||
}
|
||||
312
backend/services/tokenstore/sv/order.go
Normal file
312
backend/services/tokenstore/sv/order.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
)
|
||||
|
||||
// CreateOrder 创建 Token 商品订单。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 校验 actor_user_id、product_id、quantity 与幂等键。
|
||||
// 2. 只生成 pending 订单和商品快照,不触发真实支付或 user/auth 同步。
|
||||
// 3. 并发冲突时优先按 user_id + idempotency_key 回查旧单,保证 P0 幂等语义。
|
||||
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.ProductID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if req.Quantity < 1 || req.Quantity > 99 {
|
||||
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
product, err := s.tokenDAO.FindActiveProductByID(ctx, req.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if product == nil {
|
||||
return nil, tokenStoreBadRequest("商品不存在或已下架")
|
||||
}
|
||||
|
||||
productSnapshot, err := buildProductSnapshot(*product)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
order := tokenmodel.TokenOrder{
|
||||
OrderNo: newOrderNo(),
|
||||
UserID: req.ActorUserID,
|
||||
ProductID: product.ID,
|
||||
ProductSKU: product.SKU,
|
||||
ProductName: product.Name,
|
||||
ProductSnapshotJSON: productSnapshot,
|
||||
Quantity: req.Quantity,
|
||||
TokenAmount: product.TokenAmount * int64(req.Quantity),
|
||||
AmountCent: product.PriceCent * int64(req.Quantity),
|
||||
Currency: product.Currency,
|
||||
Status: tokenmodel.TokenOrderStatusPending,
|
||||
PaymentMode: "mock",
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
}
|
||||
if err := s.tokenDAO.CreateOrder(ctx, &order); err != nil {
|
||||
if idempotencyKey != "" && isDuplicateKeyError(err) {
|
||||
existing, findErr := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if findErr != nil {
|
||||
return nil, findErr
|
||||
}
|
||||
if existing != nil {
|
||||
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.orderViewByID(ctx, req.ActorUserID, order.ID)
|
||||
}
|
||||
|
||||
// ListOrders 按用户分页查询订单列表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只支持当前用户维度分页,不做跨用户检索。
|
||||
// 2. status 为空时不过滤,非空时按精确值过滤。
|
||||
// 3. 负责把订单与 grant 账本拼装成统一视图。
|
||||
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
return nil, tokencontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
|
||||
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||
query := tokenstoredao.ListTokenOrdersQuery{
|
||||
UserID: req.ActorUserID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
}
|
||||
|
||||
total, err := s.tokenDAO.CountOrders(ctx, query)
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
orders, err := s.tokenDAO.ListOrders(ctx, query)
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return []tokencontracts.TokenOrderView{}, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
grantMap, err := s.orderGrantMap(ctx, collectOrderIDs(orders))
|
||||
if err != nil {
|
||||
return nil, tokencontracts.PageResult{}, err
|
||||
}
|
||||
|
||||
result := make([]tokencontracts.TokenOrderView, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, orderViewFromModel(order, grantMap[order.ID]))
|
||||
}
|
||||
return result, pageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
// GetOrder 查询单个订单详情,并校验归属用户。
|
||||
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actorUserID == 0 || orderID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
return s.orderViewByID(ctx, actorUserID, orderID)
|
||||
}
|
||||
|
||||
// MockPaidOrder 在同步事务里完成 mock paid 和 grant 入账。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理订单状态流转与 token_grants 幂等写入,不调用 user/auth。
|
||||
// 2. event_id 固定为 order:{order_id}:paid,作为最终 grant 幂等边界。
|
||||
// 3. 重复调用优先复用既有 grant,再把订单补齐到 granted,避免重复写账本。
|
||||
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.OrderID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
var resultOrder tokenmodel.TokenOrder
|
||||
var resultGrant *tokenmodel.TokenGrant
|
||||
err := s.tokenDAO.Transaction(ctx, func(txDAO *tokenstoredao.TokenStoreDAO) error {
|
||||
now := time.Now()
|
||||
|
||||
// 1. 先锁订单并校验归属,避免并发 mock paid 重复写 grant。
|
||||
// 2. 订单不存在直接返回;订单不属于当前用户时明确拒绝。
|
||||
// 3. closed 状态不允许继续支付,避免把关闭单重新拉回可用态。
|
||||
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
return normalizeRecordNotFound(err, tokenStoreBadRequest("订单不存在"))
|
||||
}
|
||||
if order.UserID != req.ActorUserID {
|
||||
return tokenStoreBadRequest("订单不属于当前用户")
|
||||
}
|
||||
switch order.Status {
|
||||
case tokenmodel.TokenOrderStatusPending, tokenmodel.TokenOrderStatusPaid, tokenmodel.TokenOrderStatusGranted:
|
||||
case tokenmodel.TokenOrderStatusClosed:
|
||||
return tokenStoreBadRequest("订单已关闭,不能执行 mock paid")
|
||||
default:
|
||||
return tokenStoreBadRequest("订单状态不支持执行 mock paid")
|
||||
}
|
||||
|
||||
eventID := purchaseGrantEventID(order.ID)
|
||||
grant, err := txDAO.FindGrantByEventID(ctx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. grant 不存在时才尝试创建,保证账本幂等写入边界只在 event_id。
|
||||
// 2. 即使因为历史脏数据或极端并发触发唯一冲突,也要立刻按 event_id 反查旧 grant。
|
||||
// 3. 这里不写 user/auth,只把 token-store 自己的账本事实补齐。
|
||||
if grant == nil {
|
||||
sourceRefID := order.ID
|
||||
orderID := order.ID
|
||||
newGrant := &tokenmodel.TokenGrant{
|
||||
EventID: eventID,
|
||||
UserID: order.UserID,
|
||||
Source: tokenmodel.TokenGrantSourcePurchase,
|
||||
SourceLabel: grantSourceLabel(tokenmodel.TokenGrantSourcePurchase, ""),
|
||||
SourceRefID: &sourceRefID,
|
||||
OrderID: &orderID,
|
||||
Amount: order.TokenAmount,
|
||||
Status: tokenmodel.TokenGrantStatusRecorded,
|
||||
QuotaApplied: false,
|
||||
Description: purchaseGrantDescription(order.ProductName),
|
||||
}
|
||||
if err := txDAO.CreateGrant(ctx, newGrant); err != nil {
|
||||
if !isDuplicateKeyError(err) {
|
||||
return err
|
||||
}
|
||||
newGrant, err = txDAO.FindGrantByEventID(ctx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newGrant == nil {
|
||||
return tokenStoreBadRequest("Token 发放记录创建后未找到")
|
||||
}
|
||||
}
|
||||
grant = newGrant
|
||||
}
|
||||
|
||||
// 1. 无论订单原来是 pending、paid 还是 granted,只要 grant 已确定,就把订单补齐到 granted。
|
||||
// 2. paid_at 缺失时使用本次确认时间;granted_at 缺失时优先复用 grant.created_at,保证链路时间可追溯。
|
||||
// 3. 这样即便出现“grant 已有、订单未完成切流”的历史半状态,也能在重复调用时自愈。
|
||||
paidAt := order.PaidAt
|
||||
if paidAt == nil || paidAt.IsZero() {
|
||||
paidAt = &now
|
||||
}
|
||||
grantedAt := order.GrantedAt
|
||||
if grantedAt == nil || grantedAt.IsZero() {
|
||||
if grant != nil && !grant.CreatedAt.IsZero() {
|
||||
grantCreatedAt := grant.CreatedAt
|
||||
grantedAt = &grantCreatedAt
|
||||
} else {
|
||||
grantedAt = &now
|
||||
}
|
||||
}
|
||||
paymentMode := strings.TrimSpace(order.PaymentMode)
|
||||
if paymentMode == "" {
|
||||
paymentMode = strings.TrimSpace(req.MockChannel)
|
||||
}
|
||||
if paymentMode == "" {
|
||||
paymentMode = "mock"
|
||||
}
|
||||
if err := txDAO.UpdateOrderState(ctx, order.ID, tokenmodel.TokenOrderStatusGranted, paidAt, grantedAt, paymentMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order.Status = tokenmodel.TokenOrderStatusGranted
|
||||
order.PaidAt = paidAt
|
||||
order.GrantedAt = grantedAt
|
||||
order.PaymentMode = paymentMode
|
||||
resultOrder = *order
|
||||
resultGrant = grant
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := orderViewFromModel(resultOrder, resultGrant)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *Service) orderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
|
||||
order, err := s.tokenDAO.FindOrderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, tokenStoreBadRequest("订单不存在")
|
||||
}
|
||||
if order.UserID != actorUserID {
|
||||
return nil, tokenStoreBadRequest("订单不属于当前用户")
|
||||
}
|
||||
|
||||
grant, err := s.tokenDAO.FindGrantByOrderID(ctx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := orderViewFromModel(*order, grant)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *Service) orderGrantMap(ctx context.Context, orderIDs []uint64) (map[uint64]*tokenmodel.TokenGrant, error) {
|
||||
result := make(map[uint64]*tokenmodel.TokenGrant, len(orderIDs))
|
||||
if len(orderIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
grants, err := s.tokenDAO.ListGrantsByOrderIDs(ctx, orderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range grants {
|
||||
grant := grants[i]
|
||||
if grant.OrderID == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[*grant.OrderID]; exists {
|
||||
continue
|
||||
}
|
||||
grantCopy := grant
|
||||
result[*grant.OrderID] = &grantCopy
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectOrderIDs(orders []tokenmodel.TokenOrder) []uint64 {
|
||||
result := make([]uint64, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, order.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
34
backend/services/tokenstore/sv/product.go
Normal file
34
backend/services/tokenstore/sv/product.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
)
|
||||
|
||||
// ListProducts 返回当前可售商品列表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只返回 active 商品,不负责后台商品管理。
|
||||
// 2. 负责补齐 price_text,保持前端不必重复格式化价格。
|
||||
// 3. actorUserID 当前仅保留为统一接口形状,P0 不参与筛选。
|
||||
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
|
||||
_ = actorUserID
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
products, err := s.tokenDAO.ListActiveProducts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(products) == 0 {
|
||||
return []tokencontracts.TokenProductView{}, nil
|
||||
}
|
||||
|
||||
result := make([]tokencontracts.TokenProductView, 0, len(products))
|
||||
for _, product := range products {
|
||||
result = append(result, productViewFromModel(product))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -35,12 +36,14 @@ type Options struct {
|
||||
// 3. 不负责真实第三方支付回调,P0 只处理 mock paid。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
tokenDAO *tokenstoredao.TokenStoreDAO
|
||||
grantOutlet TokenGrantOutlet
|
||||
}
|
||||
|
||||
func New(opts Options) *Service {
|
||||
return &Service{
|
||||
db: opts.DB,
|
||||
tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB),
|
||||
grantOutlet: opts.GrantOutlet,
|
||||
}
|
||||
}
|
||||
@@ -55,53 +58,3 @@ func (s *Service) Ready() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProducts 是商品列表用例占位,第四步实现真实查询。
|
||||
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
|
||||
_ = ctx
|
||||
_ = actorUserID
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// GetSummary 是 Token 概览用例占位,第四步实现 grant 账本聚合。
|
||||
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
|
||||
_ = ctx
|
||||
_ = actorUserID
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// CreateOrder 是创建订单用例占位,第四步实现商品读取、订单幂等和金额快照。
|
||||
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListOrders 是订单列表用例占位,第四步实现用户维度分页查询。
|
||||
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return nil, tokencontracts.PageResult{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// GetOrder 是订单详情用例占位,第四步实现订单归属校验。
|
||||
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
|
||||
_ = ctx
|
||||
_ = actorUserID
|
||||
_ = orderID
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// MockPaidOrder 是 P0 mock paid 用例占位,第四步实现支付态流转和 grant 账本。
|
||||
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListGrants 是 Token 获取记录用例占位,第四步实现账本分页查询。
|
||||
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return nil, tokencontracts.PageResult{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ type TokenOrderView struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductSnapshot string `json:"product_snapshot"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
TokenAmount int64 `json:"token_amount"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
|
||||
Reference in New Issue
Block a user