Version: 0.9.78.dev.260506

This commit is contained in:
Losita
2026-05-06 00:30:08 +08:00
parent 3b6fca44a6
commit 33227e48a7
71 changed files with 13137 additions and 62 deletions

View File

@@ -0,0 +1,470 @@
package taskclassforum
import (
"context"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"github.com/zeromicro/go-zero/zrpc"
)
const (
defaultEndpoint = "127.0.0.1:9090"
defaultTimeout = 2 * time.Second
)
type ClientConfig struct {
Endpoints []string
Target string
Timeout time.Duration
}
// Client 是 gateway 侧访问计划广场 zrpc 的适配层。
//
// 职责边界:
// 1. 只负责 HTTP gateway 与 taskclassforum zrpc 之间的协议转译;
// 2. 不直连 forum_* 表,也不读取旧 TaskClass 表,所有业务规则交给 taskclassforum 服务;
// 3. gRPC 业务错误会在这里反解回 respond.Response便于 HTTP 层统一返回。
type Client struct {
rpc pb.TaskClassForumServiceClient
}
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.NewTaskClassForumServiceClient(zclient.Conn())}, nil
}
func (c *Client) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, contracts.PageResult{}, err
}
resp, err := c.rpc.ListPosts(ctx, &pb.ListForumPostsRequest{
ActorUserId: actorUserID,
Page: int32(page),
PageSize: int32(pageSize),
Sort: sort,
Keyword: keyword,
Tag: tag,
})
if err != nil {
return nil, contracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list posts response")
}
return forumPostBriefsFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListTags(ctx, &pb.ListForumTagsRequest{
ActorUserId: actorUserID,
Limit: int32(limit),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty list tags response")
}
return forumTagItemsFromPB(resp.Items), nil
}
func (c *Client) CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CreatePost(ctx, &pb.CreateForumPostRequest{
ActorUserId: req.ActorUserID,
TaskClassId: req.TaskClassID,
Title: req.Title,
Summary: req.Summary,
Tags: append([]string(nil), req.Tags...),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty create post response")
}
post := forumPostBriefFromPB(resp.Post)
return &post, nil
}
func (c *Client) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetPost(ctx, &pb.GetForumPostRequest{
ActorUserId: actorUserID,
PostId: postID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty get post response")
}
data := forumPostDetailFromPB(resp.Data)
return &data, nil
}
func (c *Client) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
if err := c.ensureReady(); err != nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
}
resp, err := c.rpc.LikePost(ctx, &pb.LikeForumPostRequest{
ActorUserId: actorUserID,
PostId: postID,
})
if err != nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
}
if resp == nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty like response")
}
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
}
func (c *Client) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) {
if err := c.ensureReady(); err != nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err
}
resp, err := c.rpc.UnlikePost(ctx, &pb.UnlikeForumPostRequest{
ActorUserId: actorUserID,
PostId: postID,
})
if err != nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err)
}
if resp == nil {
return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty unlike response")
}
return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil
}
func (c *Client) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, contracts.PageResult{}, err
}
resp, err := c.rpc.ListComments(ctx, &pb.ListForumCommentsRequest{
ActorUserId: actorUserID,
PostId: postID,
Page: int32(page),
PageSize: int32(pageSize),
Sort: sort,
})
if err != nil {
return nil, contracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list comments response")
}
return forumCommentNodesFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CreateComment(ctx, &pb.CreateForumCommentRequest{
ActorUserId: req.ActorUserID,
PostId: req.PostID,
Content: req.Content,
ParentCommentId: uint64FromPtr(req.ParentCommentID),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty create comment response")
}
comment := forumCommentNodeFromPB(resp.Comment)
return &comment, nil
}
func (c *Client) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.DeleteComment(ctx, &pb.DeleteForumCommentRequest{
ActorUserId: actorUserID,
CommentId: commentID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty delete comment response")
}
deletedAt := time.Now().Format(time.RFC3339)
return &contracts.DeleteForumCommentResult{
CommentID: resp.CommentId,
Status: resp.Status,
Content: "",
DeletedAt: &deletedAt,
}, nil
}
func (c *Client) ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ImportPost(ctx, &pb.ImportForumPostRequest{
ActorUserId: req.ActorUserID,
PostId: req.PostID,
TargetTitle: req.TargetTitle,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("taskclassforum zrpc service returned empty import post response")
}
return &contracts.ImportForumPostResult{
ImportID: resp.ImportId,
PostID: resp.PostId,
NewTaskClassID: resp.NewTaskClassId,
TaskClassTitle: resp.TaskClassTitle,
ImportCount: resp.ImportCount,
CreatedAt: resp.CreatedAt,
}, nil
}
func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil {
return errors.New("taskclassforum 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) contracts.PageResult {
if page == nil {
return contracts.PageResult{}
}
return contracts.PageResult{
Page: int(page.Page),
PageSize: int(page.PageSize),
Total: int(page.Total),
HasMore: page.HasMore,
}
}
func forumUserFromPB(user *pb.UserBrief) contracts.UserBrief {
if user == nil {
return contracts.UserBrief{}
}
return contracts.UserBrief{
UserID: user.UserId,
Nickname: user.Nickname,
AvatarURL: user.AvatarUrl,
}
}
func forumTemplateSummaryFromPB(summary *pb.TemplateSummary) contracts.TemplateSummary {
if summary == nil {
return contracts.TemplateSummary{}
}
return contracts.TemplateSummary{
TaskCount: int(summary.TaskCount),
Mode: summary.Mode,
StartDate: summary.StartDate,
EndDate: summary.EndDate,
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
}
}
func forumPostCountersFromPB(counters *pb.ForumPostCounters) contracts.ForumPostCounters {
if counters == nil {
return contracts.ForumPostCounters{}
}
return contracts.ForumPostCounters{
LikeCount: counters.LikeCount,
CommentCount: counters.CommentCount,
ImportCount: counters.ImportCount,
}
}
func forumPostViewerStateFromPB(state *pb.ForumPostViewerState) contracts.ForumPostViewerState {
if state == nil {
return contracts.ForumPostViewerState{}
}
return contracts.ForumPostViewerState{
Liked: state.Liked,
ImportedOnce: state.ImportedOnce,
}
}
func forumPostBriefFromPB(post *pb.ForumPostBrief) contracts.ForumPostBrief {
if post == nil {
return contracts.ForumPostBrief{}
}
return contracts.ForumPostBrief{
PostID: post.PostId,
Title: post.Title,
Summary: post.Summary,
Tags: append([]string(nil), post.Tags...),
Author: forumUserFromPB(post.Author),
TemplateSummary: forumTemplateSummaryFromPB(post.TemplateSummary),
Counters: forumPostCountersFromPB(post.Counters),
ViewerState: forumPostViewerStateFromPB(post.ViewerState),
Status: post.Status,
CreatedAt: post.CreatedAt,
}
}
func forumPostBriefsFromPB(items []*pb.ForumPostBrief) []contracts.ForumPostBrief {
if len(items) == 0 {
return []contracts.ForumPostBrief{}
}
result := make([]contracts.ForumPostBrief, 0, len(items))
for _, item := range items {
result = append(result, forumPostBriefFromPB(item))
}
return result
}
func forumTemplateDetailFromPB(detail *pb.TemplateDetail) contracts.TemplateDetail {
if detail == nil {
return contracts.TemplateDetail{}
}
items := make([]contracts.TemplateItemPreview, 0, len(detail.ItemsPreview))
for _, item := range detail.ItemsPreview {
if item == nil {
continue
}
items = append(items, contracts.TemplateItemPreview{
ItemID: item.ItemId,
Order: int(item.Order),
Content: item.Content,
})
}
return contracts.TemplateDetail{
Mode: detail.Mode,
StartDate: detail.StartDate,
EndDate: detail.EndDate,
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
TaskCount: int(detail.TaskCount),
ItemsPreview: items,
}
}
func forumPostDetailFromPB(detail *pb.ForumPostDetail) contracts.ForumPostDetail {
if detail == nil {
return contracts.ForumPostDetail{}
}
return contracts.ForumPostDetail{
Post: forumPostBriefFromPB(detail.Post),
Template: forumTemplateDetailFromPB(detail.Template),
}
}
func forumTagItemsFromPB(items []*pb.ForumTagItem) []contracts.ForumTagItem {
if len(items) == 0 {
return []contracts.ForumTagItem{}
}
result := make([]contracts.ForumTagItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
result = append(result, contracts.ForumTagItem{
Tag: item.Tag,
PostCount: int(item.PostCount),
})
}
return result
}
func forumCommentNodeFromPB(node *pb.ForumCommentNode) contracts.ForumCommentNode {
if node == nil {
return contracts.ForumCommentNode{}
}
children := make([]contracts.ForumCommentNode, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, forumCommentNodeFromPB(child))
}
return contracts.ForumCommentNode{
CommentID: node.CommentId,
PostID: node.PostId,
ParentCommentID: uint64PtrFromPositive(node.ParentCommentId),
Content: node.Content,
Status: node.Status,
Author: forumUserFromPB(node.Author),
CanDelete: node.CanDelete,
CreatedAt: node.CreatedAt,
DeletedAt: stringPtrFromNonEmpty(node.DeletedAt),
Children: children,
}
}
func forumCommentNodesFromPB(items []*pb.ForumCommentNode) []contracts.ForumCommentNode {
if len(items) == 0 {
return []contracts.ForumCommentNode{}
}
result := make([]contracts.ForumCommentNode, 0, len(items))
for _, item := range items {
result = append(result, forumCommentNodeFromPB(item))
}
return result
}
func uint64FromPtr(value *uint64) uint64 {
if value == nil {
return 0
}
return *value
}
func uint64PtrFromPositive(value uint64) *uint64 {
if value == 0 {
return nil
}
result := value
return &result
}
func stringPtrFromNonEmpty(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}

