feat: add token store p0 backend flow
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router"
|
gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router"
|
||||||
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
||||||
|
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore"
|
||||||
gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth"
|
gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth"
|
||||||
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
||||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||||
@@ -75,9 +76,11 @@ type appRuntime struct {
|
|||||||
handlers *api.ApiHandlers
|
handlers *api.ApiHandlers
|
||||||
userAuthClient *gatewayuserauth.Client
|
userAuthClient *gatewayuserauth.Client
|
||||||
taskClassForumClient *gatewaytaskclassforum.Client
|
taskClassForumClient *gatewaytaskclassforum.Client
|
||||||
|
tokenStoreClient *gatewaytokenstore.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 锻炼?
|
// loadConfig 负责装载全局配置。
|
||||||
|
// 职责边界:只封装配置读取入口,不做服务装配和运行时初始化。
|
||||||
func loadConfig() error {
|
func loadConfig() error {
|
||||||
return bootstrap.LoadConfig()
|
return bootstrap.LoadConfig()
|
||||||
}
|
}
|
||||||
@@ -225,6 +228,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err)
|
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 := service.NewTaskService(taskRepo, cacheRepo, eventBus)
|
||||||
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
|
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
|
||||||
courseService := buildCourseService(llmService, courseRepo, scheduleRepo)
|
courseService := buildCourseService(llmService, courseRepo, scheduleRepo)
|
||||||
@@ -335,6 +346,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
handlers: handlers,
|
handlers: handlers,
|
||||||
userAuthClient: userAuthClient,
|
userAuthClient: userAuthClient,
|
||||||
taskClassForumClient: taskClassForumClient,
|
taskClassForumClient: taskClassForumClient,
|
||||||
|
tokenStoreClient: tokenStoreClient,
|
||||||
}
|
}
|
||||||
if runtime.eventBus != nil {
|
if runtime.eventBus != nil {
|
||||||
if err := runtime.registerEventHandlers(); err != nil {
|
if err := runtime.registerEventHandlers(); err != nil {
|
||||||
@@ -915,7 +927,7 @@ func (r *appRuntime) registerEventHandlers() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *appRuntime) startHTTP(ctx context.Context) {
|
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)
|
gatewayrouter.StartEngine(ctx, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ taskclassforum:
|
|||||||
- "127.0.0.1:9082"
|
- "127.0.0.1:9082"
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
|
|
||||||
|
# Token 商店 zrpc 独立服务与网关客户端配置。
|
||||||
|
tokenstore:
|
||||||
|
rpc:
|
||||||
|
listenOn: "0.0.0.0:9083"
|
||||||
|
endpoints:
|
||||||
|
- "127.0.0.1:9083"
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
# Kafka outbox 事件总线配置。
|
# Kafka outbox 事件总线配置。
|
||||||
kafka:
|
kafka:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/LoveLosita/smartflow/backend/gateway/forumapi"
|
"github.com/LoveLosita/smartflow/backend/gateway/forumapi"
|
||||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum"
|
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"
|
"github.com/LoveLosita/smartflow/backend/gateway/userapi"
|
||||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
"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()
|
r := gin.Default()
|
||||||
apiGroup := r.Group("/api/v1")
|
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)
|
userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter)
|
||||||
forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter)
|
forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter)
|
||||||
|
tokenstoreapi.RegisterRoutes(apiGroup, tokenstoreapi.NewHandler(tokenStoreClient), authClient, cache, limiter)
|
||||||
|
|
||||||
taskGroup := apiGroup.Group("/task")
|
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 已存入
|
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)
|
redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey)
|
||||||
|
|
||||||
// 2. 查 Redis 缓存
|
// 2. 查 Redis 缓存
|
||||||
@@ -97,13 +97,17 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func idempotencyRouteKey(c *gin.Context) string {
|
func idempotencyScopeKey(c *gin.Context) string {
|
||||||
// 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。
|
// 1. 路由模板用于区分接口语义,避免同一路径被不同 handler 复用时串缓存。
|
||||||
// 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。
|
// 2. 实际路径用于区分资源实例,避免 /orders/1/mock-paid 和 /orders/2/mock-paid 复用同一个幂等响应。
|
||||||
// 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。
|
// 3. 若异常情况下拿不到模板,则退回实际路径,保证至少不会跨资源串响应。
|
||||||
route := strings.TrimSpace(c.FullPath())
|
route := strings.TrimSpace(c.FullPath())
|
||||||
if route == "" && c.Request != nil && c.Request.URL != nil {
|
actualPath := ""
|
||||||
route = strings.TrimSpace(c.Request.URL.Path)
|
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,
|
CreatedAt: order.CreatedAt,
|
||||||
PaidAt: tokenStringFromPtr(order.PaidAt),
|
PaidAt: tokenStringFromPtr(order.PaidAt),
|
||||||
GrantedAt: tokenStringFromPtr(order.GrantedAt),
|
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"`
|
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"`
|
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"`
|
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{} }
|
func (m *TokenOrderView) Reset() { *m = TokenOrderView{} }
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ message TokenOrderView {
|
|||||||
string created_at = 10;
|
string created_at = 10;
|
||||||
string paid_at = 11;
|
string paid_at = 11;
|
||||||
string granted_at = 12;
|
string granted_at = 12;
|
||||||
|
string product_snapshot = 13;
|
||||||
|
string product_name = 14;
|
||||||
|
int32 quantity = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetTokenSummaryRequest {
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -35,12 +36,14 @@ type Options struct {
|
|||||||
// 3. 不负责真实第三方支付回调,P0 只处理 mock paid。
|
// 3. 不负责真实第三方支付回调,P0 只处理 mock paid。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
tokenDAO *tokenstoredao.TokenStoreDAO
|
||||||
grantOutlet TokenGrantOutlet
|
grantOutlet TokenGrantOutlet
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(opts Options) *Service {
|
func New(opts Options) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: opts.DB,
|
db: opts.DB,
|
||||||
|
tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB),
|
||||||
grantOutlet: opts.GrantOutlet,
|
grantOutlet: opts.GrantOutlet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,53 +58,3 @@ func (s *Service) Ready() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
ProductSnapshot string `json:"product_snapshot"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
TokenAmount int64 `json:"token_amount"`
|
TokenAmount int64 `json:"token_amount"`
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
PriceText string `json:"price_text"`
|
PriceText string `json:"price_text"`
|
||||||
|
|||||||
Reference in New Issue
Block a user