389 lines
12 KiB
Go
389 lines
12 KiB
Go
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
|
||
}
|