Files
smartmate/backend/client/tokenstore/client.go
2026-05-06 00:30:08 +08:00

408 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:9095"
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) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.RecordForumRewardGrant(ctx, &pb.RecordForumRewardGrantRequest{
EventId: req.EventID,
ReceiverUserId: req.ReceiverUserID,
Source: req.Source,
SourceRefId: req.SourceRefID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty record forum reward grant response")
}
return tokenGrantFromPB(resp.Grant), 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
}