diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 9aafda7..4a7e9ee 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -25,6 +25,7 @@ import ( "github.com/LoveLosita/smartflow/backend/dao" gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router" gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" + gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore" gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -75,9 +76,11 @@ type appRuntime struct { handlers *api.ApiHandlers userAuthClient *gatewayuserauth.Client taskClassForumClient *gatewaytaskclassforum.Client + tokenStoreClient *gatewaytokenstore.Client } -// loadConfig 锻炼? +// loadConfig 负责装载全局配置。 +// 职责边界:只封装配置读取入口,不做服务装配和运行时初始化。 func loadConfig() error { return bootstrap.LoadConfig() } @@ -225,6 +228,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if err != nil { return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err) } + tokenStoreClient, err := gatewaytokenstore.NewClient(gatewaytokenstore.ClientConfig{ + Endpoints: viper.GetStringSlice("tokenstore.rpc.endpoints"), + Target: viper.GetString("tokenstore.rpc.target"), + Timeout: viper.GetDuration("tokenstore.rpc.timeout"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize tokenstore zrpc client: %w", err) + } taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(llmService, courseRepo, scheduleRepo) @@ -335,6 +346,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { handlers: handlers, userAuthClient: userAuthClient, taskClassForumClient: taskClassForumClient, + tokenStoreClient: tokenStoreClient, } if runtime.eventBus != nil { if err := runtime.registerEventHandlers(); err != nil { @@ -915,7 +927,7 @@ func (r *appRuntime) registerEventHandlers() error { } func (r *appRuntime) startHTTP(ctx context.Context) { - router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.cacheRepo, r.limiter) + router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.tokenStoreClient, r.cacheRepo, r.limiter) gatewayrouter.StartEngine(ctx, router) } diff --git a/backend/config.example.yaml b/backend/config.example.yaml index db3ab4d..6819397 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -45,6 +45,14 @@ taskclassforum: - "127.0.0.1:9082" timeout: 2s +# Token 商店 zrpc 独立服务与网关客户端配置。 +tokenstore: + rpc: + listenOn: "0.0.0.0:9083" + endpoints: + - "127.0.0.1:9083" + timeout: 2s + # Kafka outbox 事件总线配置。 kafka: enabled: true diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 8136c1c..6fa2aa5 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -12,6 +12,8 @@ import ( "github.com/LoveLosita/smartflow/backend/gateway/forumapi" gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" + gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore" + "github.com/LoveLosita/smartflow/backend/gateway/tokenstoreapi" "github.com/LoveLosita/smartflow/backend/gateway/userapi" rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" "github.com/LoveLosita/smartflow/backend/pkg" @@ -57,7 +59,7 @@ func StartEngine(ctx context.Context, r *gin.Engine) { } } -func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { +func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, tokenStoreClient *gatewaytokenstore.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { r := gin.Default() apiGroup := r.Group("/api/v1") { @@ -70,6 +72,7 @@ func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter) forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter) + tokenstoreapi.RegisterRoutes(apiGroup, tokenstoreapi.NewHandler(tokenStoreClient), authClient, cache, limiter) taskGroup := apiGroup.Group("/task") { diff --git a/backend/gateway/tokenstore/client.go b/backend/gateway/tokenstore/client.go new file mode 100644 index 0000000..6706d59 --- /dev/null +++ b/backend/gateway/tokenstore/client.go @@ -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 +} diff --git a/backend/gateway/tokenstore/errors.go b/backend/gateway/tokenstore/errors.go new file mode 100644 index 0000000..50e6e40 --- /dev/null +++ b/backend/gateway/tokenstore/errors.go @@ -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) +} diff --git a/backend/gateway/tokenstoreapi/handler.go b/backend/gateway/tokenstoreapi/handler.go new file mode 100644 index 0000000..a741e02 --- /dev/null +++ b/backend/gateway/tokenstoreapi/handler.go @@ -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 +} diff --git a/backend/gateway/tokenstoreapi/routes.go b/backend/gateway/tokenstoreapi/routes.go new file mode 100644 index 0000000..5821bdb --- /dev/null +++ b/backend/gateway/tokenstoreapi/routes.go @@ -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) + } +} diff --git a/backend/middleware/idempotency.go b/backend/middleware/idempotency.go index c376b56..6c4ef40 100644 --- a/backend/middleware/idempotency.go +++ b/backend/middleware/idempotency.go @@ -40,7 +40,7 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } userID := c.GetInt("user_id") // 假设 JWT 已存入 - routeKey := idempotencyRouteKey(c) + routeKey := idempotencyScopeKey(c) redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey) // 2. 查 Redis 缓存 @@ -97,13 +97,17 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } } -func idempotencyRouteKey(c *gin.Context) string { - // 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。 - // 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。 - // 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。 +func idempotencyScopeKey(c *gin.Context) string { + // 1. 路由模板用于区分接口语义,避免同一路径被不同 handler 复用时串缓存。 + // 2. 实际路径用于区分资源实例,避免 /orders/1/mock-paid 和 /orders/2/mock-paid 复用同一个幂等响应。 + // 3. 若异常情况下拿不到模板,则退回实际路径,保证至少不会跨资源串响应。 route := strings.TrimSpace(c.FullPath()) - if route == "" && c.Request != nil && c.Request.URL != nil { - route = strings.TrimSpace(c.Request.URL.Path) + actualPath := "" + if c.Request != nil && c.Request.URL != nil { + actualPath = strings.TrimSpace(c.Request.URL.Path) } - return strings.ReplaceAll(route, ":", "_") + if route == "" { + route = actualPath + } + return strings.ReplaceAll(route+"|"+actualPath, ":", "_") } diff --git a/backend/services/tokenstore/dao/tokenstore.go b/backend/services/tokenstore/dao/tokenstore.go new file mode 100644 index 0000000..0323645 --- /dev/null +++ b/backend/services/tokenstore/dao/tokenstore.go @@ -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 +} diff --git a/backend/services/tokenstore/rpc/handler.go b/backend/services/tokenstore/rpc/handler.go index 7763d4b..92835d5 100644 --- a/backend/services/tokenstore/rpc/handler.go +++ b/backend/services/tokenstore/rpc/handler.go @@ -253,18 +253,21 @@ func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView { return nil } return &pb.TokenOrderView{ - OrderId: order.OrderID, - OrderNo: order.OrderNo, - Status: order.Status, - TokenAmount: order.TokenAmount, - AmountCent: order.AmountCent, - PriceText: order.PriceText, - Currency: order.Currency, - PaymentMode: order.PaymentMode, - Grant: tokenGrantToPB(order.Grant), - CreatedAt: order.CreatedAt, - PaidAt: tokenStringFromPtr(order.PaidAt), - GrantedAt: tokenStringFromPtr(order.GrantedAt), + OrderId: order.OrderID, + OrderNo: order.OrderNo, + Status: order.Status, + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + Grant: tokenGrantToPB(order.Grant), + CreatedAt: order.CreatedAt, + PaidAt: tokenStringFromPtr(order.PaidAt), + GrantedAt: tokenStringFromPtr(order.GrantedAt), + ProductSnapshot: order.ProductSnapshot, + ProductName: order.ProductName, + Quantity: int32(order.Quantity), } } diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go index 249f9e7..659032d 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -63,18 +63,21 @@ func (m *TokenGrantView) String() string { return proto.CompactTextString(m) } func (*TokenGrantView) ProtoMessage() {} type TokenOrderView struct { - OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` - OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` - Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_amount,omitempty"` - AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"` - PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"` - Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"` - PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"` - Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,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"` - GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"` + OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` + OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_amount,omitempty"` + AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"` + PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"` + Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"` + PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"` + Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,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"` + 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{} } diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto index bc3eb33..30c3738 100644 --- a/backend/services/tokenstore/rpc/tokenstore.proto +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -67,6 +67,9 @@ message TokenOrderView { string created_at = 10; string paid_at = 11; string granted_at = 12; + string product_snapshot = 13; + string product_name = 14; + int32 quantity = 15; } message GetTokenSummaryRequest { diff --git a/backend/services/tokenstore/sv/grant.go b/backend/services/tokenstore/sv/grant.go new file mode 100644 index 0000000..5338f1f --- /dev/null +++ b/backend/services/tokenstore/sv/grant.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/helpers.go b/backend/services/tokenstore/sv/helpers.go new file mode 100644 index 0000000..ada2491 --- /dev/null +++ b/backend/services/tokenstore/sv/helpers.go @@ -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), + } +} diff --git a/backend/services/tokenstore/sv/order.go b/backend/services/tokenstore/sv/order.go new file mode 100644 index 0000000..030f21d --- /dev/null +++ b/backend/services/tokenstore/sv/order.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/product.go b/backend/services/tokenstore/sv/product.go new file mode 100644 index 0000000..57d3532 --- /dev/null +++ b/backend/services/tokenstore/sv/product.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/service.go b/backend/services/tokenstore/sv/service.go index b2a360b..3dbcc44 100644 --- a/backend/services/tokenstore/sv/service.go +++ b/backend/services/tokenstore/sv/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" + tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" "gorm.io/gorm" ) @@ -35,12 +36,14 @@ type Options struct { // 3. 不负责真实第三方支付回调,P0 只处理 mock paid。 type Service struct { db *gorm.DB + tokenDAO *tokenstoredao.TokenStoreDAO grantOutlet TokenGrantOutlet } func New(opts Options) *Service { return &Service{ db: opts.DB, + tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB), grantOutlet: opts.GrantOutlet, } } @@ -55,53 +58,3 @@ func (s *Service) Ready() error { } return nil } - -// ListProducts 是商品列表用例占位,第四步实现真实查询。 -func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) { - _ = ctx - _ = actorUserID - return nil, ErrNotImplemented -} - -// GetSummary 是 Token 概览用例占位,第四步实现 grant 账本聚合。 -func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) { - _ = ctx - _ = actorUserID - return nil, ErrNotImplemented -} - -// CreateOrder 是创建订单用例占位,第四步实现商品读取、订单幂等和金额快照。 -func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// ListOrders 是订单列表用例占位,第四步实现用户维度分页查询。 -func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) { - _ = ctx - _ = req - return nil, tokencontracts.PageResult{}, ErrNotImplemented -} - -// GetOrder 是订单详情用例占位,第四步实现订单归属校验。 -func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = actorUserID - _ = orderID - return nil, ErrNotImplemented -} - -// MockPaidOrder 是 P0 mock paid 用例占位,第四步实现支付态流转和 grant 账本。 -func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// ListGrants 是 Token 获取记录用例占位,第四步实现账本分页查询。 -func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) { - _ = ctx - _ = req - return nil, tokencontracts.PageResult{}, ErrNotImplemented -} diff --git a/backend/shared/contracts/tokenstore/types.go b/backend/shared/contracts/tokenstore/types.go index ff98025..72e90e8 100644 --- a/backend/shared/contracts/tokenstore/types.go +++ b/backend/shared/contracts/tokenstore/types.go @@ -51,18 +51,21 @@ type TokenGrantView struct { // TokenOrderView 是订单展示结构。 type TokenOrderView struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - Status string `json:"status"` - 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 *TokenGrantView `json:"grant"` - CreatedAt string `json:"created_at"` - PaidAt *string `json:"paid_at"` - GrantedAt *string `json:"granted_at"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductSnapshot string `json:"product_snapshot"` + ProductName string `json:"product_name"` + Quantity int `json:"quantity"` + TokenAmount int64 `json:"token_amount"` + AmountCent int64 `json:"amount_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + PaymentMode string `json:"payment_mode"` + Grant *TokenGrantView `json:"grant"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + GrantedAt *string `json:"granted_at"` } // CreateTokenOrderRequest 是创建订单请求契约。