View File

@@ -0,0 +1,94 @@
package taskclassforum
import (
"errors"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// responseFromRPCError 把计划广场 zrpc 错误恢复成 HTTP 层可处理的业务错误。
//
// 职责边界:
// 1. 优先读取 taskclassforum RPC 写入的 ErrorInfo恢复 respond.Response
// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error让 HTTP 层按 500 处理;
// 3. 暂不复用 userauth/errors.go因为 user/auth 还承担历史 legacy code 兼容,计划广场只消费新 ErrorInfo 协议。
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 = "taskclassforum zrpc service internal error"
}
return wrapRPCError(errors.New(msg))
case codes.NotFound:
return responseWithFallback(st, respond.UserTaskClassNotFound)
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 = "taskclassforum 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("调用 taskclassforum zrpc 服务失败: %w", err)
}

View File

@@ -0,0 +1,407 @@
package tokenstore
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/zeromicro/go-zero/zrpc"
)
const (
defaultEndpoint = "127.0.0.1:9095"
defaultTimeout = 2 * time.Second
)
type ClientConfig struct {
Endpoints []string
Target string
Timeout time.Duration
}
// ProductSnapshot 是订单详情里内嵌的商品快照。
//
// 职责边界:
// 1. 只承载 HTTP gateway 当前需要透出的商品摘要;
// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体;
// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底。
type ProductSnapshot struct {
ProductID uint64 `json:"product_id"`
Name string `json:"name"`
TokenAmount int64 `json:"token_amount"`
}
// OrderView 是 gateway 侧订单展示结构。
//
// 职责边界:
// 1. 复用 token-store contract 里已稳定的订单字段;
// 2. 为前端 P0 额外承载 product_snapshot / product_name / quantity 三个 HTTP 所需字段;
// 3. 不反向影响 shared/contracts等并行 worker 合入正式字段后可再收敛。
type OrderView struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Status string `json:"status"`
ProductSnapshot *ProductSnapshot `json:"product_snapshot,omitempty"`
ProductName string `json:"product_name,omitempty"`
Quantity int `json:"quantity"`
TokenAmount int64 `json:"token_amount"`
AmountCent int64 `json:"amount_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
PaymentMode string `json:"payment_mode"`
Grant *tokencontracts.TokenGrantView `json:"grant"`
CreatedAt string `json:"created_at"`
PaidAt *string `json:"paid_at"`
GrantedAt *string `json:"granted_at"`
}
// Client 是 gateway 侧访问 token-store zrpc 的适配层。
//
// 职责边界:
// 1. 只负责 HTTP gateway 与 token-store zrpc 之间的协议转译;
// 2. 不直连 token_* 表,也不承载订单/支付业务规则;
// 3. gRPC 业务错误会在这里反解回 respond.Response便于 HTTP 层统一返回。
type Client struct {
rpc pb.TokenStoreServiceClient
}
func NewClient(cfg ClientConfig) (*Client, error) {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
endpoints := normalizeEndpoints(cfg.Endpoints)
target := strings.TrimSpace(cfg.Target)
if len(endpoints) == 0 && target == "" {
endpoints = []string{defaultEndpoint}
}
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
Endpoints: endpoints,
Target: target,
NonBlock: true,
Timeout: int64(timeout / time.Millisecond),
})
if err != nil {
return nil, err
}
return &Client{rpc: pb.NewTokenStoreServiceClient(zclient.Conn())}, nil
}
func (c *Client) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetSummary(ctx, &pb.GetTokenSummaryRequest{ActorUserId: actorUserID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get summary response")
}
summary := tokenSummaryFromPB(resp.Summary)
return &summary, nil
}
func (c *Client) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListProducts(ctx, &pb.ListTokenProductsRequest{ActorUserId: actorUserID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty list products response")
}
return tokenProductsFromPB(resp.Items), nil
}
func (c *Client) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CreateOrder(ctx, &pb.CreateTokenOrderRequest{
ActorUserId: req.ActorUserID,
ProductId: req.ProductID,
Quantity: int32(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty create order response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]OrderView, tokencontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
resp, err := c.rpc.ListOrders(ctx, &pb.ListTokenOrdersRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list orders response")
}
return tokenOrdersFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetOrder(ctx, &pb.GetTokenOrderRequest{
ActorUserId: actorUserID,
OrderId: orderID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get order response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.MockPaidOrder(ctx, &pb.MockPaidOrderRequest{
ActorUserId: req.ActorUserID,
OrderId: req.OrderID,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty mock paid response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
resp, err := c.rpc.ListGrants(ctx, &pb.ListTokenGrantsRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Source: req.Source,
})
if err != nil {
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list grants response")
}
return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.RecordForumRewardGrant(ctx, &pb.RecordForumRewardGrantRequest{
EventId: req.EventID,
ReceiverUserId: req.ReceiverUserID,
Source: req.Source,
SourceRefId: req.SourceRefID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty record forum reward grant response")
}
return tokenGrantFromPB(resp.Grant), nil
}
func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil {
return errors.New("tokenstore zrpc client is not initialized")
}
return nil
}
func normalizeEndpoints(values []string) []string {
endpoints := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
endpoints = append(endpoints, trimmed)
}
}
return endpoints
}
func pageFromPB(page *pb.PageResponse) tokencontracts.PageResult {
if page == nil {
return tokencontracts.PageResult{}
}
return tokencontracts.PageResult{
Page: int(page.Page),
PageSize: int(page.PageSize),
Total: int(page.Total),
HasMore: page.HasMore,
}
}
func tokenSummaryFromPB(summary *pb.TokenSummary) tokencontracts.TokenSummary {
if summary == nil {
return tokencontracts.TokenSummary{}
}
return tokencontracts.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
QuotaSyncStatus: summary.QuotaSyncStatus,
Tip: summary.Tip,
}
}
func tokenProductFromPB(product *pb.TokenProductView) tokencontracts.TokenProductView {
if product == nil {
return tokencontracts.TokenProductView{}
}
return tokencontracts.TokenProductView{
ProductID: product.ProductId,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int(product.SortOrder),
}
}
func tokenProductsFromPB(items []*pb.TokenProductView) []tokencontracts.TokenProductView {
if len(items) == 0 {
return []tokencontracts.TokenProductView{}
}
result := make([]tokencontracts.TokenProductView, 0, len(items))
for _, item := range items {
result = append(result, tokenProductFromPB(item))
}
return result
}
func tokenGrantFromPB(grant *pb.TokenGrantView) *tokencontracts.TokenGrantView {
if grant == nil {
return nil
}
return &tokencontracts.TokenGrantView{
GrantID: grant.GrantId,
EventID: grant.EventId,
Source: grant.Source,
SourceLabel: grant.SourceLabel,
Amount: grant.Amount,
Status: grant.Status,
QuotaApplied: grant.QuotaApplied,
Description: grant.Description,
CreatedAt: grant.CreatedAt,
}
}
func tokenGrantsFromPB(items []*pb.TokenGrantView) []tokencontracts.TokenGrantView {
if len(items) == 0 {
return []tokencontracts.TokenGrantView{}
}
result := make([]tokencontracts.TokenGrantView, 0, len(items))
for _, item := range items {
if grant := tokenGrantFromPB(item); grant != nil {
result = append(result, *grant)
}
}
return result
}
func tokenOrderFromPB(order *pb.TokenOrderView) OrderView {
if order == nil {
return OrderView{}
}
productSnapshot := tokenProductSnapshotFromJSON(order.ProductSnapshot)
productName := strings.TrimSpace(order.ProductName)
if productName == "" && productSnapshot != nil {
productName = productSnapshot.Name
}
return OrderView{
OrderID: order.OrderId,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: productSnapshot,
ProductName: productName,
Quantity: int(order.Quantity),
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: tokenGrantFromPB(order.Grant),
CreatedAt: order.CreatedAt,
PaidAt: stringPtrFromNonEmpty(order.PaidAt),
GrantedAt: stringPtrFromNonEmpty(order.GrantedAt),
}
}
func tokenOrdersFromPB(items []*pb.TokenOrderView) []OrderView {
if len(items) == 0 {
return []OrderView{}
}
result := make([]OrderView, 0, len(items))
for _, item := range items {
result = append(result, tokenOrderFromPB(item))
}
return result
}
// tokenProductSnapshotFromJSON 负责把 RPC 内部快照字符串转成 HTTP 展示对象。
//
// 职责边界:
// 1. 只解析 product_id / name / token_amount 三个前端需要的字段;
// 2. 不把解析失败暴露成接口错误,避免历史脏快照影响订单主流程展示;
// 3. 不反查商品表,订单详情必须以当时下单快照为准。
func tokenProductSnapshotFromJSON(raw string) *ProductSnapshot {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var snapshot ProductSnapshot
if err := json.Unmarshal([]byte(trimmed), &snapshot); err != nil {
return nil
}
if snapshot.ProductID == 0 && snapshot.Name == "" && snapshot.TokenAmount == 0 {
return nil
}
return &snapshot
}
func stringPtrFromNonEmpty(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}

View File

@@ -0,0 +1,92 @@
package tokenstore
import (
"errors"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/shared/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)
}