Version: 0.9.78.dev.260506
This commit is contained in:
470
backend/client/taskclassforum/client.go
Normal file
470
backend/client/taskclassforum/client.go
Normal 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
|
||||||
|
}
|
||||||
94
backend/client/taskclassforum/errors.go
Normal file
94
backend/client/taskclassforum/errors.go
Normal 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)
|
||||||
|
}
|
||||||
407
backend/client/tokenstore/client.go
Normal file
407
backend/client/tokenstore/client.go
Normal 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
|
||||||
|
}
|
||||||
92
backend/client/tokenstore/errors.go
Normal file
92
backend/client/tokenstore/errors.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule"
|
scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule"
|
||||||
taskclient "github.com/LoveLosita/smartflow/backend/client/task"
|
taskclient "github.com/LoveLosita/smartflow/backend/client/task"
|
||||||
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
|
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
|
||||||
|
taskclassforumclient "github.com/LoveLosita/smartflow/backend/client/taskclassforum"
|
||||||
|
tokenstoreclient "github.com/LoveLosita/smartflow/backend/client/tokenstore"
|
||||||
userauthclient "github.com/LoveLosita/smartflow/backend/client/userauth"
|
userauthclient "github.com/LoveLosita/smartflow/backend/client/userauth"
|
||||||
coreinit "github.com/LoveLosita/smartflow/backend/cmd/internal/coreinit"
|
coreinit "github.com/LoveLosita/smartflow/backend/cmd/internal/coreinit"
|
||||||
"github.com/LoveLosita/smartflow/backend/gateway/api"
|
"github.com/LoveLosita/smartflow/backend/gateway/api"
|
||||||
@@ -84,6 +86,8 @@ type appRuntime struct {
|
|||||||
limiter *ratelimit.RateLimiter
|
limiter *ratelimit.RateLimiter
|
||||||
handlers *api.ApiHandlers
|
handlers *api.ApiHandlers
|
||||||
userAuthClient *userauthclient.Client
|
userAuthClient *userauthclient.Client
|
||||||
|
forumClient *taskclassforumclient.Client
|
||||||
|
tokenClient *tokenstoreclient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 锻炼?
|
// loadConfig 锻炼?
|
||||||
@@ -192,6 +196,22 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize notification zrpc client: %w", err)
|
return nil, fmt.Errorf("failed to initialize notification zrpc client: %w", err)
|
||||||
}
|
}
|
||||||
|
forumClient, err := taskclassforumclient.NewClient(taskclassforumclient.ClientConfig{
|
||||||
|
Endpoints: viper.GetStringSlice("taskclassforum.rpc.endpoints"),
|
||||||
|
Target: viper.GetString("taskclassforum.rpc.target"),
|
||||||
|
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err)
|
||||||
|
}
|
||||||
|
tokenClient, err := tokenstoreclient.NewClient(tokenstoreclient.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)
|
||||||
|
}
|
||||||
scheduleClient, err := scheduleclient.NewClient(scheduleclient.ClientConfig{
|
scheduleClient, err := scheduleclient.NewClient(scheduleclient.ClientConfig{
|
||||||
Endpoints: viper.GetStringSlice("schedule.rpc.endpoints"),
|
Endpoints: viper.GetStringSlice("schedule.rpc.endpoints"),
|
||||||
Target: viper.GetString("schedule.rpc.target"),
|
Target: viper.GetString("schedule.rpc.target"),
|
||||||
@@ -380,6 +400,8 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
handlers: handlers,
|
handlers: handlers,
|
||||||
userAuthClient: userAuthClient,
|
userAuthClient: userAuthClient,
|
||||||
|
forumClient: forumClient,
|
||||||
|
tokenClient: tokenClient,
|
||||||
}
|
}
|
||||||
return runtime, nil
|
return runtime, nil
|
||||||
}
|
}
|
||||||
@@ -861,7 +883,7 @@ func (r *appRuntime) startWorkers(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *appRuntime) startHTTP(ctx context.Context) {
|
func (r *appRuntime) startHTTP(ctx context.Context) {
|
||||||
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.cacheRepo, r.limiter)
|
router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.forumClient, r.tokenClient, r.cacheRepo, r.limiter)
|
||||||
gatewayrouter.StartEngine(ctx, router)
|
gatewayrouter.StartEngine(ctx, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
98
backend/cmd/taskclassforum/main.go
Normal file
98
backend/cmd/taskclassforum/main.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
|
||||||
|
redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := bootstrap.LoadConfig(); err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
db, err := forumdao.OpenDBFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect taskclassforum database: %v", err)
|
||||||
|
}
|
||||||
|
if err := registerForumRewardOutboxRoutes(); err != nil {
|
||||||
|
log.Fatalf("failed to register taskclassforum outbox routes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskClassClient, err := taskclassclient.NewClient(taskclassclient.ClientConfig{
|
||||||
|
Endpoints: viper.GetStringSlice("taskClass.rpc.endpoints"),
|
||||||
|
Target: viper.GetString("taskClass.rpc.target"),
|
||||||
|
Timeout: viper.GetDuration("taskClass.rpc.timeout"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize task-class zrpc client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 论坛服务只依赖 TaskClass 快照端口,不直接操作 task_classes / task_items 物理表。
|
||||||
|
// 2. 当前实现通过 task-class zrpc 读取/创建副本,保持 dev 主干的微服务边界不回退。
|
||||||
|
// 3. 后续若 task-class 契约扩展,只需替换 adapter 内部映射,不需要改论坛业务层。
|
||||||
|
taskClassPort := adapter.NewTaskClassRPCAdapter(taskClassClient)
|
||||||
|
eventPublisher := outboxinfra.NewRepositoryPublisher(outboxinfra.NewRepository(db), viper.GetInt("kafka.maxRetry"))
|
||||||
|
|
||||||
|
commentTreeCache := forumsv.CommentTreeCachePort(nil)
|
||||||
|
if rdb, redisErr := redisinfra.OpenRedisFromConfig(); redisErr != nil {
|
||||||
|
log.Printf("taskclassforum 评论树缓存已降级关闭,Redis 连接失败: %v", redisErr)
|
||||||
|
} else {
|
||||||
|
defer rdb.Close()
|
||||||
|
commentTreeCache = forumdao.NewCommentTreeCache(rdb)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := forumsv.New(forumsv.Options{
|
||||||
|
DB: db,
|
||||||
|
TaskClassPort: taskClassPort,
|
||||||
|
EventPublisher: eventPublisher,
|
||||||
|
CommentTreeCache: commentTreeCache,
|
||||||
|
})
|
||||||
|
|
||||||
|
server, listenOn, err := forumrpc.NewServer(forumrpc.ServerOptions{
|
||||||
|
ListenOn: viper.GetString("taskclassforum.rpc.listenOn"),
|
||||||
|
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
||||||
|
Service: svc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build taskclassforum zrpc server: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("taskclassforum zrpc service starting on %s", listenOn)
|
||||||
|
server.Start()
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("taskclassforum service stopping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerForumRewardOutboxRoutes 负责让独立 taskclassforum RPC 进程认识奖励事件的落表归属。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 点赞、导入事件都由 token-store 消费并写 token_grants,所以事件路由归属 token-store;
|
||||||
|
// 2. taskclassforum 进程只负责发布事件,不启动 consumer,也不直接写奖励账本;
|
||||||
|
// 3. 若注册失败直接阻止启动,避免后续点赞/导入看似成功但 outbox 永远无法入队。
|
||||||
|
func registerForumRewardOutboxRoutes() error {
|
||||||
|
if err := outboxinfra.RegisterEventService(sharedevents.ForumPostLikedEventType, outboxinfra.ServiceTokenStore); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return outboxinfra.RegisterEventService(sharedevents.ForumPostImportedEventType, outboxinfra.ServiceTokenStore)
|
||||||
|
}
|
||||||
67
backend/cmd/tokenstore/main.go
Normal file
67
backend/cmd/tokenstore/main.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||||
|
tokenstorerpc "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
|
||||||
|
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := bootstrap.LoadConfig(); err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
db, err := tokenstoredao.OpenDBFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect tokenstore database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := tokenstoresv.New(tokenstoresv.Options{DB: db})
|
||||||
|
|
||||||
|
outboxRepo := outboxinfra.NewRepository(db)
|
||||||
|
eventBus, err := outboxinfra.NewEventBus(outboxRepo, kafkabus.LoadConfig())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize tokenstore outbox bus: %v", err)
|
||||||
|
}
|
||||||
|
if eventBus != nil {
|
||||||
|
if err := tokenstoresv.RegisterForumRewardHandlers(eventBus, outboxRepo, svc); err != nil {
|
||||||
|
log.Fatalf("failed to register tokenstore outbox handlers: %v", err)
|
||||||
|
}
|
||||||
|
eventBus.Start(ctx)
|
||||||
|
defer eventBus.Close()
|
||||||
|
log.Println("Tokenstore outbox consumer started")
|
||||||
|
} else {
|
||||||
|
log.Println("Tokenstore outbox consumer is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
server, listenOn, err := tokenstorerpc.NewServer(tokenstorerpc.ServerOptions{
|
||||||
|
ListenOn: viper.GetString("tokenstore.rpc.listenOn"),
|
||||||
|
Timeout: viper.GetDuration("tokenstore.rpc.timeout"),
|
||||||
|
Service: svc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build tokenstore zrpc server: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("tokenstore zrpc service starting on %s", listenOn)
|
||||||
|
server.Start()
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("tokenstore service stopping")
|
||||||
|
}
|
||||||
@@ -37,6 +37,25 @@ userauth:
|
|||||||
- "127.0.0.1:9081"
|
- "127.0.0.1:9081"
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
|
|
||||||
|
# 计划广场 zrpc 独立服务与网关客户端配置。
|
||||||
|
taskclassforum:
|
||||||
|
rpc:
|
||||||
|
listenOn: "0.0.0.0:9090"
|
||||||
|
endpoints:
|
||||||
|
- "127.0.0.1:9090"
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# Token 商店 zrpc 独立服务与网关客户端配置。
|
||||||
|
tokenstore:
|
||||||
|
reward:
|
||||||
|
forumLikeAmount: 1
|
||||||
|
forumImportAmount: 5
|
||||||
|
rpc:
|
||||||
|
listenOn: "0.0.0.0:9095"
|
||||||
|
endpoints:
|
||||||
|
- "127.0.0.1:9095"
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
# Kafka outbox 事件总线配置。
|
# Kafka outbox 事件总线配置。
|
||||||
kafka:
|
kafka:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
434
backend/gateway/api/forumapi/handler.go
Normal file
434
backend/gateway/api/forumapi/handler.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
package forumapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||||
|
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
requestTimeout = 2 * time.Second
|
||||||
|
forumLikeRewardAmount = int64(1)
|
||||||
|
forumImportRewardAmount = int64(5)
|
||||||
|
rewardHintStatusActive = "rule_active"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ForumClient interface {
|
||||||
|
ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error)
|
||||||
|
ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error)
|
||||||
|
CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error)
|
||||||
|
GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error)
|
||||||
|
LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||||
|
UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error)
|
||||||
|
ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error)
|
||||||
|
CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error)
|
||||||
|
DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error)
|
||||||
|
ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
client ForumClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(client ForumClient) *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 interactionEnvelope struct {
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Liked bool `json:"liked"`
|
||||||
|
LikeCount int64 `json:"like_count"`
|
||||||
|
RewardHint *rewardHint `json:"reward_hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardHint struct {
|
||||||
|
Receiver string `json:"receiver"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nextAction struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importEnvelope struct {
|
||||||
|
ImportID uint64 `json:"import_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
NewTaskClassID uint64 `json:"new_task_class_id"`
|
||||||
|
TaskClassTitle string `json:"task_class_title"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
RewardHint rewardHint `json:"reward_hint"`
|
||||||
|
NextAction nextAction `json:"next_action"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteCommentEnvelope struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPostBody struct {
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createCommentBody struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importPostBody struct {
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPosts(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, page, err := client.ListPosts(
|
||||||
|
ctx,
|
||||||
|
currentUserID(c),
|
||||||
|
pageValue,
|
||||||
|
pageSize,
|
||||||
|
c.Query("sort"),
|
||||||
|
c.Query("keyword"),
|
||||||
|
c.Query("tag"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListTags(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
limit, ok := intQuery(c, "limit")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := client.ListTags(ctx, currentUserID(c), limit)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createPostBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
post, err := client.CreatePost(ctx, contracts.CreateForumPostRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
TaskClassID: body.TaskClassID,
|
||||||
|
Title: body.Title,
|
||||||
|
Summary: body.Summary,
|
||||||
|
Tags: append([]string(nil), body.Tags...),
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, post))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
detail, err := client.GetPost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LikePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
counters, state, err := client.LikePost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||||
|
PostID: postID,
|
||||||
|
Liked: state.Liked,
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
RewardHint: &rewardHint{
|
||||||
|
Receiver: "author",
|
||||||
|
Status: rewardHintStatusActive,
|
||||||
|
Amount: forumLikeRewardAmount,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnlikePost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
counters, state, err := client.UnlikePost(ctx, currentUserID(c), postID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{
|
||||||
|
PostID: postID,
|
||||||
|
Liked: state.Liked,
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListComments(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, page, err := client.ListComments(ctx, currentUserID(c), postID, pageValue, pageSize, c.Query("sort"))
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateComment(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createCommentBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
comment, err := client.CreateComment(ctx, contracts.CreateForumCommentRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
PostID: postID,
|
||||||
|
Content: body.Content,
|
||||||
|
ParentCommentID: body.ParentCommentID,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, comment))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteComment(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentID, ok := uint64Param(c, "comment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := client.DeleteComment(ctx, currentUserID(c), commentID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, deleteCommentEnvelope{
|
||||||
|
CommentID: result.CommentID,
|
||||||
|
Status: result.Status,
|
||||||
|
Content: result.Content,
|
||||||
|
DeletedAt: result.DeletedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportPost(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postID, ok := uint64Param(c, "post_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body importPostBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
result, err := client.ImportPost(ctx, contracts.ImportForumPostRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
PostID: postID,
|
||||||
|
TargetTitle: body.TargetTitle,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, importEnvelope{
|
||||||
|
ImportID: result.ImportID,
|
||||||
|
PostID: result.PostID,
|
||||||
|
NewTaskClassID: result.NewTaskClassID,
|
||||||
|
TaskClassTitle: result.TaskClassTitle,
|
||||||
|
ImportCount: result.ImportCount,
|
||||||
|
RewardHint: rewardHint{
|
||||||
|
Receiver: "author",
|
||||||
|
Status: rewardHintStatusActive,
|
||||||
|
Amount: forumImportRewardAmount,
|
||||||
|
},
|
||||||
|
NextAction: nextAction{
|
||||||
|
Type: "open_task_class",
|
||||||
|
TaskClassID: result.NewTaskClassID,
|
||||||
|
},
|
||||||
|
CreatedAt: result.CreatedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ready(c *gin.Context) (ForumClient, bool) {
|
||||||
|
if h == nil || h.client == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场 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 newPageEnvelope[T any](items []T, page contracts.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
|
||||||
|
}
|
||||||
87
backend/gateway/api/forumapi/routes.go
Normal file
87
backend/gateway/api/forumapi/routes.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package forumapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
|
||||||
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||||
|
ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes 把计划广场 HTTP 入口挂到 gateway 路由组。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只注册 /plan-square 下的边缘路由,不承载论坛业务规则;
|
||||||
|
// 2. 公开读接口允许匿名访问,若携带 token 则补齐 viewer_state;
|
||||||
|
// 3. 写接口必须登录,并按既有 Redis 幂等中间件保护重复提交。
|
||||||
|
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *ratelimit.RateLimiter) {
|
||||||
|
if apiGroup == nil || handler == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
planSquare := apiGroup.Group("/plan-square")
|
||||||
|
{
|
||||||
|
publicGroup := planSquare.Group("")
|
||||||
|
publicGroup.Use(optionalJWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 40, 1))
|
||||||
|
publicGroup.GET("/posts", handler.ListPosts)
|
||||||
|
publicGroup.GET("/tags", handler.ListTags)
|
||||||
|
publicGroup.GET("/posts/:post_id", handler.GetPost)
|
||||||
|
publicGroup.GET("/posts/:post_id/comments", handler.ListComments)
|
||||||
|
|
||||||
|
writeGroup := planSquare.Group("")
|
||||||
|
writeGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||||
|
writeGroup.POST("/posts", rootmiddleware.IdempotencyMiddleware(cache), handler.CreatePost)
|
||||||
|
writeGroup.POST("/posts/:post_id/like", handler.LikePost)
|
||||||
|
writeGroup.DELETE("/posts/:post_id/like", handler.UnlikePost)
|
||||||
|
writeGroup.POST("/posts/:post_id/comments", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateComment)
|
||||||
|
writeGroup.DELETE("/comments/:comment_id", handler.DeleteComment)
|
||||||
|
writeGroup.POST("/posts/:post_id/import", rootmiddleware.IdempotencyMiddleware(cache), handler.ImportPost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalJWTTokenAuth 为计划广场公开读接口提供“可登录增强”。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 没有 Authorization 时直接放行,让匿名用户也能浏览计划广场;
|
||||||
|
// 2. 有 Authorization 时复用 user/auth 校验,并把 user_id 写入上下文;
|
||||||
|
// 3. token 非法时按正常鉴权失败返回,避免前端误以为已登录状态仍可用。
|
||||||
|
func optionalJWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tokenString := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||||
|
if tokenString == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if validator == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场可选鉴权依赖未初始化")))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := validator.ValidateAccessToken(ctx, tokenString)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil || !resp.Valid || resp.UserID <= 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user_id", resp.UserID)
|
||||||
|
c.Set("claims", resp)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
390
backend/gateway/api/tokenstoreapi/handler.go
Normal file
390
backend/gateway/api/tokenstoreapi/handler.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
package tokenstoreapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/client/tokenstore"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
type TokenStoreClient interface {
|
||||||
|
GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error)
|
||||||
|
ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error)
|
||||||
|
CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*gatewaytokenstore.OrderView, error)
|
||||||
|
ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]gatewaytokenstore.OrderView, tokencontracts.PageResult, error)
|
||||||
|
GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*gatewaytokenstore.OrderView, error)
|
||||||
|
MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*gatewaytokenstore.OrderView, error)
|
||||||
|
ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
client TokenStoreClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(client TokenStoreClient) *Handler {
|
||||||
|
return &Handler{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageEnvelope[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type paymentAction struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderCreateEnvelope struct {
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMode string `json:"payment_mode"`
|
||||||
|
PaymentAction paymentAction `json:"payment_action"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderListItemEnvelope struct {
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PaidAt *string `json:"paid_at"`
|
||||||
|
GrantedAt *string `json:"granted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderDetailEnvelope struct {
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMode string `json:"payment_mode"`
|
||||||
|
Grant *tokencontracts.TokenGrantView `json:"grant"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PaidAt *string `json:"paid_at"`
|
||||||
|
GrantedAt *string `json:"granted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createOrderBody struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockPaidBody struct {
|
||||||
|
MockChannel string `json:"mock_channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetSummary(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
summary, err := client.GetSummary(ctx, currentUserID(c))
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListProducts(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
items, err := client.ListProducts(ctx, currentUserID(c))
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateOrder(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createOrderBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
order, err := client.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
ProductID: body.ProductID,
|
||||||
|
Quantity: body.Quantity,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderCreateEnvelope(order)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListOrders(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
items, page, err := client.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
Page: pageValue,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Status: c.Query("status"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(newOrderListItemEnvelopes(items), page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetOrder(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, ok := uint64Param(c, "order_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
order, err := client.GetOrder(ctx, currentUserID(c), orderID)
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MockPaidOrder(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, ok := uint64Param(c, "order_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body mockPaidBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
order, err := client.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
OrderID: orderID,
|
||||||
|
MockChannel: body.MockChannel,
|
||||||
|
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListGrants(c *gin.Context) {
|
||||||
|
client, ok := h.ready(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageValue, ok := intQuery(c, "page")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageSize, ok := intQuery(c, "page_size")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
items, page, err := client.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
|
||||||
|
ActorUserID: currentUserID(c),
|
||||||
|
Page: pageValue,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Source: c.Query("source"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respond.DealWithError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ready(c *gin.Context) (TokenStoreClient, bool) {
|
||||||
|
if h == nil || h.client == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token-store gateway client 未初始化")))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return h.client, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserID(c *gin.Context) uint64 {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
if userID <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderCreateEnvelope(order *gatewaytokenstore.OrderView) orderCreateEnvelope {
|
||||||
|
if order == nil {
|
||||||
|
return orderCreateEnvelope{
|
||||||
|
PaymentAction: paymentAction{
|
||||||
|
Type: "mock_paid",
|
||||||
|
Label: "确认支付",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orderCreateEnvelope{
|
||||||
|
OrderID: order.OrderID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
Status: order.Status,
|
||||||
|
ProductSnapshot: order.ProductSnapshot,
|
||||||
|
Quantity: order.Quantity,
|
||||||
|
TokenAmount: order.TokenAmount,
|
||||||
|
AmountCent: order.AmountCent,
|
||||||
|
PriceText: order.PriceText,
|
||||||
|
Currency: order.Currency,
|
||||||
|
PaymentMode: order.PaymentMode,
|
||||||
|
PaymentAction: paymentAction{
|
||||||
|
Type: "mock_paid",
|
||||||
|
Label: "确认支付",
|
||||||
|
},
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderListItemEnvelopes(items []gatewaytokenstore.OrderView) []orderListItemEnvelope {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []orderListItemEnvelope{}
|
||||||
|
}
|
||||||
|
result := make([]orderListItemEnvelope, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
productName := item.ProductName
|
||||||
|
if productName == "" && item.ProductSnapshot != nil {
|
||||||
|
productName = item.ProductSnapshot.Name
|
||||||
|
}
|
||||||
|
result = append(result, orderListItemEnvelope{
|
||||||
|
OrderID: item.OrderID,
|
||||||
|
OrderNo: item.OrderNo,
|
||||||
|
Status: item.Status,
|
||||||
|
ProductName: productName,
|
||||||
|
TokenAmount: item.TokenAmount,
|
||||||
|
PriceText: item.PriceText,
|
||||||
|
CreatedAt: item.CreatedAt,
|
||||||
|
PaidAt: item.PaidAt,
|
||||||
|
GrantedAt: item.GrantedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderDetailEnvelope(order *gatewaytokenstore.OrderView) orderDetailEnvelope {
|
||||||
|
if order == nil {
|
||||||
|
return orderDetailEnvelope{}
|
||||||
|
}
|
||||||
|
return orderDetailEnvelope{
|
||||||
|
OrderID: order.OrderID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
Status: order.Status,
|
||||||
|
ProductSnapshot: order.ProductSnapshot,
|
||||||
|
Quantity: order.Quantity,
|
||||||
|
TokenAmount: order.TokenAmount,
|
||||||
|
AmountCent: order.AmountCent,
|
||||||
|
PriceText: order.PriceText,
|
||||||
|
Currency: order.Currency,
|
||||||
|
PaymentMode: order.PaymentMode,
|
||||||
|
Grant: order.Grant,
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
PaidAt: order.PaidAt,
|
||||||
|
GrantedAt: order.GrantedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPageEnvelope[T any](items []T, page tokencontracts.PageResult) pageEnvelope[T] {
|
||||||
|
return pageEnvelope[T]{
|
||||||
|
Items: items,
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: page.Total,
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intQuery(c *gin.Context, key string) (int, bool) {
|
||||||
|
raw := strings.TrimSpace(c.Query(key))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64Param(c *gin.Context, key string) (uint64, bool) {
|
||||||
|
value, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64)
|
||||||
|
if err != nil || value == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
34
backend/gateway/api/tokenstoreapi/routes.go
Normal file
34
backend/gateway/api/tokenstoreapi/routes.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package tokenstoreapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
|
||||||
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
|
ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit"
|
||||||
|
"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 *ratelimit.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
taskclassforumclient "github.com/LoveLosita/smartflow/backend/client/taskclassforum"
|
||||||
|
tokenstoreclient "github.com/LoveLosita/smartflow/backend/client/tokenstore"
|
||||||
"github.com/LoveLosita/smartflow/backend/gateway/api"
|
"github.com/LoveLosita/smartflow/backend/gateway/api"
|
||||||
|
forumapi "github.com/LoveLosita/smartflow/backend/gateway/api/forumapi"
|
||||||
|
tokenstoreapi "github.com/LoveLosita/smartflow/backend/gateway/api/tokenstoreapi"
|
||||||
userauthapi "github.com/LoveLosita/smartflow/backend/gateway/api/userauth"
|
userauthapi "github.com/LoveLosita/smartflow/backend/gateway/api/userauth"
|
||||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||||
@@ -55,7 +59,14 @@ func StartEngine(ctx context.Context, r *gin.Engine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, cache *dao.CacheDAO, limiter *ratelimit.RateLimiter) *gin.Engine {
|
func RegisterRouters(
|
||||||
|
handlers *api.ApiHandlers,
|
||||||
|
authClient ports.UserAuthClient,
|
||||||
|
forumClient *taskclassforumclient.Client,
|
||||||
|
tokenStoreClient *tokenstoreclient.Client,
|
||||||
|
cache *dao.CacheDAO,
|
||||||
|
limiter *ratelimit.RateLimiter,
|
||||||
|
) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
apiGroup := r.Group("/api/v1")
|
apiGroup := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
@@ -67,6 +78,8 @@ func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient,
|
|||||||
})
|
})
|
||||||
|
|
||||||
userauthapi.RegisterRoutes(apiGroup, userauthapi.NewUserHandler(authClient), authClient, limiter)
|
userauthapi.RegisterRoutes(apiGroup, userauthapi.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")
|
taskGroup := apiGroup.Group("/task")
|
||||||
{
|
{
|
||||||
|
|||||||
329
backend/services/taskclassforum/adapter/taskclass_rpc.go
Normal file
329
backend/services/taskclassforum/adapter/taskclass_rpc.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
taskclassclient "github.com/LoveLosita/smartflow/backend/client/taskclass"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errTaskClassRPCAdapterNotReady = errors.New("taskclassforum task-class rpc adapter is not initialized")
|
||||||
|
|
||||||
|
// TaskClassRPCAdapter 负责把 task-class 独立服务适配成计划广场需要的快照端口。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只通过 task-class zrpc 读取/创建任务类,不直连 task_classes / task_items 物理表;
|
||||||
|
// 2. 只暴露论坛导入/发布需要的白名单快照语义,不透传 schedule 写入能力;
|
||||||
|
// 3. 论坛业务层只依赖快照端口,后续 task-class 契约继续演进时只改这一层。
|
||||||
|
type TaskClassRPCAdapter struct {
|
||||||
|
client *taskclassclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ forumsv.TaskClassSnapshotPort = (*TaskClassRPCAdapter)(nil)
|
||||||
|
|
||||||
|
// NewTaskClassRPCAdapter 创建基于 task-class zrpc 的论坛快照适配器。
|
||||||
|
func NewTaskClassRPCAdapter(client *taskclassclient.Client) *TaskClassRPCAdapter {
|
||||||
|
return &TaskClassRPCAdapter{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOwnedTaskClassSnapshot 读取当前用户自己的 TaskClass,并投影为论坛可分享快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只读取当前用户可见的单个 TaskClass;
|
||||||
|
// 2. 只返回论坛白名单字段和条目 source id/order/content;
|
||||||
|
// 3. 不透传 embedded_time、status 和任何 schedule 绑定细节。
|
||||||
|
func (a *TaskClassRPCAdapter) GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*forumsv.TaskClassSnapshot, error) {
|
||||||
|
if err := a.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDInt, err := toUserID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taskClassIDInt, err := toTaskClassID(taskClassID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := a.client.GetAgentTaskClasses(ctx, taskclasscontracts.AgentTaskClassesRequest{
|
||||||
|
UserID: userIDInt,
|
||||||
|
TaskClassIDs: []int{taskClassIDInt},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp taskclasscontracts.AgentTaskClassesResponse
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.TaskClasses) == 0 {
|
||||||
|
return nil, respond.UserTaskClassNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := snapshotFromTaskClass(resp.TaskClasses[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskClassFromSnapshot 基于论坛模板快照为当前用户创建 task-class 服务里的副本。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只创建 task-class 主体与 items,不写 forum_imports;
|
||||||
|
// 2. 所有 item 都作为新记录创建,不沿用原任务条目的 ID;
|
||||||
|
// 3. 不写 schedule,导入后仍保持“当前用户自己的未安排副本”语义。
|
||||||
|
func (a *TaskClassRPCAdapter) CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot forumsv.TaskClassSnapshot, targetTitle string) (*forumsv.CreatedTaskClass, error) {
|
||||||
|
if err := a.ensureReady(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDInt, err := toUserID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(targetTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(snapshot.Title)
|
||||||
|
}
|
||||||
|
if title == "" || strings.TrimSpace(snapshot.Mode) == "" {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := parseSnapshotDateRange(snapshot.Mode, snapshot.StartDate, snapshot.EndDate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := a.client.AddTaskClass(ctx, buildUpsertTaskClassRequest(userIDInt, title, snapshot))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var created taskclasscontracts.UpsertTaskClassResponse
|
||||||
|
if err := json.Unmarshal(raw, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if created.TaskClassID <= 0 {
|
||||||
|
return nil, respond.InternalError(errors.New("task-class rpc add response missing task_class_id"))
|
||||||
|
}
|
||||||
|
return &forumsv.CreatedTaskClass{
|
||||||
|
TaskClassID: uint64(created.TaskClassID),
|
||||||
|
Title: title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFromTaskClass(taskClass taskclasscontracts.AgentTaskClass) (forumsv.TaskClassSnapshot, error) {
|
||||||
|
items := snapshotItemsFromTaskClassItems(taskClass.Items)
|
||||||
|
snapshot := forumsv.TaskClassSnapshot{
|
||||||
|
TaskClassID: uint64(taskClass.ID),
|
||||||
|
Title: strings.TrimSpace(taskClass.Name),
|
||||||
|
Mode: strings.TrimSpace(taskClass.Mode),
|
||||||
|
StartDate: strings.TrimSpace(taskClass.StartDate),
|
||||||
|
EndDate: strings.TrimSpace(taskClass.EndDate),
|
||||||
|
SubjectType: strings.TrimSpace(taskClass.SubjectType),
|
||||||
|
DifficultyLevel: strings.TrimSpace(taskClass.DifficultyLevel),
|
||||||
|
CognitiveIntensity: strings.TrimSpace(taskClass.CognitiveIntensity),
|
||||||
|
TotalSlots: taskClass.TotalSlots,
|
||||||
|
AllowFillerCourse: taskClass.AllowFillerCourse,
|
||||||
|
Strategy: strings.TrimSpace(taskClass.Strategy),
|
||||||
|
ExcludedSlots: cloneIntSlice(taskClass.ExcludedSlots),
|
||||||
|
ExcludedDaysOfWeek: cloneIntSlice(taskClass.ExcludedDaysOfWeek),
|
||||||
|
StrategyLabels: strategyLabels(taskClass.Strategy),
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := buildConfigSnapshotJSON(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return forumsv.TaskClassSnapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot.ConfigSnapshotJSON = configJSON
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotItemsFromTaskClassItems(items []taskclasscontracts.AgentTaskClassItem) []forumsv.TaskClassSnapshotItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []forumsv.TaskClassSnapshotItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := append([]taskclasscontracts.AgentTaskClassItem(nil), items...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
leftOrder := derefInt(sorted[i].Order)
|
||||||
|
rightOrder := derefInt(sorted[j].Order)
|
||||||
|
if leftOrder != rightOrder {
|
||||||
|
return leftOrder < rightOrder
|
||||||
|
}
|
||||||
|
return sorted[i].ID < sorted[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]forumsv.TaskClassSnapshotItem, 0, len(sorted))
|
||||||
|
for _, item := range sorted {
|
||||||
|
content := strings.TrimSpace(item.Content)
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, forumsv.TaskClassSnapshotItem{
|
||||||
|
TaskItemID: uint64(item.ID),
|
||||||
|
Order: derefInt(item.Order),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUpsertTaskClassRequest(userID int, title string, snapshot forumsv.TaskClassSnapshot) taskclasscontracts.UpsertTaskClassRequest {
|
||||||
|
items := make([]taskclasscontracts.UpsertTaskClassItemConfig, 0, len(snapshot.Items))
|
||||||
|
sortedItems := append([]forumsv.TaskClassSnapshotItem(nil), snapshot.Items...)
|
||||||
|
sort.SliceStable(sortedItems, func(i, j int) bool {
|
||||||
|
if sortedItems[i].Order != sortedItems[j].Order {
|
||||||
|
return sortedItems[i].Order < sortedItems[j].Order
|
||||||
|
}
|
||||||
|
return sortedItems[i].TaskItemID < sortedItems[j].TaskItemID
|
||||||
|
})
|
||||||
|
for _, item := range sortedItems {
|
||||||
|
content := strings.TrimSpace(item.Content)
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, taskclasscontracts.UpsertTaskClassItemConfig{
|
||||||
|
Order: item.Order,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskclasscontracts.UpsertTaskClassRequest{
|
||||||
|
UserID: userID,
|
||||||
|
Name: title,
|
||||||
|
StartDate: strings.TrimSpace(snapshot.StartDate),
|
||||||
|
EndDate: strings.TrimSpace(snapshot.EndDate),
|
||||||
|
Mode: strings.TrimSpace(snapshot.Mode),
|
||||||
|
SubjectType: strings.TrimSpace(snapshot.SubjectType),
|
||||||
|
DifficultyLevel: strings.TrimSpace(snapshot.DifficultyLevel),
|
||||||
|
CognitiveIntensity: strings.TrimSpace(snapshot.CognitiveIntensity),
|
||||||
|
Config: taskclasscontracts.UpsertTaskClassConfig{
|
||||||
|
TotalSlots: snapshot.TotalSlots,
|
||||||
|
AllowFillerCourse: snapshot.AllowFillerCourse,
|
||||||
|
Strategy: strings.TrimSpace(snapshot.Strategy),
|
||||||
|
ExcludedSlots: cloneIntSlice(snapshot.ExcludedSlots),
|
||||||
|
ExcludedDaysOfWeek: cloneIntSlice(snapshot.ExcludedDaysOfWeek),
|
||||||
|
},
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strategyLabels(strategy string) []string {
|
||||||
|
trimmed := strings.TrimSpace(strategy)
|
||||||
|
if trimmed == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return []string{trimmed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *TaskClassRPCAdapter) ensureReady() error {
|
||||||
|
if a == nil || a.client == nil {
|
||||||
|
return errTaskClassRPCAdapterNotReady
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUserID(value uint64) (int, error) {
|
||||||
|
if value == 0 || value > uint64(maxIntValue()) {
|
||||||
|
return 0, respond.WrongUserID
|
||||||
|
}
|
||||||
|
return int(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTaskClassID(value uint64) (int, error) {
|
||||||
|
if value == 0 || value > uint64(maxIntValue()) {
|
||||||
|
return 0, respond.WrongTaskClassID
|
||||||
|
}
|
||||||
|
return int(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxIntValue() int {
|
||||||
|
return int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefInt(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSnapshotDateRange(mode string, startDate string, endDate string) (*time.Time, *time.Time, error) {
|
||||||
|
parsedStart, err := parseDatePtr(startDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, respond.WrongParamType
|
||||||
|
}
|
||||||
|
parsedEnd, err := parseDatePtr(endDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, respond.WrongParamType
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(mode) != "auto" {
|
||||||
|
return parsedStart, parsedEnd, nil
|
||||||
|
}
|
||||||
|
if parsedStart == nil || parsedEnd == nil {
|
||||||
|
return nil, nil, respond.MissingParamForAutoScheduling
|
||||||
|
}
|
||||||
|
if parsedStart.After(*parsedEnd) {
|
||||||
|
return nil, nil, respond.InvalidDateRange
|
||||||
|
}
|
||||||
|
return parsedStart, parsedEnd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildConfigSnapshotJSON(snapshot forumsv.TaskClassSnapshot) (string, error) {
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"mode": snapshot.Mode,
|
||||||
|
"start_date": snapshot.StartDate,
|
||||||
|
"end_date": snapshot.EndDate,
|
||||||
|
"subject_type": snapshot.SubjectType,
|
||||||
|
"difficulty_level": snapshot.DifficultyLevel,
|
||||||
|
"cognitive_intensity": snapshot.CognitiveIntensity,
|
||||||
|
"total_slots": snapshot.TotalSlots,
|
||||||
|
"allow_filler_course": snapshot.AllowFillerCourse,
|
||||||
|
"strategy": snapshot.Strategy,
|
||||||
|
"excluded_slots": cloneIntSlice(snapshot.ExcludedSlots),
|
||||||
|
"excluded_days_of_week": cloneIntSlice(snapshot.ExcludedDaysOfWeek),
|
||||||
|
"strategy_labels": append([]string(nil), snapshot.StrategyLabels...),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDatePtr(value string) (*time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation("2006-01-02", trimmed, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIntSlice(values []int) []int {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
return append([]int(nil), values...)
|
||||||
|
}
|
||||||
204
backend/services/taskclassforum/commenttree/tree.go
Normal file
204
backend/services/taskclassforum/commenttree/tree.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package commenttree
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deletedCommentPlaceholder = "该评论已删除"
|
||||||
|
|
||||||
|
type commentTreeNode struct {
|
||||||
|
comment forummodel.ForumComment
|
||||||
|
parent *commentTreeNode
|
||||||
|
children []*commentTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildForumCommentTree 将扁平评论组装为多层评论树。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责根据 parent_comment_id 组装无限层树结构,并按 CreatedAt 升序稳定排序;
|
||||||
|
// 2. 负责把软删除评论转换成前端展示文案,同时保留 deleted 状态与 deleted_at;
|
||||||
|
// 3. 不负责查询数据库、补充真实昵称头像,也不负责帖子级权限校验。
|
||||||
|
func BuildForumCommentTree(comments []forummodel.ForumComment, actorUserID uint64) []forumcontracts.ForumCommentNode {
|
||||||
|
if len(comments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nodesByID := make(map[uint64]*commentTreeNode, len(comments))
|
||||||
|
orderedNodes := make([]*commentTreeNode, 0, len(comments))
|
||||||
|
for i := range comments {
|
||||||
|
node := &commentTreeNode{comment: comments[i]}
|
||||||
|
nodesByID[comments[i].ID] = node
|
||||||
|
orderedNodes = append(orderedNodes, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
roots := attachCommentTreeNodes(orderedNodes, nodesByID)
|
||||||
|
sortCommentTreeChildren(roots)
|
||||||
|
|
||||||
|
result := make([]forumcontracts.ForumCommentNode, 0, len(roots))
|
||||||
|
for i := range roots {
|
||||||
|
result = append(result, buildForumCommentNode(roots[i], actorUserID))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachCommentTreeNodes 按原始 parent_comment_id 把评论挂成树。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理节点挂载关系,不做字段格式化;
|
||||||
|
// 2. 缺失父节点、自指向、环引用都回退到根层,避免整棵树丢失;
|
||||||
|
// 3. 根层顺序先保留输入顺序,后续统一由排序函数做稳定排序。
|
||||||
|
func attachCommentTreeNodes(
|
||||||
|
orderedNodes []*commentTreeNode,
|
||||||
|
nodesByID map[uint64]*commentTreeNode,
|
||||||
|
) []*commentTreeNode {
|
||||||
|
roots := make([]*commentTreeNode, 0, len(orderedNodes))
|
||||||
|
for i := range orderedNodes {
|
||||||
|
node := orderedNodes[i]
|
||||||
|
parentID := node.comment.ParentCommentID
|
||||||
|
if parentID == nil {
|
||||||
|
roots = append(roots, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parentNode, ok := nodesByID[*parentID]
|
||||||
|
if !ok || parentNode == nil || parentNode.comment.ID == node.comment.ID {
|
||||||
|
roots = append(roots, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if wouldCreateCommentCycle(nodesByID, node.comment.ID, parentNode) {
|
||||||
|
roots = append(roots, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
node.parent = parentNode
|
||||||
|
parentNode.children = append(parentNode.children, node)
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
// wouldCreateCommentCycle 判断把 child 挂到 parent 下时是否会形成环。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只依赖原始 parent_comment_id 链路判断,不依赖当前挂载顺序;
|
||||||
|
// 2. 一旦发现 child 会回到自己,或父链本身已成环,就返回 true;
|
||||||
|
// 3. 父链中途断开时按“无环”处理,让节点继续挂到可用分支上。
|
||||||
|
func wouldCreateCommentCycle(
|
||||||
|
nodesByID map[uint64]*commentTreeNode,
|
||||||
|
childCommentID uint64,
|
||||||
|
parentNode *commentTreeNode,
|
||||||
|
) bool {
|
||||||
|
visited := make(map[uint64]struct{})
|
||||||
|
current := parentNode
|
||||||
|
for current != nil {
|
||||||
|
if current.comment.ID == childCommentID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, seen := visited[current.comment.ID]; seen {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
visited[current.comment.ID] = struct{}{}
|
||||||
|
|
||||||
|
if current.comment.ParentCommentID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNode, ok := nodesByID[*current.comment.ParentCommentID]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
current = nextNode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCommentTreeChildren 对根层以下的兄弟节点做 CreatedAt 升序稳定排序。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 根层顺序来自服务层根评论分页,必须保留 latest/oldest 的查询语义;
|
||||||
|
// 2. 子回复统一按 CreatedAt 升序展示,符合常见对话阅读顺序;
|
||||||
|
// 3. 相同 CreatedAt 依赖稳定排序保留原始输入顺序,避免同秒回复来回跳动。
|
||||||
|
func sortCommentTreeChildren(nodes []*commentTreeNode) {
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range nodes {
|
||||||
|
sort.SliceStable(nodes[i].children, func(left, right int) bool {
|
||||||
|
return nodes[i].children[left].comment.CreatedAt.Before(nodes[i].children[right].comment.CreatedAt)
|
||||||
|
})
|
||||||
|
sortCommentTreeChildren(nodes[i].children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildForumCommentNode 把内部树节点转换成对外契约节点。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责软删除展示文案、CanDelete、时间格式等输出字段整理;
|
||||||
|
// 2. 根节点统一输出 nil parent_comment_id;孤儿兜底到根层后也遵循该规则;
|
||||||
|
// 3. 这里沿用当前服务里的“用户{ID}”占位昵称语义。
|
||||||
|
func buildForumCommentNode(node *commentTreeNode, actorUserID uint64) forumcontracts.ForumCommentNode {
|
||||||
|
children := make([]forumcontracts.ForumCommentNode, 0, len(node.children))
|
||||||
|
for i := range node.children {
|
||||||
|
children = append(children, buildForumCommentNode(node.children[i], actorUserID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先基于最终挂载结果回填 parent_comment_id,保证孤儿回退到根层后对外语义一致。
|
||||||
|
// 2. 再处理软删除评论文案:内容固定替换,但 status 仍保留 deleted,便于前端区分。
|
||||||
|
// 3. 最后按“当前用户且评论可见”计算 CanDelete,避免已删除评论被重复展示可删除按钮。
|
||||||
|
parentCommentID := actualParentCommentID(node.parent)
|
||||||
|
content := node.comment.Content
|
||||||
|
if node.comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||||
|
content = deletedCommentPlaceholder
|
||||||
|
}
|
||||||
|
|
||||||
|
return forumcontracts.ForumCommentNode{
|
||||||
|
CommentID: node.comment.ID,
|
||||||
|
PostID: node.comment.PostID,
|
||||||
|
ParentCommentID: parentCommentID,
|
||||||
|
Content: content,
|
||||||
|
Status: node.comment.Status,
|
||||||
|
Author: buildCommentAuthor(node.comment.UserID),
|
||||||
|
CanDelete: node.comment.Status == forummodel.ForumCommentStatusVisible && node.comment.UserID == actorUserID,
|
||||||
|
CreatedAt: formatCommentTime(node.comment.CreatedAt),
|
||||||
|
DeletedAt: formatCommentTimePtr(node.comment.DeletedAt),
|
||||||
|
Children: children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actualParentCommentID(parent *commentTreeNode) *uint64 {
|
||||||
|
if parent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parentID := parent.comment.ID
|
||||||
|
return &parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCommentTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCommentTimePtr(value *time.Time) *string {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formatted := value.Format(time.RFC3339)
|
||||||
|
return &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCommentAuthor(userID uint64) forumcontracts.UserBrief {
|
||||||
|
// 由于本轮写入范围被限制在 commenttree/tree.go,暂时无法把 UserBrief 生成逻辑下沉成公共能力;
|
||||||
|
// 这里先与现有服务层保持同一占位昵称语义,避免为了抽公共层去改动 sv/contract 等非授权文件。
|
||||||
|
return forumcontracts.UserBrief{
|
||||||
|
UserID: userID,
|
||||||
|
Nickname: fmt.Sprintf("用户%d", userID),
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/services/taskclassforum/dao/cache.go
Normal file
146
backend/services/taskclassforum/dao/cache.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commentTreeCacheTTL = 2 * time.Minute
|
||||||
|
|
||||||
|
type commentTreeCachePayload struct {
|
||||||
|
Items []forumcontracts.ForumCommentNode `json:"items"`
|
||||||
|
Page forumcontracts.PageResult `json:"page"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentTreeCache 承载计划广场评论树的 Redis 缓存能力。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责评论树读模型的 JSON 缓存和版本号失效,不读写 MySQL;
|
||||||
|
// 2. 不计算当前用户是否可删除评论,避免把用户视角写进共享缓存;
|
||||||
|
// 3. Redis 异常向上返回,由 service 层决定是否降级回源 DB。
|
||||||
|
type CommentTreeCache struct {
|
||||||
|
client *redis.Client
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentTreeCache(client *redis.Client) *CommentTreeCache {
|
||||||
|
return &CommentTreeCache{
|
||||||
|
client: client,
|
||||||
|
ttl: commentTreeCacheTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentTreeVersionKey(postID uint64) string {
|
||||||
|
return fmt.Sprintf("forum:comments:%d:version", postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentTreeDataKey(postID uint64, version int64, sort string, page int, pageSize int) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"forum:comments:%d:v%d:sort:%s:page:%d:size:%d",
|
||||||
|
postID,
|
||||||
|
version,
|
||||||
|
strings.TrimSpace(sort),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommentTree 读取指定帖子、排序和分页维度下的评论树缓存。
|
||||||
|
//
|
||||||
|
// 返回值语义:
|
||||||
|
// 1. hit=true 表示命中缓存,items/page 可直接用于返回前的用户视角补全;
|
||||||
|
// 2. hit=false 且 error=nil 表示未命中,调用方应回源 DB;
|
||||||
|
// 3. error 非空表示 Redis 或 JSON 异常,调用方应记录日志并回源 DB。
|
||||||
|
func (c *CommentTreeCache) GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) {
|
||||||
|
if c == nil || c.client == nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, false, errors.New("评论树缓存未初始化")
|
||||||
|
}
|
||||||
|
version, err := c.currentCommentTreeVersion(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := c.client.Get(ctx, commentTreeDataKey(postID, version, sort, page, pageSize)).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, forumcontracts.PageResult{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload commentTreeCachePayload
|
||||||
|
if err = json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, false, err
|
||||||
|
}
|
||||||
|
if payload.Items == nil {
|
||||||
|
payload.Items = []forumcontracts.ForumCommentNode{}
|
||||||
|
}
|
||||||
|
return payload.Items, payload.Page, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommentTree 写入指定帖子、排序和分页维度下的评论树缓存。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先读取当前版本号,保证写入 key 与后续读取 key 一致;
|
||||||
|
// 2. 再序列化去个性化后的评论树,避免缓存里带入某个用户的 can_delete;
|
||||||
|
// 3. 最后写入短 TTL,让版本失效失败时也能靠自然过期兜底。
|
||||||
|
func (c *CommentTreeCache) SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error {
|
||||||
|
if c == nil || c.client == nil {
|
||||||
|
return errors.New("评论树缓存未初始化")
|
||||||
|
}
|
||||||
|
version, err := c.currentCommentTreeVersion(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if items == nil {
|
||||||
|
items = []forumcontracts.ForumCommentNode{}
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(commentTreeCachePayload{
|
||||||
|
Items: items,
|
||||||
|
Page: pageResult,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.client.Set(ctx, commentTreeDataKey(postID, version, sort, page, pageSize), data, c.ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpCommentTreeVersion 递增帖子评论树版本号,让旧分页缓存自然失效。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只做版本递增,不扫描删除旧 data key,避免写评论时阻塞 Redis;
|
||||||
|
// 2. 旧 data key 依赖短 TTL 自动回收;
|
||||||
|
// 3. 当 version key 不存在时 INCR 会从 1 开始,能够让默认 v0 缓存失效。
|
||||||
|
func (c *CommentTreeCache) BumpCommentTreeVersion(ctx context.Context, postID uint64) error {
|
||||||
|
if c == nil || c.client == nil {
|
||||||
|
return errors.New("评论树缓存未初始化")
|
||||||
|
}
|
||||||
|
return c.client.Incr(ctx, commentTreeVersionKey(postID)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommentTreeCache) currentCommentTreeVersion(ctx context.Context, postID uint64) (int64, error) {
|
||||||
|
raw, err := c.client.Get(ctx, commentTreeVersionKey(postID)).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
version, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if version < 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
70
backend/services/taskclassforum/dao/connect.go
Normal file
70
backend/services/taskclassforum/dao/connect.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenDBFromConfig 创建计划广场服务自己的数据库句柄,并迁移本服务私有表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只迁移 forum_* 表和本服务 outbox 表,不迁移 task_classes / task_items,避免抢占 task-class 拆分线;
|
||||||
|
// 2. 不负责装配 legacy TaskClass adapter,adapter 在服务实现阶段单独注入;
|
||||||
|
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
|
||||||
|
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||||
|
host := viper.GetString("database.host")
|
||||||
|
port := viper.GetString("database.port")
|
||||||
|
user := viper.GetString("database.user")
|
||||||
|
password := viper.GetString("database.password")
|
||||||
|
dbname := viper.GetString("database.dbname")
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
user, password, host, port, dbname,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = AutoMigrate(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate 只迁移计划广场服务拥有的表。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先创建帖子、模板、条目、点赞、评论、导入记录表;
|
||||||
|
// 2. 再按 service catalog 创建 taskclass-forum outbox 表,为后续论坛自身异步事件预留稳定目录;
|
||||||
|
// 3. 迁移期论坛奖励事件直接写 token-store outbox 表,发布端也兜底创建目标表,避免独立启动顺序导致奖励漏表;
|
||||||
|
// 4. 唯一约束交给 GORM tag 生成,保证点赞和导入幂等有数据库兜底;
|
||||||
|
// 5. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
|
||||||
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("taskclassforum auto migrate failed: db is nil")
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&forummodel.ForumPost{},
|
||||||
|
&forummodel.ForumPostTemplate{},
|
||||||
|
&forummodel.ForumPostTemplateItem{},
|
||||||
|
&forummodel.ForumLike{},
|
||||||
|
&forummodel.ForumComment{},
|
||||||
|
&forummodel.ForumImport{},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("auto migrate taskclassforum tables failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTaskClassForum); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
466
backend/services/taskclassforum/dao/forum.go
Normal file
466
backend/services/taskclassforum/dao/forum.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForumDAO 承载计划广场私有表的持久化访问。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只访问 forum_* 表,不直接读写旧 task_classes / task_items;
|
||||||
|
// 2. 只做查询、事务和基础状态更新,不组装前端 DTO;
|
||||||
|
// 3. 业务规则由 sv 层控制,DAO 仅提供必要的数据原子操作。
|
||||||
|
type ForumDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewForumDAO(db *gorm.DB) *ForumDAO {
|
||||||
|
return &ForumDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) WithTx(tx *gorm.DB) *ForumDAO {
|
||||||
|
return &ForumDAO{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormDB 返回当前 DAO 绑定的 GORM 句柄。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只提供给需要和 forum 业务事务同提交的基础设施使用,例如 outbox 入队;
|
||||||
|
// 2. 不鼓励业务层绕过 DAO 任意读写 forum_* 表;
|
||||||
|
// 3. 若当前 DAO 来自 WithTx,返回值就是同一个事务句柄。
|
||||||
|
func (dao *ForumDAO) GormDB() *gorm.DB {
|
||||||
|
if dao == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dao.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction 在一个数据库事务内执行计划广场写操作。
|
||||||
|
func (dao *ForumDAO) Transaction(ctx context.Context, fn func(txDAO *ForumDAO) error) error {
|
||||||
|
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(dao.WithTx(tx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPostsQuery struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Sort string
|
||||||
|
Keyword string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePostSnapshot 在同一事务中写帖子、模板和模板条目。
|
||||||
|
func (dao *ForumDAO) CreatePostSnapshot(ctx context.Context, post *forummodel.ForumPost, template *forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) error {
|
||||||
|
return dao.Transaction(ctx, func(txDAO *ForumDAO) error {
|
||||||
|
if err := txDAO.db.Create(post).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
template.PostID = post.ID
|
||||||
|
if err := txDAO.db.Create(template).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i].PostID = post.ID
|
||||||
|
items[i].TemplateID = template.ID
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
if err := txDAO.db.Create(&items).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindPostByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumPost, error) {
|
||||||
|
var post forummodel.ForumPost
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("author_user_id = ? AND idempotency_key = ?", userID, key).
|
||||||
|
First(&post).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ListPosts(ctx context.Context, query ListPostsQuery) ([]forummodel.ForumPost, int64, error) {
|
||||||
|
db := dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumPost{}).
|
||||||
|
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished)
|
||||||
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
db = db.Where("title LIKE ? OR summary LIKE ?", like, like)
|
||||||
|
}
|
||||||
|
if tag := strings.TrimSpace(query.Tag); tag != "" {
|
||||||
|
db = db.Where("JSON_CONTAINS(tags_json, JSON_QUOTE(?))", tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
orderBy := "created_at DESC"
|
||||||
|
switch strings.TrimSpace(query.Sort) {
|
||||||
|
case "likes":
|
||||||
|
orderBy = "like_count DESC, created_at DESC"
|
||||||
|
case "imports":
|
||||||
|
orderBy = "import_count DESC, created_at DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
var posts []forummodel.ForumPost
|
||||||
|
err := db.Order(orderBy).
|
||||||
|
Offset((query.Page - 1) * query.PageSize).
|
||||||
|
Limit(query.PageSize).
|
||||||
|
Find(&posts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ListPublishedTagJSONs(ctx context.Context) ([]string, error) {
|
||||||
|
var rows []struct {
|
||||||
|
TagsJSON string
|
||||||
|
}
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumPost{}).
|
||||||
|
Select("tags_json").
|
||||||
|
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, row.TagsJSON)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
|
||||||
|
var post forummodel.ForumPost
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
|
||||||
|
First(&post).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) LockPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
|
||||||
|
var post forummodel.ForumPost
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
|
||||||
|
First(&post).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindTemplateByPostID(ctx context.Context, postID uint64) (*forummodel.ForumPostTemplate, error) {
|
||||||
|
var template forummodel.ForumPostTemplate
|
||||||
|
err := dao.db.WithContext(ctx).Where("post_id = ?", postID).First(&template).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ListTemplateItemsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumPostTemplateItem, error) {
|
||||||
|
var items []forummodel.ForumPostTemplateItem
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("post_id = ?", postID).
|
||||||
|
Order("item_order ASC").
|
||||||
|
Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindTemplatesByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]forummodel.ForumPostTemplate, error) {
|
||||||
|
var templates []forummodel.ForumPostTemplate
|
||||||
|
err := dao.db.WithContext(ctx).Where("post_id IN ?", postIDs).Find(&templates).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[uint64]forummodel.ForumPostTemplate, len(templates))
|
||||||
|
for _, template := range templates {
|
||||||
|
result[template.PostID] = template
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CountTemplateItemsByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]int, error) {
|
||||||
|
var rows []struct {
|
||||||
|
PostID uint64
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumPostTemplateItem{}).
|
||||||
|
Select("post_id, COUNT(*) AS count").
|
||||||
|
Where("post_id IN ?", postIDs).
|
||||||
|
Group("post_id").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[uint64]int, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.PostID] = row.Count
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) LikedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
|
||||||
|
var likes []forummodel.ForumLike
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Select("post_id").
|
||||||
|
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumLikeStatusActive).
|
||||||
|
Find(&likes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[uint64]bool, len(likes))
|
||||||
|
for _, like := range likes {
|
||||||
|
result[like.PostID] = true
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ImportedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
|
||||||
|
var imports []forummodel.ForumImport
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Select("post_id").
|
||||||
|
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumImportStatusImported).
|
||||||
|
Find(&imports).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[uint64]bool, len(imports))
|
||||||
|
for _, item := range imports {
|
||||||
|
result[item.PostID] = true
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindLike(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumLike, error) {
|
||||||
|
var like forummodel.ForumLike
|
||||||
|
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &like, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CreateLike(ctx context.Context, like *forummodel.ForumLike) error {
|
||||||
|
return dao.db.WithContext(ctx).Create(like).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ActivateLike(ctx context.Context, likeID uint64) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumLike{}).
|
||||||
|
Where("id = ?", likeID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": forummodel.ForumLikeStatusActive,
|
||||||
|
"canceled_at": nil,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CancelLike(ctx context.Context, likeID uint64, now time.Time) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumLike{}).
|
||||||
|
Where("id = ?", likeID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": forummodel.ForumLikeStatusCanceled,
|
||||||
|
"canceled_at": &now,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) AddPostCounter(ctx context.Context, postID uint64, column string, delta int64) error {
|
||||||
|
expr := "CASE WHEN " + column + " + ? < 0 THEN 0 ELSE " + column + " + ? END"
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumPost{}).
|
||||||
|
Where("id = ?", postID).
|
||||||
|
UpdateColumn(column, gorm.Expr(expr, delta, delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
|
||||||
|
var comment forummodel.ForumComment
|
||||||
|
err := dao.db.WithContext(ctx).Where("id = ?", commentID).First(&comment).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) LockCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
|
||||||
|
var comment forummodel.ForumComment
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ?", commentID).
|
||||||
|
First(&comment).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindCommentByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumComment, error) {
|
||||||
|
var comment forummodel.ForumComment
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||||
|
First(&comment).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CreateComment(ctx context.Context, comment *forummodel.ForumComment) error {
|
||||||
|
return dao.db.WithContext(ctx).Create(comment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CountRootComments(ctx context.Context, postID uint64) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumComment{}).
|
||||||
|
Where("post_id = ? AND parent_comment_id IS NULL", postID).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ListRootComments(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forummodel.ForumComment, error) {
|
||||||
|
orderBy := "created_at ASC"
|
||||||
|
if strings.TrimSpace(sort) == "latest" {
|
||||||
|
orderBy = "created_at DESC"
|
||||||
|
}
|
||||||
|
var comments []forummodel.ForumComment
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("post_id = ? AND parent_comment_id IS NULL", postID).
|
||||||
|
Order(orderBy).
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&comments).Error
|
||||||
|
return comments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) ListCommentsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumComment, error) {
|
||||||
|
var comments []forummodel.ForumComment
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("post_id = ?", postID).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Find(&comments).Error
|
||||||
|
return comments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) SoftDeleteComment(ctx context.Context, commentID uint64, now time.Time) error {
|
||||||
|
tx := dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumComment{}).
|
||||||
|
Where("id = ? AND status = ?", commentID, forummodel.ForumCommentStatusVisible).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": forummodel.ForumCommentStatusDeleted,
|
||||||
|
"deleted_at": &now,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
return tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindImport(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumImport, error) {
|
||||||
|
var item forummodel.ForumImport
|
||||||
|
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&item).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FindImportByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumImport, error) {
|
||||||
|
var item forummodel.ForumImport
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||||
|
First(&item).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) CreateImport(ctx context.Context, item *forummodel.ForumImport) error {
|
||||||
|
return dao.db.WithContext(ctx).Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) UpdateImportProcessing(ctx context.Context, importID uint64, title string, now time.Time) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumImport{}).
|
||||||
|
Where("id = ?", importID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"target_title": title,
|
||||||
|
"status": forummodel.ForumImportStatusPending,
|
||||||
|
"last_error": nil,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) FinalizeImport(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, now time.Time) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumImport{}).
|
||||||
|
Where("id = ?", importID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"new_task_class_id": &newTaskClassID,
|
||||||
|
"target_title": targetTitle,
|
||||||
|
"status": forummodel.ForumImportStatusImported,
|
||||||
|
"last_error": nil,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) MarkImportFailed(ctx context.Context, importID uint64, message string, now time.Time) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumImport{}).
|
||||||
|
Where("id = ?", importID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": forummodel.ForumImportStatusFailed,
|
||||||
|
"last_error": &message,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dao *ForumDAO) MarkImportFailedAfterTaskClassCreated(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, message string, now time.Time) error {
|
||||||
|
return dao.db.WithContext(ctx).
|
||||||
|
Model(&forummodel.ForumImport{}).
|
||||||
|
Where("id = ?", importID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"new_task_class_id": &newTaskClassID,
|
||||||
|
"target_title": targetTitle,
|
||||||
|
"status": forummodel.ForumImportStatusFailed,
|
||||||
|
"last_error": &message,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
186
backend/services/taskclassforum/model/forum.go
Normal file
186
backend/services/taskclassforum/model/forum.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ForumPostStatusPublished 表示帖子已公开展示在计划广场。
|
||||||
|
ForumPostStatusPublished = "published"
|
||||||
|
// ForumPostStatusHidden 表示帖子被作者隐藏或后续治理流程下架。
|
||||||
|
ForumPostStatusHidden = "hidden"
|
||||||
|
// ForumPostStatusDeleted 表示帖子已软删除,P0 暂不对前端展示。
|
||||||
|
ForumPostStatusDeleted = "deleted"
|
||||||
|
// ForumPostStatusPendingReview 预留审核态,P0 不启用审核后台。
|
||||||
|
ForumPostStatusPendingReview = "pending_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ForumLikeStatusActive 表示当前用户仍保持点赞。
|
||||||
|
ForumLikeStatusActive = "active"
|
||||||
|
// ForumLikeStatusCanceled 表示用户取消点赞,保留记录用于奖励幂等。
|
||||||
|
ForumLikeStatusCanceled = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ForumCommentStatusVisible 表示评论正常展示。
|
||||||
|
ForumCommentStatusVisible = "visible"
|
||||||
|
// ForumCommentStatusDeleted 表示评论已由本人删除,服务层仍保留子回复结构。
|
||||||
|
ForumCommentStatusDeleted = "deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ForumImportStatusPending 表示导入记录已占位,正在创建 TaskClass 副本。
|
||||||
|
ForumImportStatusPending = "pending"
|
||||||
|
// ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。
|
||||||
|
ForumImportStatusImported = "imported"
|
||||||
|
// ForumImportStatusFailed 表示导入副本创建或最终确认失败,可由后续重试覆盖。
|
||||||
|
ForumImportStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForumPost 是计划广场帖子主体表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只保存社区帖子可展示信息、作者和计数字段;
|
||||||
|
// 2. 不保存完整 TaskClass 模板,模板快照归 ForumPostTemplate / ForumPostTemplateItem;
|
||||||
|
// 3. 计数字段由服务事务内维护,避免列表页每次做聚合统计。
|
||||||
|
type ForumPost struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_posts_author_status,priority:1;uniqueIndex:uk_forum_posts_author_idem,priority:1;comment:作者用户ID"`
|
||||||
|
SourceTaskClassID uint64 `gorm:"column:source_task_class_id;not null;index:idx_forum_posts_source_task_class;comment:发布时选择的原始TaskClass ID,仅用于审计"`
|
||||||
|
Title string `gorm:"column:title;type:varchar(80);not null;comment:帖子标题"`
|
||||||
|
Summary string `gorm:"column:summary;type:text;comment:帖子简介"`
|
||||||
|
TagsJSON string `gorm:"column:tags_json;type:json;not null;comment:标签JSON数组"`
|
||||||
|
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_posts_author_idem,priority:2;comment:发布请求幂等键"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'published';index:idx_forum_posts_status_created,priority:1;index:idx_forum_posts_author_status,priority:2;comment:published/hidden/deleted/pending_review"`
|
||||||
|
LikeCount int64 `gorm:"column:like_count;not null;default:0;index:idx_forum_posts_like_count;comment:点赞数冗余计数"`
|
||||||
|
CommentCount int64 `gorm:"column:comment_count;not null;default:0;comment:评论数冗余计数"`
|
||||||
|
ImportCount int64 `gorm:"column:import_count;not null;default:0;index:idx_forum_posts_import_count;comment:导入数冗余计数"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_forum_posts_status_created,priority:2;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
DeletedAt *time.Time `gorm:"column:deleted_at;index;comment:软删除时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumPost) TableName() string {
|
||||||
|
return "forum_posts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostTemplate 是发布瞬间复制出的 TaskClass 配置快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只保存可分享的 TaskClass 配置白名单;
|
||||||
|
// 2. 不保存 embedded_time、schedule 绑定和用户私有排程状态;
|
||||||
|
// 3. 后续原作者修改原 TaskClass 时,本快照不跟随变化。
|
||||||
|
type ForumPostTemplate struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_templates_post;comment:所属帖子ID"`
|
||||||
|
SourceTaskClassID uint64 `gorm:"column:source_task_class_id;not null;comment:原始TaskClass ID"`
|
||||||
|
Mode string `gorm:"column:mode;type:varchar(32);comment:TaskClass 模式"`
|
||||||
|
StartDate *time.Time `gorm:"column:start_date;comment:计划开始日期"`
|
||||||
|
EndDate *time.Time `gorm:"column:end_date;comment:计划结束日期"`
|
||||||
|
SubjectType string `gorm:"column:subject_type;type:varchar(32);comment:学科类型"`
|
||||||
|
DifficultyLevel string `gorm:"column:difficulty_level;type:varchar(16);comment:难度等级"`
|
||||||
|
CognitiveIntensity string `gorm:"column:cognitive_intensity;type:varchar(16);comment:认知强度"`
|
||||||
|
TotalSlots int `gorm:"column:total_slots;comment:分配的总节数"`
|
||||||
|
AllowFillerCourse bool `gorm:"column:allow_filler_course;not null;default:true;comment:是否允许填充课程空隙"`
|
||||||
|
Strategy string `gorm:"column:strategy;type:varchar(32);comment:规划策略"`
|
||||||
|
ExcludedSlotsJSON *string `gorm:"column:excluded_slots_json;type:json;comment:排除节次JSON数组"`
|
||||||
|
ExcludedDaysOfWeekJSON *string `gorm:"column:excluded_days_of_week_json;type:json;comment:排除星期JSON数组"`
|
||||||
|
StrategyLabelsJSON *string `gorm:"column:strategy_labels_json;type:json;comment:前端展示用策略标签JSON数组"`
|
||||||
|
ConfigSnapshotJSON *string `gorm:"column:config_snapshot_json;type:json;comment:过滤后的配置快照,便于后续兼容扩展"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumPostTemplate) TableName() string {
|
||||||
|
return "forum_post_templates"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostTemplateItem 是 TaskClassItem 的可分享快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只保存任务条目的顺序和内容;
|
||||||
|
// 2. 不保存 embedded_time,避免把原作者私有排程状态带给导入用户;
|
||||||
|
// 3. 导入时服务层按这些快照重新创建当前用户自己的 TaskClassItem。
|
||||||
|
type ForumPostTemplateItem struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
TemplateID uint64 `gorm:"column:template_id;not null;uniqueIndex:uk_forum_template_items_order,priority:1;index:idx_forum_template_items_template;comment:模板ID"`
|
||||||
|
PostID uint64 `gorm:"column:post_id;not null;index:idx_forum_template_items_post;comment:帖子ID,便于按帖子直接读取预览"`
|
||||||
|
SourceTaskItemID uint64 `gorm:"column:source_task_item_id;not null;comment:原始TaskClassItem ID,仅用于审计"`
|
||||||
|
Order int `gorm:"column:item_order;not null;uniqueIndex:uk_forum_template_items_order,priority:2;comment:条目顺序"`
|
||||||
|
Content string `gorm:"column:content;type:text;not null;comment:任务条目内容"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumPostTemplateItem) TableName() string {
|
||||||
|
return "forum_post_template_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumLike 是点赞幂等表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 通过 post_id + user_id 唯一约束保证同一用户同一帖子只有一条点赞状态;
|
||||||
|
// 2. 取消点赞只把状态改为 canceled,不删除记录,避免作者奖励被反复触发;
|
||||||
|
// 3. event_id 对应首次点赞奖励事件,供 token-store 账本幂等使用。
|
||||||
|
type ForumLike struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_likes_post_user,priority:1;index:idx_forum_likes_post_status,priority:1;comment:帖子ID"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_likes_post_user,priority:2;comment:点赞用户ID"`
|
||||||
|
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_likes_author;comment:帖子作者ID,便于奖励和审计"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_forum_likes_post_status,priority:2;comment:active/canceled"`
|
||||||
|
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_forum_likes_event;comment:首次点赞事件ID"`
|
||||||
|
LikedAt time.Time `gorm:"column:liked_at;autoCreateTime;comment:首次点赞时间"`
|
||||||
|
CanceledAt *time.Time `gorm:"column:canceled_at;comment:最近一次取消点赞时间"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumLike) TableName() string {
|
||||||
|
return "forum_likes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumComment 是评论和多层回复的扁平存储表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 数据库只保存 parent_comment_id,不保存树结构;
|
||||||
|
// 2. 服务层按帖子读取扁平评论后组装评论树;
|
||||||
|
// 3. 删除评论使用 status + deleted_at 软删除,保留子回复链路。
|
||||||
|
type ForumComment struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
PostID uint64 `gorm:"column:post_id;not null;index:idx_forum_comments_post_parent_created,priority:1;comment:帖子ID"`
|
||||||
|
ParentCommentID *uint64 `gorm:"column:parent_comment_id;index:idx_forum_comments_post_parent_created,priority:2;comment:父评论ID,根评论为空"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_comments_user_idem,priority:1;index:idx_forum_comments_user;comment:评论用户ID"`
|
||||||
|
Content string `gorm:"column:content;type:text;not null;comment:评论内容"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'visible';index:idx_forum_comments_status;comment:visible/deleted"`
|
||||||
|
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_comments_user_idem,priority:2;comment:评论创建幂等键"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_forum_comments_post_parent_created,priority:3;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
DeletedAt *time.Time `gorm:"column:deleted_at;comment:用户删除时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumComment) TableName() string {
|
||||||
|
return "forum_comments"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumImport 是一键导入记录表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 通过 post_id + user_id 唯一约束保证同一用户同一计划只导入一次;
|
||||||
|
// 2. 只记录导入到 TaskClass 的结果,不写 schedule;
|
||||||
|
// 3. event_id 对应导入奖励事件,供 token-store 账本幂等使用。
|
||||||
|
type ForumImport struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
PostID uint64 `gorm:"column:post_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:1;index:idx_forum_imports_post;comment:帖子ID"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_forum_imports_post_user,priority:2;uniqueIndex:uk_forum_imports_user_idem,priority:1;index:idx_forum_imports_user;comment:导入用户ID"`
|
||||||
|
AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_imports_author;comment:帖子作者ID,便于奖励和审计"`
|
||||||
|
NewTaskClassID *uint64 `gorm:"column:new_task_class_id;comment:导入后创建的当前用户TaskClass ID,pending/failed 时为空"`
|
||||||
|
TargetTitle string `gorm:"column:target_title;type:varchar(80);comment:导入后的TaskClass标题"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';comment:pending/imported/failed"`
|
||||||
|
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_forum_imports_event;comment:导入事件ID"`
|
||||||
|
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_imports_user_idem,priority:2;comment:导入请求幂等键"`
|
||||||
|
LastError *string `gorm:"column:last_error;type:text;comment:最近一次导入失败原因"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForumImport) TableName() string {
|
||||||
|
return "forum_imports"
|
||||||
|
}
|
||||||
72
backend/services/taskclassforum/rpc/errors.go
Normal file
72
backend/services/taskclassforum/rpc/errors.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const taskClassForumErrorDomain = "smartflow.taskclassforum"
|
||||||
|
|
||||||
|
// grpcErrorFromServiceError 负责把计划广场内部错误收口成 gRPC status。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只做 service error -> gRPC error 的传输适配;
|
||||||
|
// 2. 不负责 HTTP 响应,gateway client 后续会把 gRPC error 反解成 respond.Response;
|
||||||
|
// 3. 普通内部错误只暴露统一文案,避免把 DAO / SQL 细节透给前端。
|
||||||
|
func grpcErrorFromServiceError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp respond.Response
|
||||||
|
if errors.As(err, &resp) {
|
||||||
|
return grpcErrorFromResponse(resp)
|
||||||
|
}
|
||||||
|
log.Printf("taskclassforum rpc internal error: %v", err)
|
||||||
|
return status.Error(codes.Internal, "taskclassforum service internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func grpcErrorFromResponse(resp respond.Response) error {
|
||||||
|
code := grpcCodeFromRespondStatus(resp.Status)
|
||||||
|
message := strings.TrimSpace(resp.Info)
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
st := status.New(code, message)
|
||||||
|
detail := &errdetails.ErrorInfo{
|
||||||
|
Domain: taskClassForumErrorDomain,
|
||||||
|
Reason: resp.Status,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"info": resp.Info,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
withDetails, err := st.WithDetails(detail)
|
||||||
|
if err != nil {
|
||||||
|
return st.Err()
|
||||||
|
}
|
||||||
|
return withDetails.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||||
|
switch strings.TrimSpace(statusValue) {
|
||||||
|
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status, respond.ErrUnauthorized.Status:
|
||||||
|
return codes.Unauthenticated
|
||||||
|
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status, respond.WrongUserID.Status:
|
||||||
|
return codes.InvalidArgument
|
||||||
|
case respond.UserTaskClassNotFound.Status:
|
||||||
|
return codes.NotFound
|
||||||
|
case respond.UserTaskClassForbidden.Status, respond.TaskClassItemNotBelongToUser.Status:
|
||||||
|
return codes.PermissionDenied
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||||
|
return codes.Internal
|
||||||
|
}
|
||||||
|
return codes.InvalidArgument
|
||||||
|
}
|
||||||
412
backend/services/taskclassforum/rpc/handler.go
Normal file
412
backend/services/taskclassforum/rpc/handler.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
pb.UnimplementedTaskClassForumServiceServer
|
||||||
|
svc *forumsv.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(svc *forumsv.Service) *Handler {
|
||||||
|
return &Handler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// service 负责统一校验 RPC 层依赖是否已经注入。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只判断 handler 自身和业务 service 是否可用;
|
||||||
|
// 2. 不负责校验请求参数,也不处理具体业务规则;
|
||||||
|
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
|
||||||
|
func (h *Handler) service() (*forumsv.Service, error) {
|
||||||
|
if h == nil || h.svc == nil {
|
||||||
|
return nil, errors.New("taskclassforum service dependency not initialized")
|
||||||
|
}
|
||||||
|
return h.svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosts 负责把计划广场列表请求从 gRPC 协议转成内部服务调用。
|
||||||
|
func (h *Handler) ListPosts(ctx context.Context, req *pb.ListForumPostsRequest) (*pb.ListForumPostsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListPosts(ctx, req.ActorUserId, int(req.Page), int(req.PageSize), req.Sort, req.Keyword, req.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumPostsResponse{
|
||||||
|
Items: forumPostBriefsToPB(items),
|
||||||
|
Page: forumPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListTags(ctx context.Context, req *pb.ListForumTagsRequest) (*pb.ListForumTagsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := svc.ListTags(ctx, req.ActorUserId, int(req.Limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumTagsResponse{Items: forumTagItemsToPB(items)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePost(ctx context.Context, req *pb.CreateForumPostRequest) (*pb.CreateForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := svc.CreatePost(ctx, forumcontracts.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, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.CreateForumPostResponse{Post: forumPostBriefToPB(post)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPost(ctx context.Context, req *pb.GetForumPostRequest) (*pb.GetForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := svc.GetPost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.GetForumPostResponse{Data: forumPostDetailToPB(data)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LikePost(ctx context.Context, req *pb.LikeForumPostRequest) (*pb.LikeForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
counters, viewerState, err := svc.LikePost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.LikeForumPostResponse{
|
||||||
|
Counters: forumPostCountersToPB(counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnlikePost(ctx context.Context, req *pb.UnlikeForumPostRequest) (*pb.UnlikeForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
counters, viewerState, err := svc.UnlikePost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.UnlikeForumPostResponse{
|
||||||
|
Counters: forumPostCountersToPB(counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListComments(ctx context.Context, req *pb.ListForumCommentsRequest) (*pb.ListForumCommentsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListComments(ctx, req.ActorUserId, req.PostId, int(req.Page), int(req.PageSize), req.Sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumCommentsResponse{
|
||||||
|
Items: forumCommentNodesToPB(items),
|
||||||
|
Page: forumPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateComment(ctx context.Context, req *pb.CreateForumCommentRequest) (*pb.CreateForumCommentResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := svc.CreateComment(ctx, forumcontracts.CreateForumCommentRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
PostID: req.PostId,
|
||||||
|
Content: req.Content,
|
||||||
|
ParentCommentID: forumUint64PtrFromPositive(req.ParentCommentId),
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.CreateForumCommentResponse{Comment: forumCommentNodeToPB(comment)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteComment(ctx context.Context, req *pb.DeleteForumCommentRequest) (*pb.DeleteForumCommentResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.DeleteComment(ctx, req.ActorUserId, req.CommentId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.DeleteForumCommentResponse{
|
||||||
|
CommentId: result.CommentID,
|
||||||
|
Status: result.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportPost(ctx context.Context, req *pb.ImportForumPostRequest) (*pb.ImportForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.ImportPost(ctx, forumcontracts.ImportForumPostRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
PostID: req.PostId,
|
||||||
|
TargetTitle: req.TargetTitle,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ImportForumPostResponse{
|
||||||
|
ImportId: result.ImportID,
|
||||||
|
PostId: result.PostID,
|
||||||
|
NewTaskClassId: result.NewTaskClassID,
|
||||||
|
TaskClassTitle: result.TaskClassTitle,
|
||||||
|
ImportCount: result.ImportCount,
|
||||||
|
CreatedAt: result.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPageToPB(page forumcontracts.PageResult) *pb.PageResponse {
|
||||||
|
return &pb.PageResponse{
|
||||||
|
Page: int32(page.Page),
|
||||||
|
PageSize: int32(page.PageSize),
|
||||||
|
Total: int32(page.Total),
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUserToPB(user forumcontracts.UserBrief) *pb.UserBrief {
|
||||||
|
return &pb.UserBrief{
|
||||||
|
UserId: user.UserID,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
AvatarUrl: user.AvatarURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateSummaryToPB(summary forumcontracts.TemplateSummary) *pb.TemplateSummary {
|
||||||
|
return &pb.TemplateSummary{
|
||||||
|
TaskCount: int32(summary.TaskCount),
|
||||||
|
Mode: summary.Mode,
|
||||||
|
StartDate: summary.StartDate,
|
||||||
|
EndDate: summary.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostCountersToPB(counters forumcontracts.ForumPostCounters) *pb.ForumPostCounters {
|
||||||
|
return &pb.ForumPostCounters{
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
CommentCount: counters.CommentCount,
|
||||||
|
ImportCount: counters.ImportCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostViewerStateToPB(viewerState forumcontracts.ForumPostViewerState) *pb.ForumPostViewerState {
|
||||||
|
return &pb.ForumPostViewerState{
|
||||||
|
Liked: viewerState.Liked,
|
||||||
|
ImportedOnce: viewerState.ImportedOnce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefToPB(post *forumcontracts.ForumPostBrief) *pb.ForumPostBrief {
|
||||||
|
if post == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.ForumPostBrief{
|
||||||
|
PostId: post.PostID,
|
||||||
|
Title: post.Title,
|
||||||
|
Summary: post.Summary,
|
||||||
|
Tags: append([]string(nil), post.Tags...),
|
||||||
|
Author: forumUserToPB(post.Author),
|
||||||
|
TemplateSummary: forumTemplateSummaryToPB(post.TemplateSummary),
|
||||||
|
Counters: forumPostCountersToPB(post.Counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(post.ViewerState),
|
||||||
|
Status: post.Status,
|
||||||
|
CreatedAt: post.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefsToPB(items []forumcontracts.ForumPostBrief) []*pb.ForumPostBrief {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumPostBrief, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, forumPostBriefToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateItemPreviewToPB(item forumcontracts.TemplateItemPreview) *pb.TemplateItemPreview {
|
||||||
|
return &pb.TemplateItemPreview{
|
||||||
|
ItemId: item.ItemID,
|
||||||
|
Order: int32(item.Order),
|
||||||
|
Content: item.Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateDetailToPB(detail forumcontracts.TemplateDetail) *pb.TemplateDetail {
|
||||||
|
preview := make([]*pb.TemplateItemPreview, 0, len(detail.ItemsPreview))
|
||||||
|
for i := range detail.ItemsPreview {
|
||||||
|
item := detail.ItemsPreview[i]
|
||||||
|
preview = append(preview, forumTemplateItemPreviewToPB(item))
|
||||||
|
}
|
||||||
|
return &pb.TemplateDetail{
|
||||||
|
Mode: detail.Mode,
|
||||||
|
StartDate: detail.StartDate,
|
||||||
|
EndDate: detail.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
|
||||||
|
TaskCount: int32(detail.TaskCount),
|
||||||
|
ItemsPreview: preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostDetailToPB(detail *forumcontracts.ForumPostDetail) *pb.ForumPostDetail {
|
||||||
|
if detail == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.ForumPostDetail{
|
||||||
|
Post: forumPostBriefToPB(&detail.Post),
|
||||||
|
Template: forumTemplateDetailToPB(detail.Template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTagItemsToPB(items []forumcontracts.ForumTagItem) []*pb.ForumTagItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumTagItem, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, &pb.ForumTagItem{
|
||||||
|
Tag: item.Tag,
|
||||||
|
PostCount: int32(item.PostCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodeToPB(node *forumcontracts.ForumCommentNode) *pb.ForumCommentNode {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
children := make([]*pb.ForumCommentNode, 0, len(node.Children))
|
||||||
|
for i := range node.Children {
|
||||||
|
child := node.Children[i]
|
||||||
|
children = append(children, forumCommentNodeToPB(&child))
|
||||||
|
}
|
||||||
|
return &pb.ForumCommentNode{
|
||||||
|
CommentId: node.CommentID,
|
||||||
|
PostId: node.PostID,
|
||||||
|
ParentCommentId: forumUint64FromPtr(node.ParentCommentID),
|
||||||
|
Content: node.Content,
|
||||||
|
Status: node.Status,
|
||||||
|
Author: forumUserToPB(node.Author),
|
||||||
|
CanDelete: node.CanDelete,
|
||||||
|
CreatedAt: node.CreatedAt,
|
||||||
|
DeletedAt: forumStringFromPtr(node.DeletedAt),
|
||||||
|
Children: children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodesToPB(items []forumcontracts.ForumCommentNode) []*pb.ForumCommentNode {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumCommentNode, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, forumCommentNodeToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUint64FromPtr(value *uint64) uint64 {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUint64PtrFromPositive(value uint64) *uint64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumStringFromPtr(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import proto "github.com/golang/protobuf/proto"
|
||||||
|
|
||||||
|
var _ = proto.Marshal
|
||||||
|
|
||||||
|
const _ = proto.ProtoPackageIsVersion3
|
||||||
|
|
||||||
|
type PageRequest struct {
|
||||||
|
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PageRequest) Reset() { *m = PageRequest{} }
|
||||||
|
func (m *PageRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*PageRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type PageResponse struct {
|
||||||
|
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"`
|
||||||
|
HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PageResponse) Reset() { *m = PageResponse{} }
|
||||||
|
func (m *PageResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*PageResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UserBrief struct {
|
||||||
|
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||||
|
Nickname string `protobuf:"bytes,2,opt,name=nickname,proto3" json:"nickname,omitempty"`
|
||||||
|
AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserBrief) Reset() { *m = UserBrief{} }
|
||||||
|
func (m *UserBrief) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UserBrief) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateSummary struct {
|
||||||
|
TaskCount int32 `protobuf:"varint,1,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||||
|
Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||||
|
StartDate string `protobuf:"bytes,3,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||||
|
EndDate string `protobuf:"bytes,4,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||||
|
StrategyLabels []string `protobuf:"bytes,5,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateSummary) Reset() { *m = TemplateSummary{} }
|
||||||
|
func (m *TemplateSummary) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateSummary) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostCounters struct {
|
||||||
|
LikeCount int64 `protobuf:"varint,1,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"`
|
||||||
|
CommentCount int64 `protobuf:"varint,2,opt,name=comment_count,json=commentCount,proto3" json:"comment_count,omitempty"`
|
||||||
|
ImportCount int64 `protobuf:"varint,3,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostCounters) Reset() { *m = ForumPostCounters{} }
|
||||||
|
func (m *ForumPostCounters) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostCounters) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostViewerState struct {
|
||||||
|
Liked bool `protobuf:"varint,1,opt,name=liked,proto3" json:"liked,omitempty"`
|
||||||
|
ImportedOnce bool `protobuf:"varint,2,opt,name=imported_once,json=importedOnce,proto3" json:"imported_once,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostViewerState) Reset() { *m = ForumPostViewerState{} }
|
||||||
|
func (m *ForumPostViewerState) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostViewerState) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostBrief struct {
|
||||||
|
PostId uint64 `protobuf:"varint,1,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
Author *UserBrief `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"`
|
||||||
|
TemplateSummary *TemplateSummary `protobuf:"bytes,6,opt,name=template_summary,json=templateSummary,proto3" json:"template_summary,omitempty"`
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,7,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,8,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostBrief) Reset() { *m = ForumPostBrief{} }
|
||||||
|
func (m *ForumPostBrief) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostBrief) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateItemPreview struct {
|
||||||
|
ItemId uint64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
|
||||||
|
Order int32 `protobuf:"varint,2,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateItemPreview) Reset() { *m = TemplateItemPreview{} }
|
||||||
|
func (m *TemplateItemPreview) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateItemPreview) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateDetail struct {
|
||||||
|
Mode string `protobuf:"bytes,1,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||||
|
StartDate string `protobuf:"bytes,2,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||||
|
EndDate string `protobuf:"bytes,3,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||||
|
StrategyLabels []string `protobuf:"bytes,4,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||||
|
TaskCount int32 `protobuf:"varint,5,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||||
|
ItemsPreview []*TemplateItemPreview `protobuf:"bytes,6,rep,name=items_preview,json=itemsPreview,proto3" json:"items_preview,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateDetail) Reset() { *m = TemplateDetail{} }
|
||||||
|
func (m *TemplateDetail) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateDetail) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostDetail struct {
|
||||||
|
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||||
|
Template *TemplateDetail `protobuf:"bytes,2,opt,name=template,proto3" json:"template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostDetail) Reset() { *m = ForumPostDetail{} }
|
||||||
|
func (m *ForumPostDetail) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostDetail) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumCommentNode struct {
|
||||||
|
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
ParentCommentId uint64 `protobuf:"varint,3,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
Author *UserBrief `protobuf:"bytes,6,opt,name=author,proto3" json:"author,omitempty"`
|
||||||
|
CanDelete bool `protobuf:"varint,7,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
DeletedAt string `protobuf:"bytes,9,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"`
|
||||||
|
Children []*ForumCommentNode `protobuf:"bytes,10,rep,name=children,proto3" json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumCommentNode) Reset() { *m = ForumCommentNode{} }
|
||||||
|
func (m *ForumCommentNode) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumCommentNode) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumPostsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Sort string `protobuf:"bytes,4,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||||
|
Keyword string `protobuf:"bytes,5,opt,name=keyword,proto3" json:"keyword,omitempty"`
|
||||||
|
Tag string `protobuf:"bytes,6,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumPostsRequest) Reset() { *m = ListForumPostsRequest{} }
|
||||||
|
func (m *ListForumPostsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumPostsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumPostsResponse struct {
|
||||||
|
Items []*ForumPostBrief `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumPostsResponse) Reset() { *m = ListForumPostsResponse{} }
|
||||||
|
func (m *ListForumPostsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumPostsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumTagsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumTagsRequest) Reset() { *m = ListForumTagsRequest{} }
|
||||||
|
func (m *ListForumTagsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumTagsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumTagItem struct {
|
||||||
|
Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||||
|
PostCount int32 `protobuf:"varint,2,opt,name=post_count,json=postCount,proto3" json:"post_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumTagItem) Reset() { *m = ForumTagItem{} }
|
||||||
|
func (m *ForumTagItem) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumTagItem) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumTagsResponse struct {
|
||||||
|
Items []*ForumTagItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumTagsResponse) Reset() { *m = ListForumTagsResponse{} }
|
||||||
|
func (m *ListForumTagsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumTagsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
TaskClassId uint64 `protobuf:"varint,2,opt,name=task_class_id,json=taskClassId,proto3" json:"task_class_id,omitempty"`
|
||||||
|
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,5,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,6,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumPostRequest) Reset() { *m = CreateForumPostRequest{} }
|
||||||
|
func (m *CreateForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumPostResponse struct {
|
||||||
|
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumPostResponse) Reset() { *m = CreateForumPostResponse{} }
|
||||||
|
func (m *CreateForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetForumPostRequest) Reset() { *m = GetForumPostRequest{} }
|
||||||
|
func (m *GetForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetForumPostResponse struct {
|
||||||
|
Data *ForumPostDetail `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetForumPostResponse) Reset() { *m = GetForumPostResponse{} }
|
||||||
|
func (m *GetForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type LikeForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *LikeForumPostRequest) Reset() { *m = LikeForumPostRequest{} }
|
||||||
|
func (m *LikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*LikeForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type LikeForumPostResponse struct {
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *LikeForumPostResponse) Reset() { *m = LikeForumPostResponse{} }
|
||||||
|
func (m *LikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*LikeForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UnlikeForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnlikeForumPostRequest) Reset() { *m = UnlikeForumPostRequest{} }
|
||||||
|
func (m *UnlikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UnlikeForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UnlikeForumPostResponse struct {
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnlikeForumPostResponse) Reset() { *m = UnlikeForumPostResponse{} }
|
||||||
|
func (m *UnlikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UnlikeForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumCommentsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Sort string `protobuf:"bytes,5,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumCommentsRequest) Reset() { *m = ListForumCommentsRequest{} }
|
||||||
|
func (m *ListForumCommentsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumCommentsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumCommentsResponse struct {
|
||||||
|
Items []*ForumCommentNode `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumCommentsResponse) Reset() { *m = ListForumCommentsResponse{} }
|
||||||
|
func (m *ListForumCommentsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumCommentsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumCommentRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
ParentCommentId uint64 `protobuf:"varint,4,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,5,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumCommentRequest) Reset() { *m = CreateForumCommentRequest{} }
|
||||||
|
func (m *CreateForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumCommentRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumCommentResponse struct {
|
||||||
|
Comment *ForumCommentNode `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumCommentResponse) Reset() { *m = CreateForumCommentResponse{} }
|
||||||
|
func (m *CreateForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumCommentResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type DeleteForumCommentRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
CommentId uint64 `protobuf:"varint,2,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeleteForumCommentRequest) Reset() { *m = DeleteForumCommentRequest{} }
|
||||||
|
func (m *DeleteForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*DeleteForumCommentRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type DeleteForumCommentResponse struct {
|
||||||
|
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeleteForumCommentResponse) Reset() { *m = DeleteForumCommentResponse{} }
|
||||||
|
func (m *DeleteForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*DeleteForumCommentResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ImportForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
TargetTitle string `protobuf:"bytes,3,opt,name=target_title,json=targetTitle,proto3" json:"target_title,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ImportForumPostRequest) Reset() { *m = ImportForumPostRequest{} }
|
||||||
|
func (m *ImportForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ImportForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ImportForumPostResponse struct {
|
||||||
|
ImportId uint64 `protobuf:"varint,1,opt,name=import_id,json=importId,proto3" json:"import_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
NewTaskClassId uint64 `protobuf:"varint,3,opt,name=new_task_class_id,json=newTaskClassId,proto3" json:"new_task_class_id,omitempty"`
|
||||||
|
TaskClassTitle string `protobuf:"bytes,4,opt,name=task_class_title,json=taskClassTitle,proto3" json:"task_class_title,omitempty"`
|
||||||
|
ImportCount int64 `protobuf:"varint,5,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ImportForumPostResponse) Reset() { *m = ImportForumPostResponse{} }
|
||||||
|
func (m *ImportForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ImportForumPostResponse) ProtoMessage() {}
|
||||||
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskClassForumService_ListPosts_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListPosts"
|
||||||
|
TaskClassForumService_ListTags_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListTags"
|
||||||
|
TaskClassForumService_CreatePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreatePost"
|
||||||
|
TaskClassForumService_GetPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/GetPost"
|
||||||
|
TaskClassForumService_LikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/LikePost"
|
||||||
|
TaskClassForumService_UnlikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/UnlikePost"
|
||||||
|
TaskClassForumService_ListComments_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListComments"
|
||||||
|
TaskClassForumService_CreateComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreateComment"
|
||||||
|
TaskClassForumService_DeleteComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/DeleteComment"
|
||||||
|
TaskClassForumService_ImportPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ImportPost"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskClassForumServiceClient interface {
|
||||||
|
ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error)
|
||||||
|
ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error)
|
||||||
|
CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error)
|
||||||
|
GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error)
|
||||||
|
LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error)
|
||||||
|
UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error)
|
||||||
|
ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error)
|
||||||
|
CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error)
|
||||||
|
DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error)
|
||||||
|
ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskClassForumServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskClassForumServiceClient(cc grpc.ClientConnInterface) TaskClassForumServiceClient {
|
||||||
|
return &taskClassForumServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumPostsResponse](ctx, c.cc, TaskClassForumService_ListPosts_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumTagsResponse](ctx, c.cc, TaskClassForumService_ListTags_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[CreateForumPostResponse](ctx, c.cc, TaskClassForumService_CreatePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[GetForumPostResponse](ctx, c.cc, TaskClassForumService_GetPost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[LikeForumPostResponse](ctx, c.cc, TaskClassForumService_LikePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[UnlikeForumPostResponse](ctx, c.cc, TaskClassForumService_UnlikePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumCommentsResponse](ctx, c.cc, TaskClassForumService_ListComments_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error) {
|
||||||
|
return invokeTaskClassForum[CreateForumCommentResponse](ctx, c.cc, TaskClassForumService_CreateComment_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error) {
|
||||||
|
return invokeTaskClassForum[DeleteForumCommentResponse](ctx, c.cc, TaskClassForumService_DeleteComment_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[ImportForumPostResponse](ctx, c.cc, TaskClassForumService_ImportPost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeTaskClassForum[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
|
||||||
|
out := new(Resp)
|
||||||
|
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskClassForumServiceServer interface {
|
||||||
|
ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error)
|
||||||
|
ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error)
|
||||||
|
CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error)
|
||||||
|
GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error)
|
||||||
|
LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error)
|
||||||
|
UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error)
|
||||||
|
ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error)
|
||||||
|
CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error)
|
||||||
|
DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error)
|
||||||
|
ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnimplementedTaskClassForumServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListPosts not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListTags not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreatePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetPost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method LikePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method UnlikePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListComments not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method DeleteComment not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ImportPost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterTaskClassForumServiceServer(s grpc.ServiceRegistrar, srv TaskClassForumServiceServer) {
|
||||||
|
s.RegisterService(&TaskClassForumService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskClassForumUnaryHandler[Req any](methodName string, fullMethod string, invoke func(TaskClassForumServiceServer, context.Context, *Req) (interface{}, error)) grpc.MethodDesc {
|
||||||
|
return grpc.MethodDesc{
|
||||||
|
MethodName: methodName,
|
||||||
|
Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Req)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return invoke(srv.(TaskClassForumServiceServer), ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: fullMethod,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return invoke(srv.(TaskClassForumServiceServer), ctx, req.(*Req))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var TaskClassForumService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "smartflow.taskclassforum.TaskClassForumService",
|
||||||
|
HandlerType: (*TaskClassForumServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
taskClassForumUnaryHandler[ListForumPostsRequest]("ListPosts", TaskClassForumService_ListPosts_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumPostsRequest) (interface{}, error) {
|
||||||
|
return s.ListPosts(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ListForumTagsRequest]("ListTags", TaskClassForumService_ListTags_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumTagsRequest) (interface{}, error) {
|
||||||
|
return s.ListTags(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[CreateForumPostRequest]("CreatePost", TaskClassForumService_CreatePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumPostRequest) (interface{}, error) {
|
||||||
|
return s.CreatePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[GetForumPostRequest]("GetPost", TaskClassForumService_GetPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *GetForumPostRequest) (interface{}, error) {
|
||||||
|
return s.GetPost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[LikeForumPostRequest]("LikePost", TaskClassForumService_LikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *LikeForumPostRequest) (interface{}, error) {
|
||||||
|
return s.LikePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[UnlikeForumPostRequest]("UnlikePost", TaskClassForumService_UnlikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *UnlikeForumPostRequest) (interface{}, error) {
|
||||||
|
return s.UnlikePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ListForumCommentsRequest]("ListComments", TaskClassForumService_ListComments_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumCommentsRequest) (interface{}, error) {
|
||||||
|
return s.ListComments(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[CreateForumCommentRequest]("CreateComment", TaskClassForumService_CreateComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumCommentRequest) (interface{}, error) {
|
||||||
|
return s.CreateComment(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[DeleteForumCommentRequest]("DeleteComment", TaskClassForumService_DeleteComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *DeleteForumCommentRequest) (interface{}, error) {
|
||||||
|
return s.DeleteComment(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ImportForumPostRequest]("ImportPost", TaskClassForumService_ImportPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ImportForumPostRequest) (interface{}, error) {
|
||||||
|
return s.ImportPost(ctx, req)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "taskclassforum.proto",
|
||||||
|
}
|
||||||
73
backend/services/taskclassforum/rpc/server.go
Normal file
73
backend/services/taskclassforum/rpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
"github.com/zeromicro/go-zero/core/service"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListenOn = "0.0.0.0:9090"
|
||||||
|
defaultTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerOptions struct {
|
||||||
|
ListenOn string
|
||||||
|
Timeout time.Duration
|
||||||
|
Service *forumsv.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动计划广场 zrpc 服务。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责装配 go-zero zrpc server 和注册 protobuf service;
|
||||||
|
// 2. 不创建 DB 连接,也不装配 TaskClass legacy adapter,这些依赖由 cmd 入口注入;
|
||||||
|
// 3. 启动后阻塞当前进程,保持后续“一服务一进程”的迁移方向。
|
||||||
|
func Start(opts ServerOptions) {
|
||||||
|
server, listenOn, err := NewServer(opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build taskclassforum zrpc server: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
log.Printf("taskclassforum zrpc service starting on %s", listenOn)
|
||||||
|
server.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer 负责创建计划广场 RPC server,供 cmd 启动和测试复用。
|
||||||
|
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||||
|
if opts.Service == nil {
|
||||||
|
return nil, "", errors.New("taskclassforum service dependency not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||||
|
if listenOn == "" {
|
||||||
|
listenOn = defaultListenOn
|
||||||
|
}
|
||||||
|
timeout := opts.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||||
|
ServiceConf: service.ServiceConf{
|
||||||
|
Name: "taskclassforum.rpc",
|
||||||
|
Mode: service.DevMode,
|
||||||
|
},
|
||||||
|
ListenOn: listenOn,
|
||||||
|
Timeout: int64(timeout / time.Millisecond),
|
||||||
|
}, func(grpcServer *grpc.Server) {
|
||||||
|
pb.RegisterTaskClassForumServiceServer(grpcServer, NewHandler(opts.Service))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return server, listenOn, nil
|
||||||
|
}
|
||||||
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package smartflow.taskclassforum;
|
||||||
|
|
||||||
|
option go_package = "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb";
|
||||||
|
|
||||||
|
service TaskClassForumService {
|
||||||
|
rpc ListPosts(ListForumPostsRequest) returns (ListForumPostsResponse);
|
||||||
|
rpc ListTags(ListForumTagsRequest) returns (ListForumTagsResponse);
|
||||||
|
rpc CreatePost(CreateForumPostRequest) returns (CreateForumPostResponse);
|
||||||
|
rpc GetPost(GetForumPostRequest) returns (GetForumPostResponse);
|
||||||
|
rpc LikePost(LikeForumPostRequest) returns (LikeForumPostResponse);
|
||||||
|
rpc UnlikePost(UnlikeForumPostRequest) returns (UnlikeForumPostResponse);
|
||||||
|
rpc ListComments(ListForumCommentsRequest) returns (ListForumCommentsResponse);
|
||||||
|
rpc CreateComment(CreateForumCommentRequest) returns (CreateForumCommentResponse);
|
||||||
|
rpc DeleteComment(DeleteForumCommentRequest) returns (DeleteForumCommentResponse);
|
||||||
|
rpc ImportPost(ImportForumPostRequest) returns (ImportForumPostResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message PageRequest {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PageResponse {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
int32 total = 3;
|
||||||
|
bool has_more = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserBrief {
|
||||||
|
uint64 user_id = 1;
|
||||||
|
string nickname = 2;
|
||||||
|
string avatar_url = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateSummary {
|
||||||
|
int32 task_count = 1;
|
||||||
|
string mode = 2;
|
||||||
|
string start_date = 3;
|
||||||
|
string end_date = 4;
|
||||||
|
repeated string strategy_labels = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostCounters {
|
||||||
|
int64 like_count = 1;
|
||||||
|
int64 comment_count = 2;
|
||||||
|
int64 import_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostViewerState {
|
||||||
|
bool liked = 1;
|
||||||
|
bool imported_once = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostBrief {
|
||||||
|
uint64 post_id = 1;
|
||||||
|
string title = 2;
|
||||||
|
string summary = 3;
|
||||||
|
repeated string tags = 4;
|
||||||
|
UserBrief author = 5;
|
||||||
|
TemplateSummary template_summary = 6;
|
||||||
|
ForumPostCounters counters = 7;
|
||||||
|
ForumPostViewerState viewer_state = 8;
|
||||||
|
string status = 9;
|
||||||
|
string created_at = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateItemPreview {
|
||||||
|
uint64 item_id = 1;
|
||||||
|
int32 order = 2;
|
||||||
|
string content = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateDetail {
|
||||||
|
string mode = 1;
|
||||||
|
string start_date = 2;
|
||||||
|
string end_date = 3;
|
||||||
|
repeated string strategy_labels = 4;
|
||||||
|
int32 task_count = 5;
|
||||||
|
repeated TemplateItemPreview items_preview = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostDetail {
|
||||||
|
ForumPostBrief post = 1;
|
||||||
|
TemplateDetail template = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumCommentNode {
|
||||||
|
uint64 comment_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
uint64 parent_comment_id = 3;
|
||||||
|
string content = 4;
|
||||||
|
string status = 5;
|
||||||
|
UserBrief author = 6;
|
||||||
|
bool can_delete = 7;
|
||||||
|
string created_at = 8;
|
||||||
|
string deleted_at = 9;
|
||||||
|
repeated ForumCommentNode children = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumPostsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 page = 2;
|
||||||
|
int32 page_size = 3;
|
||||||
|
string sort = 4;
|
||||||
|
string keyword = 5;
|
||||||
|
string tag = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumPostsResponse {
|
||||||
|
repeated ForumPostBrief items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumTagsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumTagItem {
|
||||||
|
string tag = 1;
|
||||||
|
int32 post_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumTagsResponse {
|
||||||
|
repeated ForumTagItem items = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 task_class_id = 2;
|
||||||
|
string title = 3;
|
||||||
|
string summary = 4;
|
||||||
|
repeated string tags = 5;
|
||||||
|
string idempotency_key = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumPostResponse {
|
||||||
|
ForumPostBrief post = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetForumPostResponse {
|
||||||
|
ForumPostDetail data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LikeForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LikeForumPostResponse {
|
||||||
|
ForumPostCounters counters = 1;
|
||||||
|
ForumPostViewerState viewer_state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlikeForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlikeForumPostResponse {
|
||||||
|
ForumPostCounters counters = 1;
|
||||||
|
ForumPostViewerState viewer_state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumCommentsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
int32 page = 3;
|
||||||
|
int32 page_size = 4;
|
||||||
|
string sort = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumCommentsResponse {
|
||||||
|
repeated ForumCommentNode items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumCommentRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
string content = 3;
|
||||||
|
uint64 parent_comment_id = 4;
|
||||||
|
string idempotency_key = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumCommentResponse {
|
||||||
|
ForumCommentNode comment = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteForumCommentRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 comment_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteForumCommentResponse {
|
||||||
|
uint64 comment_id = 1;
|
||||||
|
string status = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImportForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
string target_title = 3;
|
||||||
|
string idempotency_key = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImportForumPostResponse {
|
||||||
|
uint64 import_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
uint64 new_task_class_id = 3;
|
||||||
|
string task_class_title = 4;
|
||||||
|
int64 import_count = 5;
|
||||||
|
string created_at = 6;
|
||||||
|
}
|
||||||
284
backend/services/taskclassforum/sv/comment.go
Normal file
284
backend/services/taskclassforum/sv/comment.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/commenttree"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListComments 查询评论树。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端;
|
||||||
|
// 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装;
|
||||||
|
// 3. 采用 cache-aside 缓存去个性化评论树,返回前再补当前用户的删除权限。
|
||||||
|
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if postID == 0 {
|
||||||
|
return nil, forumcontracts.PageResult{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
page, pageSize = normalizePage(page, pageSize)
|
||||||
|
sortBy = normalizeCommentSort(sortBy)
|
||||||
|
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cachedItems, cachedPage, hit := s.getCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy); hit {
|
||||||
|
return personalizeCommentNodesForActor(cachedItems, actorUserID), cachedPage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := s.forumDAO.CountRootComments(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
roots, err := s.forumDAO.ListRootComments(ctx, postID, page, pageSize, sortBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
resultPage := pageResult(page, pageSize, total)
|
||||||
|
if len(roots) == 0 {
|
||||||
|
emptyItems := []forumcontracts.ForumCommentNode{}
|
||||||
|
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, emptyItems, resultPage)
|
||||||
|
return emptyItems, resultPage, nil
|
||||||
|
}
|
||||||
|
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0)
|
||||||
|
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage)
|
||||||
|
return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateComment 创建帖子评论或多层回复。
|
||||||
|
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.ActorUserID == 0 || req.PostID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
if err := validateRuneMax(req.Content, maxCommentLen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
existing, err := s.forumDAO.FindCommentByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return commentModelToNode(*existing, req.ActorUserID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var created forummodel.ForumComment
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
if req.ParentCommentID != nil {
|
||||||
|
parent, err := txDAO.FindCommentByID(ctx, *req.ParentCommentID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||||
|
}
|
||||||
|
if parent.PostID != req.PostID {
|
||||||
|
return respond.MissingParam
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created = forummodel.ForumComment{
|
||||||
|
PostID: req.PostID,
|
||||||
|
ParentCommentID: req.ParentCommentID,
|
||||||
|
UserID: req.ActorUserID,
|
||||||
|
Content: strings.TrimSpace(req.Content),
|
||||||
|
Status: forummodel.ForumCommentStatusVisible,
|
||||||
|
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateComment(ctx, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "comment_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.bumpCommentTreeVersionBestEffort(req.PostID)
|
||||||
|
return commentModelToNode(created, req.ActorUserID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment 软删除当前用户自己的评论。
|
||||||
|
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || commentID == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedAt *string
|
||||||
|
var changedPostID uint64
|
||||||
|
status := forummodel.ForumCommentStatusDeleted
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
comment, err := txDAO.LockCommentByID(ctx, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.MissingParam)
|
||||||
|
}
|
||||||
|
if comment.UserID != actorUserID {
|
||||||
|
return respond.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||||
|
deletedAt = formatTimePtr(comment.DeletedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := txDAO.SoftDeleteComment(ctx, commentID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changedPostID = comment.PostID
|
||||||
|
deletedAt = formatTimePtr(&now)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if changedPostID != 0 {
|
||||||
|
s.bumpCommentTreeVersionBestEffort(changedPostID)
|
||||||
|
}
|
||||||
|
return &forumcontracts.DeleteForumCommentResult{
|
||||||
|
CommentID: commentID,
|
||||||
|
Status: status,
|
||||||
|
Content: "",
|
||||||
|
DeletedAt: deletedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentModelToNode(comment forummodel.ForumComment, actorUserID uint64) *forumcontracts.ForumCommentNode {
|
||||||
|
content := comment.Content
|
||||||
|
if comment.Status == forummodel.ForumCommentStatusDeleted {
|
||||||
|
content = "该评论已删除"
|
||||||
|
}
|
||||||
|
return &forumcontracts.ForumCommentNode{
|
||||||
|
CommentID: comment.ID,
|
||||||
|
PostID: comment.PostID,
|
||||||
|
ParentCommentID: comment.ParentCommentID,
|
||||||
|
Content: content,
|
||||||
|
Status: comment.Status,
|
||||||
|
Author: userBrief(comment.UserID),
|
||||||
|
CanDelete: comment.Status == forummodel.ForumCommentStatusVisible && comment.UserID == actorUserID,
|
||||||
|
CreatedAt: formatTime(comment.CreatedAt),
|
||||||
|
DeletedAt: formatTimePtr(comment.DeletedAt),
|
||||||
|
Children: []forumcontracts.ForumCommentNode{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCommentsForRoots(allComments []forummodel.ForumComment, roots []forummodel.ForumComment) []forummodel.ForumComment {
|
||||||
|
filtered := make([]forummodel.ForumComment, 0, len(allComments))
|
||||||
|
included := make(map[uint64]struct{}, len(allComments))
|
||||||
|
for _, root := range roots {
|
||||||
|
filtered = append(filtered, root)
|
||||||
|
included[root.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
candidateSet := make(map[uint64]struct{}, len(allComments))
|
||||||
|
for _, root := range roots {
|
||||||
|
collectDescendantCommentIDs(root.ID, allComments, candidateSet)
|
||||||
|
}
|
||||||
|
for _, comment := range allComments {
|
||||||
|
if _, ok := included[comment.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := candidateSet[comment.ID]; ok {
|
||||||
|
filtered = append(filtered, comment)
|
||||||
|
included[comment.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumComment, result map[uint64]struct{}) {
|
||||||
|
for _, comment := range comments {
|
||||||
|
if comment.ParentCommentID == nil || *comment.ParentCommentID != parentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := result[comment.ID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[comment.ID] = struct{}{}
|
||||||
|
collectDescendantCommentIDs(comment.ID, comments, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommentSort(sortBy string) string {
|
||||||
|
if strings.TrimSpace(sortBy) == "latest" {
|
||||||
|
return "latest"
|
||||||
|
}
|
||||||
|
return "oldest"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool) {
|
||||||
|
if s == nil || s.commentTreeCache == nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, false
|
||||||
|
}
|
||||||
|
items, resultPage, hit, err := s.commentTreeCache.GetCommentTree(ctx, postID, page, pageSize, sortBy)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("评论树缓存读取失败,已降级回源 DB post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||||
|
return nil, forumcontracts.PageResult{}, false
|
||||||
|
}
|
||||||
|
return items, resultPage, hit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) setCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string, items []forumcontracts.ForumCommentNode, resultPage forumcontracts.PageResult) {
|
||||||
|
if s == nil || s.commentTreeCache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.commentTreeCache.SetCommentTree(ctx, postID, page, pageSize, sortBy, items, resultPage); err != nil {
|
||||||
|
log.Printf("评论树缓存写入失败,已保持 DB 结果返回 post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) bumpCommentTreeVersionBestEffort(postID uint64) {
|
||||||
|
if s == nil || s.commentTreeCache == nil || postID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 写库事务已经成功,缓存失效不应再反向影响评论发布/删除结果。
|
||||||
|
// 2. 使用独立短超时 context,避免客户端取消请求后漏掉版本递增。
|
||||||
|
// 3. 失败时只记录日志,旧缓存依靠短 TTL 自然过期作为兜底。
|
||||||
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.commentTreeCache.BumpCommentTreeVersion(cacheCtx, postID); err != nil {
|
||||||
|
log.Printf("评论树缓存版本递增失败,等待短 TTL 自然过期 post_id=%d err=%v", postID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func personalizeCommentNodesForActor(nodes []forumcontracts.ForumCommentNode, actorUserID uint64) []forumcontracts.ForumCommentNode {
|
||||||
|
if nodes == nil {
|
||||||
|
return []forumcontracts.ForumCommentNode{}
|
||||||
|
}
|
||||||
|
result := make([]forumcontracts.ForumCommentNode, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
result = append(result, personalizeCommentNodeForActor(node, actorUserID))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func personalizeCommentNodeForActor(node forumcontracts.ForumCommentNode, actorUserID uint64) forumcontracts.ForumCommentNode {
|
||||||
|
children := make([]forumcontracts.ForumCommentNode, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
children = append(children, personalizeCommentNodeForActor(child, actorUserID))
|
||||||
|
}
|
||||||
|
node.Children = children
|
||||||
|
node.CanDelete = actorUserID != 0 &&
|
||||||
|
node.Author.UserID == actorUserID &&
|
||||||
|
node.Status == forummodel.ForumCommentStatusVisible
|
||||||
|
return node
|
||||||
|
}
|
||||||
8
backend/services/taskclassforum/sv/errors.go
Normal file
8
backend/services/taskclassforum/sv/errors.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass,但 adapter 尚未注入。
|
||||||
|
ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil")
|
||||||
|
)
|
||||||
294
backend/services/taskclassforum/sv/helpers.go
Normal file
294
backend/services/taskclassforum/sv/helpers.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPage = 1
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 50
|
||||||
|
maxPostTitleLen = 40
|
||||||
|
maxSummaryLen = 300
|
||||||
|
maxTagCount = 5
|
||||||
|
maxTagLength = 12
|
||||||
|
maxCommentLen = 500
|
||||||
|
maxImportTitle = 80
|
||||||
|
)
|
||||||
|
|
||||||
|
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) forumcontracts.PageResult {
|
||||||
|
return forumcontracts.PageResult{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Total: int(total),
|
||||||
|
HasMore: int64(page*pageSize) < total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTags(tags []string) ([]string, error) {
|
||||||
|
result := make([]string, 0, len(tags))
|
||||||
|
seen := make(map[string]struct{}, len(tags))
|
||||||
|
for _, raw := range tags {
|
||||||
|
tag := strings.TrimSpace(raw)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len([]rune(tag)) > maxTagLength {
|
||||||
|
return nil, respond.ParamTooLong
|
||||||
|
}
|
||||||
|
if _, exists := seen[tag]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[tag] = struct{}{}
|
||||||
|
result = append(result, tag)
|
||||||
|
if len(result) > maxTagCount {
|
||||||
|
return nil, respond.ParamTooLong
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRuneMax(value string, maxLen int) error {
|
||||||
|
if len([]rune(strings.TrimSpace(value))) > maxLen {
|
||||||
|
return respond.ParamTooLong
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagsToJSON(tags []string) (string, error) {
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(tags)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagsFromJSON(raw string) []string {
|
||||||
|
var tags []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func intSliceToJSONPtr(values []int) (*string, error) {
|
||||||
|
if values == nil {
|
||||||
|
values = []int{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := string(raw)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceToJSONPtr(values []string) (*string, error) {
|
||||||
|
if values == nil {
|
||||||
|
values = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := string(raw)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intSliceFromJSONPtr(raw *string) []int {
|
||||||
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
var values []int
|
||||||
|
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceFromJSONPtr(raw *string) []string {
|
||||||
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSnapshotDate(value string) *time.Time {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(value *time.Time) string {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 userBrief(userID uint64) forumcontracts.UserBrief {
|
||||||
|
return forumcontracts.UserBrief{
|
||||||
|
UserID: userID,
|
||||||
|
Nickname: fmt.Sprintf("用户%d", userID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countersFromPost(post forummodel.ForumPost) forumcontracts.ForumPostCounters {
|
||||||
|
return forumcontracts.ForumPostCounters{
|
||||||
|
LikeCount: post.LikeCount,
|
||||||
|
CommentCount: post.CommentCount,
|
||||||
|
ImportCount: post.ImportCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewerState(postID uint64, liked map[uint64]bool, imported map[uint64]bool) forumcontracts.ForumPostViewerState {
|
||||||
|
return forumcontracts.ForumPostViewerState{
|
||||||
|
Liked: liked[postID],
|
||||||
|
ImportedOnce: imported[postID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateSummaryFromTemplate(template *forummodel.ForumPostTemplate, itemCount int) forumcontracts.TemplateSummary {
|
||||||
|
if template == nil {
|
||||||
|
return forumcontracts.TemplateSummary{}
|
||||||
|
}
|
||||||
|
return forumcontracts.TemplateSummary{
|
||||||
|
TaskCount: itemCount,
|
||||||
|
Mode: template.Mode,
|
||||||
|
StartDate: formatDate(template.StartDate),
|
||||||
|
EndDate: formatDate(template.EndDate),
|
||||||
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postBriefFromModel(post forummodel.ForumPost, template *forummodel.ForumPostTemplate, itemCount int, state forumcontracts.ForumPostViewerState) forumcontracts.ForumPostBrief {
|
||||||
|
return forumcontracts.ForumPostBrief{
|
||||||
|
PostID: post.ID,
|
||||||
|
Title: post.Title,
|
||||||
|
Summary: post.Summary,
|
||||||
|
Tags: tagsFromJSON(post.TagsJSON),
|
||||||
|
Author: userBrief(post.AuthorUserID),
|
||||||
|
TemplateSummary: templateSummaryFromTemplate(template, itemCount),
|
||||||
|
Counters: countersFromPost(post),
|
||||||
|
ViewerState: state,
|
||||||
|
Status: post.Status,
|
||||||
|
CreatedAt: formatTime(post.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateDetailFromModel(template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) forumcontracts.TemplateDetail {
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
return items[i].Order < items[j].Order
|
||||||
|
})
|
||||||
|
preview := make([]forumcontracts.TemplateItemPreview, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
preview = append(preview, forumcontracts.TemplateItemPreview{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Order: item.Order,
|
||||||
|
Content: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return forumcontracts.TemplateDetail{
|
||||||
|
Mode: template.Mode,
|
||||||
|
StartDate: formatDate(template.StartDate),
|
||||||
|
EndDate: formatDate(template.EndDate),
|
||||||
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||||
|
TaskCount: len(items),
|
||||||
|
ItemsPreview: preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFromTemplate(post forummodel.ForumPost, template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) TaskClassSnapshot {
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
return items[i].Order < items[j].Order
|
||||||
|
})
|
||||||
|
snapshotItems := make([]TaskClassSnapshotItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
snapshotItems = append(snapshotItems, TaskClassSnapshotItem{
|
||||||
|
TaskItemID: item.SourceTaskItemID,
|
||||||
|
Order: item.Order,
|
||||||
|
Content: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return TaskClassSnapshot{
|
||||||
|
TaskClassID: template.SourceTaskClassID,
|
||||||
|
Title: post.Title,
|
||||||
|
Mode: template.Mode,
|
||||||
|
StartDate: formatDate(template.StartDate),
|
||||||
|
EndDate: formatDate(template.EndDate),
|
||||||
|
SubjectType: template.SubjectType,
|
||||||
|
DifficultyLevel: template.DifficultyLevel,
|
||||||
|
CognitiveIntensity: template.CognitiveIntensity,
|
||||||
|
TotalSlots: template.TotalSlots,
|
||||||
|
AllowFillerCourse: template.AllowFillerCourse,
|
||||||
|
Strategy: template.Strategy,
|
||||||
|
ExcludedSlots: intSliceFromJSONPtr(template.ExcludedSlotsJSON),
|
||||||
|
ExcludedDaysOfWeek: intSliceFromJSONPtr(template.ExcludedDaysOfWeekJSON),
|
||||||
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
||||||
|
Items: snapshotItems,
|
||||||
|
ConfigSnapshotJSON: stringFromPtr(template.ConfigSnapshotJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromPtr(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtrFromNonEmpty(value string) *string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
283
backend/services/taskclassforum/sv/import.go
Normal file
283
backend/services/taskclassforum/sv/import.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 同一用户同一帖子只允许导入一次,由 forum_imports 唯一约束兜底;
|
||||||
|
// 2. 只通过 TaskClassSnapshotPort 创建 TaskClass,不写 schedule;
|
||||||
|
// 3. 只写 forum_imports 和 import_count,Token 奖励后续基于 event_id 消费。
|
||||||
|
func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.ActorUserID == 0 || req.PostID == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.TargetTitle) != "" {
|
||||||
|
if err := validateRuneMax(req.TargetTitle, maxImportTitle); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.taskClassPort == nil {
|
||||||
|
return nil, ErrTaskClassPortMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
existing, err := s.forumDAO.FindImportByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusImported {
|
||||||
|
return s.importResultWithCurrentImportCount(ctx, *existing), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing, err := s.forumDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusImported {
|
||||||
|
return s.importResultWithCurrentImportCount(ctx, *existing), nil
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusFailed && existing.NewTaskClassID != nil {
|
||||||
|
return s.recoverCreatedImport(ctx, req, *existing)
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == forummodel.ForumImportStatusPending {
|
||||||
|
return nil, respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
|
||||||
|
post, template, items, err := s.loadPostTemplate(ctx, req.PostID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshot := snapshotFromTemplate(*post, *template, items)
|
||||||
|
targetTitle := strings.TrimSpace(req.TargetTitle)
|
||||||
|
if targetTitle == "" {
|
||||||
|
targetTitle = post.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := s.reserveImport(ctx, req, post.AuthorUserID, targetTitle, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pending.Status == forummodel.ForumImportStatusImported {
|
||||||
|
return s.importResultWithCurrentImportCount(ctx, *pending), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.taskClassPort.CreateTaskClassFromSnapshot(ctx, req.ActorUserID, snapshot, targetTitle)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Error(), time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if created == nil {
|
||||||
|
err := respond.InternalError(fmt.Errorf("taskclass adapter returned nil created taskclass"))
|
||||||
|
_ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Info, time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var imported forummodel.ForumImport
|
||||||
|
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if again == nil || again.ID != pending.ID {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
if again.Status == forummodel.ForumImportStatusImported {
|
||||||
|
imported = *again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
finalizedAt := time.Now()
|
||||||
|
if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, finalizedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imported = *again
|
||||||
|
imported.NewTaskClassID = &created.TaskClassID
|
||||||
|
imported.TargetTitle = created.Title
|
||||||
|
imported.Status = forummodel.ForumImportStatusImported
|
||||||
|
if again.Status != forummodel.ForumImportStatusImported {
|
||||||
|
payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt)
|
||||||
|
if again.EventID != "" {
|
||||||
|
payload.EventID = again.EventID
|
||||||
|
}
|
||||||
|
// 调用目的:导入成功和作者奖励事件必须同事务提交,避免只创建副本却永久漏发奖励。
|
||||||
|
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||||
|
if publishErr != nil {
|
||||||
|
return publishErr
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
rewardPayload = &payload
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
_ = s.forumDAO.MarkImportFailedAfterTaskClassCreated(ctx, pending.ID, created.TaskClassID, created.Title, err.Error(), time.Now())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rewardPayload != nil {
|
||||||
|
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||||
|
}
|
||||||
|
result := importResultFromModel(imported)
|
||||||
|
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||||
|
result.ImportCount = postAfter.ImportCount
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) reserveImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, authorUserID uint64, targetTitle string, idempotencyKey string) (*forummodel.ForumImport, error) {
|
||||||
|
var reserved *forummodel.ForumImport
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
existing, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
switch existing.Status {
|
||||||
|
case forummodel.ForumImportStatusImported:
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
case forummodel.ForumImportStatusPending:
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
case forummodel.ForumImportStatusFailed:
|
||||||
|
if existing.NewTaskClassID != nil {
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.UpdateImportProcessing(ctx, existing.ID, targetTitle, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing.Status = forummodel.ForumImportStatusPending
|
||||||
|
existing.TargetTitle = targetTitle
|
||||||
|
reserved = existing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := &forummodel.ForumImport{
|
||||||
|
PostID: req.PostID,
|
||||||
|
UserID: req.ActorUserID,
|
||||||
|
AuthorUserID: authorUserID,
|
||||||
|
TargetTitle: targetTitle,
|
||||||
|
Status: forummodel.ForumImportStatusPending,
|
||||||
|
EventID: forumImportEventID(req.PostID, req.ActorUserID),
|
||||||
|
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateImport(ctx, item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reserved = item
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reserved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, existing forummodel.ForumImport) (*forumcontracts.ImportForumPostResult, error) {
|
||||||
|
if existing.NewTaskClassID == nil {
|
||||||
|
return nil, respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
imported := existing
|
||||||
|
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if again == nil || again.ID != existing.ID {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
if again.Status == forummodel.ForumImportStatusImported {
|
||||||
|
imported = *again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if again.Status != forummodel.ForumImportStatusFailed || again.NewTaskClassID == nil {
|
||||||
|
return respond.RequestIsProcessing
|
||||||
|
}
|
||||||
|
finalizedAt := time.Now()
|
||||||
|
if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, finalizedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imported = *again
|
||||||
|
imported.Status = forummodel.ForumImportStatusImported
|
||||||
|
payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt)
|
||||||
|
if again.EventID != "" {
|
||||||
|
payload.EventID = again.EventID
|
||||||
|
}
|
||||||
|
// 调用目的:恢复已创建副本的导入记录时,同步补齐奖励 outbox,保证恢复路径和首次成功路径一致。
|
||||||
|
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||||
|
if publishErr != nil {
|
||||||
|
return publishErr
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
rewardPayload = &payload
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rewardPayload != nil {
|
||||||
|
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||||
|
}
|
||||||
|
result := importResultFromModel(imported)
|
||||||
|
if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil {
|
||||||
|
result.ImportCount = postAfter.ImportCount
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importResultFromModel(item forummodel.ForumImport) *forumcontracts.ImportForumPostResult {
|
||||||
|
var newTaskClassID uint64
|
||||||
|
if item.NewTaskClassID != nil {
|
||||||
|
newTaskClassID = *item.NewTaskClassID
|
||||||
|
}
|
||||||
|
return &forumcontracts.ImportForumPostResult{
|
||||||
|
ImportID: item.ID,
|
||||||
|
PostID: item.PostID,
|
||||||
|
NewTaskClassID: newTaskClassID,
|
||||||
|
TaskClassTitle: item.TargetTitle,
|
||||||
|
CreatedAt: formatTime(item.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importResultWithCurrentImportCount 复用已有导入记录时补齐帖子当前导入计数。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只补齐响应展示用的 import_count,不改变 forum_imports 状态;
|
||||||
|
// 2. 查询帖子失败时保留基础导入回执,避免幂等重放因为展示字段失败而误报导入失败;
|
||||||
|
// 3. 新导入路径仍以事务内 AddPostCounter 为准,这里只处理已导入短路路径。
|
||||||
|
func (s *Service) importResultWithCurrentImportCount(ctx context.Context, item forummodel.ForumImport) *forumcontracts.ImportForumPostResult {
|
||||||
|
result := importResultFromModel(item)
|
||||||
|
if post, err := s.forumDAO.FindPublishedPost(ctx, item.PostID); err == nil {
|
||||||
|
result.ImportCount = post.ImportCount
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumImportEventID(postID uint64, userID uint64) string {
|
||||||
|
return sharedevents.ForumRewardEventID(sharedevents.ForumPostImportedEventType, postID, userID)
|
||||||
|
}
|
||||||
140
backend/services/taskclassforum/sv/like.go
Normal file
140
backend/services/taskclassforum/sv/like.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LikePost 点赞计划帖子。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 保证同一用户同一帖子只有一个 active 点赞状态;
|
||||||
|
// 2. 维护帖子 like_count 计数字段;
|
||||||
|
// 3. 只在首次创建 like 记录时补发 outbox 事件,取消后重新激活旧记录不重复发奖励。
|
||||||
|
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || postID == 0 {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
var rewardPayload *sharedevents.ForumPostRewardPayload
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
post, err := txDAO.LockPublishedPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if like == nil {
|
||||||
|
payload, createErr := createActiveLike(ctx, txDAO, post, actorUserID)
|
||||||
|
if createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
// 调用目的:优先把首次点赞奖励事件写入当前事务,保证点赞记录和 outbox 入队原子提交。
|
||||||
|
handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload)
|
||||||
|
if publishErr != nil {
|
||||||
|
return publishErr
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
rewardPayload = &payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if like.Status == forummodel.ForumLikeStatusActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.ActivateLike(ctx, like.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, postID, "like_count", 1)
|
||||||
|
}); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rewardPayload != nil {
|
||||||
|
s.publishForumRewardEventBestEffort(*rewardPayload)
|
||||||
|
}
|
||||||
|
return s.postInteractionState(ctx, actorUserID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlikePost 取消计划帖子点赞。
|
||||||
|
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 || postID == 0 {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
|
||||||
|
if _, err := txDAO.LockPublishedPost(ctx, postID); err != nil {
|
||||||
|
return normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
like, err := txDAO.FindLike(ctx, postID, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if like == nil || like.Status != forummodel.ForumLikeStatusActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := txDAO.CancelLike(ctx, like.ID, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txDAO.AddPostCounter(ctx, postID, "like_count", -1)
|
||||||
|
}); err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
return s.postInteractionState(ctx, actorUserID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) (sharedevents.ForumPostRewardPayload, error) {
|
||||||
|
like := &forummodel.ForumLike{
|
||||||
|
PostID: post.ID,
|
||||||
|
UserID: actorUserID,
|
||||||
|
AuthorUserID: post.AuthorUserID,
|
||||||
|
Status: forummodel.ForumLikeStatusActive,
|
||||||
|
EventID: forumLikeEventID(post.ID, actorUserID),
|
||||||
|
}
|
||||||
|
if err := txDAO.CreateLike(ctx, like); err != nil {
|
||||||
|
return sharedevents.ForumPostRewardPayload{}, err
|
||||||
|
}
|
||||||
|
if err := txDAO.AddPostCounter(ctx, post.ID, "like_count", 1); err != nil {
|
||||||
|
return sharedevents.ForumPostRewardPayload{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
likedAt := like.LikedAt
|
||||||
|
if likedAt.IsZero() {
|
||||||
|
likedAt = time.Now()
|
||||||
|
}
|
||||||
|
payload := sharedevents.NewForumPostLikedPayload(post.ID, post.AuthorUserID, actorUserID, likedAt)
|
||||||
|
if like.EventID != "" {
|
||||||
|
payload.EventID = like.EventID
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
|
||||||
|
post, err := s.forumDAO.FindPublishedPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||||
|
if err != nil {
|
||||||
|
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err
|
||||||
|
}
|
||||||
|
return countersFromPost(*post), viewerState(postID, liked, imported), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumLikeEventID(postID uint64, userID uint64) string {
|
||||||
|
return sharedevents.ForumRewardEventID(sharedevents.ForumPostLikedEventType, postID, userID)
|
||||||
|
}
|
||||||
339
backend/services/taskclassforum/sv/post.go
Normal file
339
backend/services/taskclassforum/sv/post.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListPosts 查询计划广场帖子列表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责分页、排序、关键词和标签筛选的业务口径;
|
||||||
|
// 2. 负责补齐模板摘要、当前用户点赞/导入状态;
|
||||||
|
// 3. 不读取原作者当前 TaskClass,列表只基于论坛快照表。
|
||||||
|
func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sortBy string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
page, pageSize = normalizePage(page, pageSize)
|
||||||
|
|
||||||
|
posts, total, err := s.forumDAO.ListPosts(ctx, forumdao.ListPostsQuery{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Sort: sortBy,
|
||||||
|
Keyword: keyword,
|
||||||
|
Tag: tag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return []forumcontracts.ForumPostBrief{}, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
postIDs := collectPostIDs(posts)
|
||||||
|
templates, err := s.forumDAO.FindTemplatesByPostIDs(ctx, postIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
itemCounts, err := s.forumDAO.CountTemplateItemsByPostIDs(ctx, postIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
liked, imported, err := s.viewerStateSets(ctx, actorUserID, postIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forumcontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]forumcontracts.ForumPostBrief, 0, len(posts))
|
||||||
|
for _, post := range posts {
|
||||||
|
template, ok := templates[post.ID]
|
||||||
|
var templatePtr *forummodel.ForumPostTemplate
|
||||||
|
if ok {
|
||||||
|
templateCopy := template
|
||||||
|
templatePtr = &templateCopy
|
||||||
|
}
|
||||||
|
result = append(result, postBriefFromModel(post, templatePtr, itemCounts[post.ID], viewerState(post.ID, liked, imported)))
|
||||||
|
}
|
||||||
|
return result, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTags 聚合计划广场已发布帖子的标签。
|
||||||
|
func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) {
|
||||||
|
_ = actorUserID
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counter := make(map[string]int)
|
||||||
|
for _, raw := range rawTags {
|
||||||
|
for _, tag := range tagsFromJSON(raw) {
|
||||||
|
if strings.TrimSpace(tag) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counter[tag]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]forumcontracts.ForumTagItem, 0, len(counter))
|
||||||
|
for tag, count := range counter {
|
||||||
|
items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count})
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].PostCount == items[j].PostCount {
|
||||||
|
return items[i].Tag < items[j].Tag
|
||||||
|
}
|
||||||
|
return items[i].PostCount > items[j].PostCount
|
||||||
|
})
|
||||||
|
if len(items) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePost 发布计划,并把旧 TaskClass 复制为论坛快照。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 通过 TaskClassSnapshotPort 获取当前用户自己的 TaskClass 快照;
|
||||||
|
// 2. 在论坛私有表写帖子、模板和模板条目;
|
||||||
|
// 3. 不修改旧 TaskClass,也不写 schedule。
|
||||||
|
func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.ActorUserID == 0 || req.TaskClassID == 0 || strings.TrimSpace(req.Title) == "" {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
if err := validateRuneMax(req.Title, maxPostTitleLen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateRuneMax(req.Summary, maxSummaryLen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.taskClassPort == nil {
|
||||||
|
return nil, ErrTaskClassPortMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
existing, err := s.forumDAO.FindPostByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return s.postBriefByID(ctx, req.ActorUserID, existing.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := normalizeTags(req.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tagsJSON, err := tagsToJSON(tags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshot, err := s.taskClassPort.GetOwnedTaskClassSnapshot(ctx, req.ActorUserID, req.TaskClassID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil, respond.UserTaskClassNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
post, template, items, err := buildPostSnapshotModels(req, idempotencyKey, tagsJSON, *snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.forumDAO.CreatePostSnapshot(ctx, &post, &template, items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.postBriefByID(ctx, req.ActorUserID, post.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPost 查询帖子详情和模板快照。
|
||||||
|
func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if postID == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
post, template, items, err := s.loadPostTemplate(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := viewerState(postID, liked, imported)
|
||||||
|
return &forumcontracts.ForumPostDetail{
|
||||||
|
Post: postBriefFromModel(*post, template, len(items), state),
|
||||||
|
Template: templateDetailFromModel(*template, items),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) postBriefByID(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostBrief, error) {
|
||||||
|
post, template, items, err := s.loadPostTemplate(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
brief := postBriefFromModel(*post, template, len(items), viewerState(postID, liked, imported))
|
||||||
|
return &brief, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadPostTemplate(ctx context.Context, postID uint64) (*forummodel.ForumPost, *forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) {
|
||||||
|
post, err := s.forumDAO.FindPublishedPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
template, err := s.forumDAO.FindTemplateByPostID(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
|
||||||
|
}
|
||||||
|
items, err := s.forumDAO.ListTemplateItemsByPostID(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return post, template, items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) viewerStateSets(ctx context.Context, actorUserID uint64, postIDs []uint64) (map[uint64]bool, map[uint64]bool, error) {
|
||||||
|
if actorUserID == 0 || len(postIDs) == 0 {
|
||||||
|
return map[uint64]bool{}, map[uint64]bool{}, nil
|
||||||
|
}
|
||||||
|
liked, err := s.forumDAO.LikedPostIDSet(ctx, actorUserID, postIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
imported, err := s.forumDAO.ImportedPostIDSet(ctx, actorUserID, postIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return liked, imported, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPostIDs(posts []forummodel.ForumPost) []uint64 {
|
||||||
|
result := make([]uint64, 0, len(posts))
|
||||||
|
for _, post := range posts {
|
||||||
|
result = append(result, post.ID)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPostSnapshotModels(req forumcontracts.CreateForumPostRequest, idempotencyKey string, tagsJSON string, snapshot TaskClassSnapshot) (forummodel.ForumPost, forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) {
|
||||||
|
configJSON, err := configSnapshotJSON(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||||
|
}
|
||||||
|
excludedSlotsJSON, err := intSliceToJSONPtr(snapshot.ExcludedSlots)
|
||||||
|
if err != nil {
|
||||||
|
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||||
|
}
|
||||||
|
excludedDaysJSON, err := intSliceToJSONPtr(snapshot.ExcludedDaysOfWeek)
|
||||||
|
if err != nil {
|
||||||
|
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||||
|
}
|
||||||
|
labelsJSON, err := stringSliceToJSONPtr(snapshot.StrategyLabels)
|
||||||
|
if err != nil {
|
||||||
|
return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
post := forummodel.ForumPost{
|
||||||
|
AuthorUserID: req.ActorUserID,
|
||||||
|
SourceTaskClassID: req.TaskClassID,
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
Summary: strings.TrimSpace(req.Summary),
|
||||||
|
TagsJSON: tagsJSON,
|
||||||
|
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||||
|
Status: forummodel.ForumPostStatusPublished,
|
||||||
|
}
|
||||||
|
template := forummodel.ForumPostTemplate{
|
||||||
|
SourceTaskClassID: snapshot.TaskClassID,
|
||||||
|
Mode: snapshot.Mode,
|
||||||
|
StartDate: parseSnapshotDate(snapshot.StartDate),
|
||||||
|
EndDate: parseSnapshotDate(snapshot.EndDate),
|
||||||
|
SubjectType: snapshot.SubjectType,
|
||||||
|
DifficultyLevel: snapshot.DifficultyLevel,
|
||||||
|
CognitiveIntensity: snapshot.CognitiveIntensity,
|
||||||
|
TotalSlots: snapshot.TotalSlots,
|
||||||
|
AllowFillerCourse: snapshot.AllowFillerCourse,
|
||||||
|
Strategy: snapshot.Strategy,
|
||||||
|
ExcludedSlotsJSON: excludedSlotsJSON,
|
||||||
|
ExcludedDaysOfWeekJSON: excludedDaysJSON,
|
||||||
|
StrategyLabelsJSON: labelsJSON,
|
||||||
|
ConfigSnapshotJSON: &configJSON,
|
||||||
|
}
|
||||||
|
snapshotItems := append([]TaskClassSnapshotItem(nil), snapshot.Items...)
|
||||||
|
sort.SliceStable(snapshotItems, func(i, j int) bool {
|
||||||
|
if snapshotItems[i].Order != snapshotItems[j].Order {
|
||||||
|
return snapshotItems[i].Order < snapshotItems[j].Order
|
||||||
|
}
|
||||||
|
return snapshotItems[i].TaskItemID < snapshotItems[j].TaskItemID
|
||||||
|
})
|
||||||
|
items := make([]forummodel.ForumPostTemplateItem, 0, len(snapshotItems))
|
||||||
|
for _, item := range snapshotItems {
|
||||||
|
if strings.TrimSpace(item.Content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, forummodel.ForumPostTemplateItem{
|
||||||
|
SourceTaskItemID: item.TaskItemID,
|
||||||
|
Order: len(items) + 1,
|
||||||
|
Content: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return post, template, items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configSnapshotJSON(snapshot TaskClassSnapshot) (string, error) {
|
||||||
|
if strings.TrimSpace(snapshot.ConfigSnapshotJSON) != "" {
|
||||||
|
return snapshot.ConfigSnapshotJSON, nil
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"mode": snapshot.Mode,
|
||||||
|
"start_date": snapshot.StartDate,
|
||||||
|
"end_date": snapshot.EndDate,
|
||||||
|
"subject_type": snapshot.SubjectType,
|
||||||
|
"difficulty_level": snapshot.DifficultyLevel,
|
||||||
|
"cognitive_intensity": snapshot.CognitiveIntensity,
|
||||||
|
"total_slots": snapshot.TotalSlots,
|
||||||
|
"allow_filler_course": snapshot.AllowFillerCourse,
|
||||||
|
"strategy": snapshot.Strategy,
|
||||||
|
"excluded_slots": snapshot.ExcludedSlots,
|
||||||
|
"excluded_days_of_week": snapshot.ExcludedDaysOfWeek,
|
||||||
|
"strategy_labels": snapshot.StrategyLabels,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordNotFound(err error, fallback error) error {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
214
backend/services/taskclassforum/sv/service.go
Normal file
214
backend/services/taskclassforum/sv/service.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const forumRewardPublishTimeout = 800 * time.Millisecond
|
||||||
|
|
||||||
|
type transactionalEventPublisher interface {
|
||||||
|
PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentTreeCachePort 是计划广场评论树缓存端口。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只暴露“读分页树、写分页树、递增版本”三个能力,避免 service 依赖 Redis 细节;
|
||||||
|
// 2. 缓存内容必须是去个性化读模型,不能带入当前用户的 can_delete;
|
||||||
|
// 3. Redis 异常不应影响主链路,service 层会降级回源 DB。
|
||||||
|
type CommentTreeCachePort interface {
|
||||||
|
GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error)
|
||||||
|
SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error
|
||||||
|
BumpCommentTreeVersion(ctx context.Context, postID uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 先由 legacy adapter 适配旧 TaskClass DAO / Service;
|
||||||
|
// 2. 业务层只依赖快照语义,不关心底层来自旧表、旧服务还是后续 RPC;
|
||||||
|
// 3. 不负责写 schedule,一键导入只创建当前用户自己的 TaskClass 副本。
|
||||||
|
type TaskClassSnapshotPort interface {
|
||||||
|
GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*TaskClassSnapshot, error)
|
||||||
|
CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot TaskClassSnapshot, targetTitle string) (*CreatedTaskClass, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskClassSnapshot 是可分享的 TaskClass 白名单快照。
|
||||||
|
//
|
||||||
|
// 注意:这里刻意不包含 embedded_time、schedule 绑定和用户私有排程状态。
|
||||||
|
type TaskClassSnapshot struct {
|
||||||
|
TaskClassID uint64
|
||||||
|
Title string
|
||||||
|
Mode string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
SubjectType string
|
||||||
|
DifficultyLevel string
|
||||||
|
CognitiveIntensity string
|
||||||
|
TotalSlots int
|
||||||
|
AllowFillerCourse bool
|
||||||
|
Strategy string
|
||||||
|
ExcludedSlots []int
|
||||||
|
ExcludedDaysOfWeek []int
|
||||||
|
StrategyLabels []string
|
||||||
|
Items []TaskClassSnapshotItem
|
||||||
|
ConfigSnapshotJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskClassSnapshotItem 是 TaskClassItem 的可分享条目快照。
|
||||||
|
type TaskClassSnapshotItem struct {
|
||||||
|
TaskItemID uint64
|
||||||
|
Order int
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatedTaskClass 是导入后创建出的当前用户 TaskClass。
|
||||||
|
type CreatedTaskClass struct {
|
||||||
|
TaskClassID uint64
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options 是计划广场服务的依赖注入参数。
|
||||||
|
type Options struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
TaskClassPort TaskClassSnapshotPort
|
||||||
|
EventPublisher outboxinfra.EventPublisher
|
||||||
|
CommentTreeCache CommentTreeCachePort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 承载计划广场服务内部业务编排。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责帖子、模板快照、点赞、评论、导入记录的事务编排;
|
||||||
|
// 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response;
|
||||||
|
// 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
forumDAO *forumdao.ForumDAO
|
||||||
|
taskClassPort TaskClassSnapshotPort
|
||||||
|
eventPublisher outboxinfra.EventPublisher
|
||||||
|
commentTreeCache CommentTreeCachePort
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(opts Options) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: opts.DB,
|
||||||
|
forumDAO: forumdao.NewForumDAO(opts.DB),
|
||||||
|
taskClassPort: opts.TaskClassPort,
|
||||||
|
eventPublisher: opts.EventPublisher,
|
||||||
|
commentTreeCache: opts.CommentTreeCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready 用于第二步骨架阶段的依赖检查。
|
||||||
|
//
|
||||||
|
// 后续实现真实用例时,具体方法会做更细的参数校验;这里只先帮助 cmd / 测试快速发现依赖未注入。
|
||||||
|
func (s *Service) Ready() error {
|
||||||
|
if s == nil {
|
||||||
|
return errors.New("taskclassforum service is nil")
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return errors.New("taskclassforum db is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishForumRewardEventBestEffort 在主事务成功后补发论坛奖励 outbox 事件。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 这里只处理“事务已经成功提交后的补发”,不再回头影响点赞/导入接口的成功结果;
|
||||||
|
// 2. 改用独立短超时 context,避免客户端断开直接打断补发,也避免 outbox 写入长时间拖慢接口尾部;
|
||||||
|
// 3. 发布失败时只记日志不返回 error,这是 P0 的明确取舍:先保住主链路,再靠日志和稳定 event_id 排障/补偿。
|
||||||
|
func (s *Service) publishForumRewardEventBestEffort(payload sharedevents.ForumPostRewardPayload) {
|
||||||
|
if s == nil || s.eventPublisher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := payload.Validate(); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"forum reward outbox payload 非法,跳过发布: event_id=%s post_id=%d import_id=%d source=%s err=%v",
|
||||||
|
payload.EventID,
|
||||||
|
payload.PostID,
|
||||||
|
payload.ImportID,
|
||||||
|
payload.Source,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := strings.TrimSpace(payload.EventType())
|
||||||
|
if eventType == "" {
|
||||||
|
log.Printf(
|
||||||
|
"forum reward outbox 事件类型为空,跳过发布: event_id=%s post_id=%d import_id=%d source=%s",
|
||||||
|
payload.EventID,
|
||||||
|
payload.PostID,
|
||||||
|
payload.ImportID,
|
||||||
|
payload.Source,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publishCtx, cancel := context.WithTimeout(context.Background(), forumRewardPublishTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := s.eventPublisher.Publish(publishCtx, outboxinfra.PublishRequest{
|
||||||
|
EventType: eventType,
|
||||||
|
EventVersion: sharedevents.ForumRewardEventVersion,
|
||||||
|
MessageKey: payload.MessageKey(),
|
||||||
|
AggregateID: payload.AggregateID(),
|
||||||
|
EventID: payload.EventID,
|
||||||
|
Payload: payload,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"forum reward outbox 发布失败,按 P0 约定忽略主链路错误: event_type=%s event_id=%s post_id=%d import_id=%d actor_user_id=%d err=%v",
|
||||||
|
eventType,
|
||||||
|
payload.EventID,
|
||||||
|
payload.PostID,
|
||||||
|
payload.ImportID,
|
||||||
|
payload.ActorUserID,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishForumRewardEventInTx 尝试把论坛奖励事件写进当前业务事务。
|
||||||
|
//
|
||||||
|
// 返回值说明:
|
||||||
|
// 1. handled=true 表示发布器支持事务写入,调用方不需要再做事务后 best-effort 补发;
|
||||||
|
// 2. handled=false 表示当前发布器不支持事务写入,调用方可退回旧的事务后补发路径;
|
||||||
|
// 3. error 非空表示 outbox 入队失败,业务事务应一起回滚,避免成功互动永久漏奖。
|
||||||
|
func (s *Service) publishForumRewardEventInTx(ctx context.Context, tx *gorm.DB, payload sharedevents.ForumPostRewardPayload) (bool, error) {
|
||||||
|
if s == nil || s.eventPublisher == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
publisher, ok := s.eventPublisher.(transactionalEventPublisher)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := payload.Validate(); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := strings.TrimSpace(payload.EventType())
|
||||||
|
if eventType == "" {
|
||||||
|
return true, errors.New("论坛奖励事件类型为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, publisher.PublishWithTx(ctx, tx, outboxinfra.PublishRequest{
|
||||||
|
EventType: eventType,
|
||||||
|
EventVersion: sharedevents.ForumRewardEventVersion,
|
||||||
|
MessageKey: payload.MessageKey(),
|
||||||
|
AggregateID: payload.AggregateID(),
|
||||||
|
EventID: payload.EventID,
|
||||||
|
Payload: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
184
backend/services/tokenstore/dao/connect.go
Normal file
184
backend/services/tokenstore/dao/connect.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users,避免和 user/auth 服务边界冲突;
|
||||||
|
// 2. 自动迁移后执行 P0 seed,确保前端商品页有可展示商品;
|
||||||
|
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
|
||||||
|
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||||
|
host := viper.GetString("database.host")
|
||||||
|
port := viper.GetString("database.port")
|
||||||
|
user := viper.GetString("database.user")
|
||||||
|
password := viper.GetString("database.password")
|
||||||
|
dbname := viper.GetString("database.dbname")
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
user, password, host, port, dbname,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = AutoMigrate(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = SeedDefaults(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate 只迁移 token-store 服务拥有的表。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先创建商品、订单、获取账本和奖励规则表;
|
||||||
|
// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录;
|
||||||
|
// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入;
|
||||||
|
// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
|
||||||
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("tokenstore auto migrate failed: db is nil")
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&tokenmodel.TokenProduct{},
|
||||||
|
&tokenmodel.TokenOrder{},
|
||||||
|
&tokenmodel.TokenGrant{},
|
||||||
|
&tokenmodel.TokenRewardRule{},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("auto migrate tokenstore tables failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedDefaults 写入 P0 默认商品和奖励规则。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 商品和奖励规则都用稳定业务键做 upsert,允许重复启动服务;
|
||||||
|
// 2. seed 只提供 P0 默认数据,不代表有管理后台能力;
|
||||||
|
// 3. 后续若商品或规则由运营后台维护,可替换本函数或仅保留初始化兜底。
|
||||||
|
func SeedDefaults(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("tokenstore seed failed: db is nil")
|
||||||
|
}
|
||||||
|
if err := seedDefaultProducts(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedDefaultRewardRules(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDefaultProducts(db *gorm.DB) error {
|
||||||
|
products := defaultTokenProducts()
|
||||||
|
for _, product := range products {
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "sku"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"token_amount",
|
||||||
|
"price_cent",
|
||||||
|
"currency",
|
||||||
|
"badge",
|
||||||
|
"status",
|
||||||
|
"sort_order",
|
||||||
|
"updated_at",
|
||||||
|
}),
|
||||||
|
}).Create(&product).Error; err != nil {
|
||||||
|
return fmt.Errorf("seed token product %s failed: %w", product.SKU, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTokenProducts() []tokenmodel.TokenProduct {
|
||||||
|
return []tokenmodel.TokenProduct{
|
||||||
|
{
|
||||||
|
SKU: "token_basic_100",
|
||||||
|
Name: "基础 Token 包",
|
||||||
|
Description: "适合轻量使用 Agent。",
|
||||||
|
TokenAmount: 100,
|
||||||
|
PriceCent: 990,
|
||||||
|
Currency: "CNY",
|
||||||
|
Badge: "入门",
|
||||||
|
Status: tokenmodel.TokenProductStatusActive,
|
||||||
|
SortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SKU: "token_plus_300",
|
||||||
|
Name: "进阶 Token 包",
|
||||||
|
Description: "适合高频规划和复盘。",
|
||||||
|
TokenAmount: 300,
|
||||||
|
PriceCent: 1990,
|
||||||
|
Currency: "CNY",
|
||||||
|
Badge: "推荐",
|
||||||
|
Status: tokenmodel.TokenProductStatusActive,
|
||||||
|
SortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SKU: "token_pro_800",
|
||||||
|
Name: "专业 Token 包",
|
||||||
|
Description: "适合长周期学习计划和高频 Agent 使用。",
|
||||||
|
TokenAmount: 800,
|
||||||
|
PriceCent: 3990,
|
||||||
|
Currency: "CNY",
|
||||||
|
Badge: "高频",
|
||||||
|
Status: tokenmodel.TokenProductStatusActive,
|
||||||
|
SortOrder: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDefaultRewardRules(db *gorm.DB) error {
|
||||||
|
rules := defaultTokenRewardRules()
|
||||||
|
for _, rule := range rules {
|
||||||
|
if err := db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "source"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"name",
|
||||||
|
"amount",
|
||||||
|
"status",
|
||||||
|
"config_json",
|
||||||
|
"updated_at",
|
||||||
|
}),
|
||||||
|
}).Create(&rule).Error; err != nil {
|
||||||
|
return fmt.Errorf("seed token reward rule %s failed: %w", rule.Source, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTokenRewardRules() []tokenmodel.TokenRewardRule {
|
||||||
|
return []tokenmodel.TokenRewardRule{
|
||||||
|
{
|
||||||
|
Source: tokenmodel.TokenGrantSourceForumLike,
|
||||||
|
Name: "计划被点赞奖励",
|
||||||
|
Amount: 1,
|
||||||
|
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Source: tokenmodel.TokenGrantSourceForumImport,
|
||||||
|
Name: "计划被导入奖励",
|
||||||
|
Amount: 5,
|
||||||
|
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
285
backend/services/tokenstore/dao/tokenstore.go
Normal file
285
backend/services/tokenstore/dao/tokenstore.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRewardRuleBySource 按来源读取社区奖励规则。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只读取 token_reward_rules,不计算最终发放金额,也不判断停用语义;
|
||||||
|
// 2. 未找到规则时返回 nil,由服务层决定配置或默认值兜底;
|
||||||
|
// 3. source 在 DAO 层做一次规范化,避免大小写和空格造成规则漏命中。
|
||||||
|
func (dao *TokenStoreDAO) FindRewardRuleBySource(ctx context.Context, source string) (*tokenmodel.TokenRewardRule, error) {
|
||||||
|
source = strings.ToLower(strings.TrimSpace(source))
|
||||||
|
if source == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule tokenmodel.TokenRewardRule
|
||||||
|
err := dao.db.WithContext(ctx).
|
||||||
|
Where("source = ?", source).
|
||||||
|
First(&rule).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
155
backend/services/tokenstore/model/token.go
Normal file
155
backend/services/tokenstore/model/token.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenProductStatusActive 表示商品可在 Token 商店展示和购买。
|
||||||
|
TokenProductStatusActive = "active"
|
||||||
|
// TokenProductStatusInactive 表示商品已下架,不再对前端展示。
|
||||||
|
TokenProductStatusInactive = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenOrderStatusPending 表示订单已创建,等待支付确认。
|
||||||
|
TokenOrderStatusPending = "pending"
|
||||||
|
// TokenOrderStatusPaid 表示订单已确认支付,等待写入获取账本。
|
||||||
|
TokenOrderStatusPaid = "paid"
|
||||||
|
// TokenOrderStatusGranted 表示订单已经写入 token_grants 获取账本。
|
||||||
|
TokenOrderStatusGranted = "granted"
|
||||||
|
// TokenOrderStatusClosed 表示订单关闭,P0 暂不实现复杂关闭流程。
|
||||||
|
TokenOrderStatusClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenGrantStatusRecorded 表示 Token 获取事实已记录在 token-store 内。
|
||||||
|
TokenGrantStatusRecorded = "recorded"
|
||||||
|
// TokenGrantStatusApplied 表示后续已同步到 user/auth 权威额度。
|
||||||
|
TokenGrantStatusApplied = "applied"
|
||||||
|
// TokenGrantStatusSkipped 表示命中奖励规则或幂等条件后跳过发放。
|
||||||
|
TokenGrantStatusSkipped = "skipped"
|
||||||
|
// TokenGrantStatusFailed 表示记录或后续同步失败,可按 event_id 重试。
|
||||||
|
TokenGrantStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenGrantSourcePurchase 表示购买 Token 商品产生的获取记录。
|
||||||
|
TokenGrantSourcePurchase = "purchase"
|
||||||
|
// TokenGrantSourceForumLike 表示计划被点赞产生的作者奖励。
|
||||||
|
TokenGrantSourceForumLike = "forum_like"
|
||||||
|
// TokenGrantSourceForumImport 表示计划被导入产生的作者奖励。
|
||||||
|
TokenGrantSourceForumImport = "forum_import"
|
||||||
|
// TokenGrantSourceManual 预留人工补偿来源,P0 不做管理后台。
|
||||||
|
TokenGrantSourceManual = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenRewardRuleStatusActive 表示奖励规则启用。
|
||||||
|
TokenRewardRuleStatusActive = "active"
|
||||||
|
// TokenRewardRuleStatusInactive 表示奖励规则停用。
|
||||||
|
TokenRewardRuleStatusInactive = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenProduct 是 Token 商店商品表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 从表读取商品,由 seed 初始化 2-3 个固定商品;
|
||||||
|
// 2. 不承载真实支付渠道配置,也不做商品管理后台;
|
||||||
|
// 3. 下单时会复制商品快照到订单,避免后续改价影响历史订单。
|
||||||
|
type TokenProduct struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_token_products_sku;comment:商品稳定编码"`
|
||||||
|
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
|
||||||
|
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
|
||||||
|
TokenAmount int64 `gorm:"column:token_amount;not null;comment:商品包含Token数量"`
|
||||||
|
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
|
||||||
|
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||||
|
Badge string `gorm:"column:badge;type:varchar(32);comment:前端角标"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_products_status_sort,priority:1;comment:active/inactive"`
|
||||||
|
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_token_products_status_sort,priority:2;comment:展示排序"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenProduct) TableName() string {
|
||||||
|
return "token_products"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenOrder 是 Token 商品订单表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 记录用户购买商品的订单状态机;
|
||||||
|
// 2. P0 只支持 mock paid,不接真实支付网关;
|
||||||
|
// 3. granted 只表示已写入 token-store 获取账本,不代表已同步到 user/auth 权威额度。
|
||||||
|
type TokenOrder struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_token_orders_order_no;comment:订单号"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_token_orders_user_idem,priority:1;index:idx_token_orders_user_status_created,priority:1;comment:下单用户ID"`
|
||||||
|
ProductID uint64 `gorm:"column:product_id;not null;index:idx_token_orders_product;comment:商品ID"`
|
||||||
|
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
|
||||||
|
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
|
||||||
|
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
|
||||||
|
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
|
||||||
|
TokenAmount int64 `gorm:"column:token_amount;not null;comment:订单总Token数量"`
|
||||||
|
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
|
||||||
|
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_token_orders_user_status_created,priority:2;comment:pending/paid/granted/closed"`
|
||||||
|
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式,P0为mock"`
|
||||||
|
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_token_orders_user_idem,priority:2;comment:创建订单幂等键"`
|
||||||
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
|
||||||
|
GrantedAt *time.Time `gorm:"column:granted_at;comment:写入获取账本时间"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_orders_user_status_created,priority:3;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenOrder) TableName() string {
|
||||||
|
return "token_orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrant 是 Token 获取账本表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 记录购买、论坛点赞奖励、论坛导入奖励等 Token 获取事实;
|
||||||
|
// 2. event_id 是最终幂等边界,避免订单或 outbox 重试重复发放;
|
||||||
|
// 3. P0 不直接修改 users 表,quota_applied 默认为 false。
|
||||||
|
type TokenGrant struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_token_grants_event;comment:幂等事件ID"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;index:idx_token_grants_user_source_created,priority:1;comment:获得Token的用户ID"`
|
||||||
|
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_token_grants_user_source_created,priority:2;comment:purchase/forum_like/forum_import/manual"`
|
||||||
|
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:前端展示来源"`
|
||||||
|
SourceRefID *uint64 `gorm:"column:source_ref_id;index:idx_token_grants_source_ref;comment:来源业务ID,如order_id/post_id/import_id"`
|
||||||
|
OrderID *uint64 `gorm:"column:order_id;index:idx_token_grants_order;comment:购买订单ID,非购买来源为空"`
|
||||||
|
Amount int64 `gorm:"column:amount;not null;comment:获取Token数量"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'recorded';index:idx_token_grants_status;comment:recorded/applied/skipped/failed"`
|
||||||
|
QuotaApplied bool `gorm:"column:quota_applied;not null;default:false;comment:是否已同步到user/auth权威额度"`
|
||||||
|
Description string `gorm:"column:description;type:varchar(255);comment:前端展示描述"`
|
||||||
|
AppliedAt *time.Time `gorm:"column:applied_at;comment:同步到权威额度时间"`
|
||||||
|
LastError *string `gorm:"column:last_error;type:text;comment:后续同步失败原因"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_grants_user_source_created,priority:3;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenGrant) TableName() string {
|
||||||
|
return "token_grants"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenRewardRule 是社区奖励规则表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 可用 seed 初始化点赞、导入奖励额度;
|
||||||
|
// 2. 不提供管理后台,规则调整先通过配置或 seed 变更;
|
||||||
|
// 3. 规则命中后的最终发放仍以 token_grants.event_id 幂等为准。
|
||||||
|
type TokenRewardRule struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_token_reward_rules_source;comment:forum_like/forum_import"`
|
||||||
|
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
|
||||||
|
Amount int64 `gorm:"column:amount;not null;comment:奖励Token数量"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_reward_rules_status;comment:active/inactive"`
|
||||||
|
ConfigJSON *string `gorm:"column:config_json;type:json;comment:预留扩展配置"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenRewardRule) TableName() string {
|
||||||
|
return "token_reward_rules"
|
||||||
|
}
|
||||||
72
backend/services/tokenstore/rpc/errors.go
Normal file
72
backend/services/tokenstore/rpc/errors.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenStoreErrorDomain = "smartflow.tokenstore"
|
||||||
|
|
||||||
|
// grpcErrorFromServiceError 负责把 token-store 内部错误收口成 gRPC status。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理服务内部错误到跨进程错误的转换;
|
||||||
|
// 2. 不决定 HTTP 状态码,也不直接写前端响应;
|
||||||
|
// 3. 未识别错误统一按 Internal 处理,避免泄露数据库或支付细节。
|
||||||
|
func grpcErrorFromServiceError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp respond.Response
|
||||||
|
if errors.As(err, &resp) {
|
||||||
|
return grpcErrorFromResponse(resp)
|
||||||
|
}
|
||||||
|
if errors.Is(err, tokenstoresv.ErrNotImplemented) {
|
||||||
|
return status.Error(codes.Unimplemented, err.Error())
|
||||||
|
}
|
||||||
|
log.Printf("tokenstore rpc internal error: %v", err)
|
||||||
|
return status.Error(codes.Internal, "tokenstore service internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func grpcErrorFromResponse(resp respond.Response) error {
|
||||||
|
code := grpcCodeFromRespondStatus(resp.Status)
|
||||||
|
message := strings.TrimSpace(resp.Info)
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
st := status.New(code, message)
|
||||||
|
detail := &errdetails.ErrorInfo{
|
||||||
|
Domain: tokenStoreErrorDomain,
|
||||||
|
Reason: resp.Status,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"info": resp.Info,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
withDetails, err := st.WithDetails(detail)
|
||||||
|
if err != nil {
|
||||||
|
return st.Err()
|
||||||
|
}
|
||||||
|
return withDetails.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||||
|
switch strings.TrimSpace(statusValue) {
|
||||||
|
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status, respond.ErrUnauthorized.Status:
|
||||||
|
return codes.Unauthenticated
|
||||||
|
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status, respond.WrongUserID.Status:
|
||||||
|
return codes.InvalidArgument
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||||
|
return codes.Internal
|
||||||
|
}
|
||||||
|
return codes.InvalidArgument
|
||||||
|
}
|
||||||
313
backend/services/tokenstore/rpc/handler.go
Normal file
313
backend/services/tokenstore/rpc/handler.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
pb.UnimplementedTokenStoreServiceServer
|
||||||
|
svc *tokenstoresv.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(svc *tokenstoresv.Service) *Handler {
|
||||||
|
return &Handler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// service 负责统一校验 RPC 层依赖是否已经注入。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只判断 handler 自身和业务 service 是否可用;
|
||||||
|
// 2. 不负责支付状态流转、订单幂等和 grant 账本写入;
|
||||||
|
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
|
||||||
|
func (h *Handler) service() (*tokenstoresv.Service, error) {
|
||||||
|
if h == nil || h.svc == nil {
|
||||||
|
return nil, errors.New("tokenstore service dependency not initialized")
|
||||||
|
}
|
||||||
|
return h.svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSummary 负责把 Token 概览请求从 gRPC 协议转成内部服务调用。
|
||||||
|
func (h *Handler) GetSummary(ctx context.Context, req *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := svc.GetSummary(ctx, req.ActorUserId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.GetTokenSummaryResponse{Summary: tokenSummaryToPB(summary)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListProducts(ctx context.Context, req *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := svc.ListProducts(ctx, req.ActorUserId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListTokenProductsResponse{Items: tokenProductsToPB(items)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateOrder(ctx context.Context, req *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := svc.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
ProductID: req.ProductId,
|
||||||
|
Quantity: int(req.Quantity),
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.CreateTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListOrders(ctx context.Context, req *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
Page: int(req.Page),
|
||||||
|
PageSize: int(req.PageSize),
|
||||||
|
Status: req.Status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListTokenOrdersResponse{
|
||||||
|
Items: tokenOrdersToPB(items),
|
||||||
|
Page: tokenPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetOrder(ctx context.Context, req *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := svc.GetOrder(ctx, req.ActorUserId, req.OrderId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.GetTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MockPaidOrder(ctx context.Context, req *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := svc.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
OrderID: req.OrderId,
|
||||||
|
MockChannel: req.MockChannel,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.MockPaidOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
Page: int(req.Page),
|
||||||
|
PageSize: int(req.PageSize),
|
||||||
|
Source: req.Source,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListTokenGrantsResponse{
|
||||||
|
Items: tokenGrantsToPB(items),
|
||||||
|
Page: tokenPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。
|
||||||
|
func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
|
||||||
|
EventID: req.EventId,
|
||||||
|
ReceiverUserID: req.ReceiverUserId,
|
||||||
|
Source: req.Source,
|
||||||
|
SourceRefID: req.SourceRefId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse {
|
||||||
|
return &pb.PageResponse{
|
||||||
|
Page: int32(page.Page),
|
||||||
|
PageSize: int32(page.PageSize),
|
||||||
|
Total: int32(page.Total),
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenSummaryToPB(summary *tokencontracts.TokenSummary) *pb.TokenSummary {
|
||||||
|
if summary == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.TokenSummary{
|
||||||
|
RecordedTokenTotal: summary.RecordedTokenTotal,
|
||||||
|
AppliedTokenTotal: summary.AppliedTokenTotal,
|
||||||
|
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
|
||||||
|
QuotaSyncStatus: summary.QuotaSyncStatus,
|
||||||
|
Tip: summary.Tip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenProductToPB(product tokencontracts.TokenProductView) *pb.TokenProductView {
|
||||||
|
return &pb.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: int32(product.SortOrder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenProductsToPB(items []tokencontracts.TokenProductView) []*pb.TokenProductView {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.TokenProductView, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
result = append(result, tokenProductToPB(items[i]))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenGrantToPB(grant *tokencontracts.TokenGrantView) *pb.TokenGrantView {
|
||||||
|
if grant == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.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 tokenGrantsToPB(items []tokencontracts.TokenGrantView) []*pb.TokenGrantView {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.TokenGrantView, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, tokenGrantToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
|
||||||
|
if order == nil {
|
||||||
|
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),
|
||||||
|
ProductSnapshot: order.ProductSnapshot,
|
||||||
|
ProductName: order.ProductName,
|
||||||
|
Quantity: int32(order.Quantity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenOrdersToPB(items []tokencontracts.TokenOrderView) []*pb.TokenOrderView {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.TokenOrderView, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, tokenOrderToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenStringFromPtr(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
231
backend/services/tokenstore/rpc/pb/tokenstore.pb.go
Normal file
231
backend/services/tokenstore/rpc/pb/tokenstore.pb.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import proto "github.com/golang/protobuf/proto"
|
||||||
|
|
||||||
|
var _ = proto.Marshal
|
||||||
|
|
||||||
|
const _ = proto.ProtoPackageIsVersion3
|
||||||
|
|
||||||
|
type PageResponse struct {
|
||||||
|
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"`
|
||||||
|
HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PageResponse) Reset() { *m = PageResponse{} }
|
||||||
|
func (m *PageResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*PageResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TokenSummary struct {
|
||||||
|
RecordedTokenTotal int64 `protobuf:"varint,1,opt,name=recorded_token_total,json=recordedTokenTotal,proto3" json:"recorded_token_total,omitempty"`
|
||||||
|
AppliedTokenTotal int64 `protobuf:"varint,2,opt,name=applied_token_total,json=appliedTokenTotal,proto3" json:"applied_token_total,omitempty"`
|
||||||
|
PendingApplyTokenTotal int64 `protobuf:"varint,3,opt,name=pending_apply_token_total,json=pendingApplyTokenTotal,proto3" json:"pending_apply_token_total,omitempty"`
|
||||||
|
QuotaSyncStatus string `protobuf:"bytes,4,opt,name=quota_sync_status,json=quotaSyncStatus,proto3" json:"quota_sync_status,omitempty"`
|
||||||
|
Tip string `protobuf:"bytes,5,opt,name=tip,proto3" json:"tip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenSummary) Reset() { *m = TokenSummary{} }
|
||||||
|
func (m *TokenSummary) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TokenSummary) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TokenProductView struct {
|
||||||
|
ProductId uint64 `protobuf:"varint,1,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
|
||||||
|
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_amount,omitempty"`
|
||||||
|
PriceCent int64 `protobuf:"varint,5,opt,name=price_cent,json=priceCent,proto3" json:"price_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"`
|
||||||
|
Badge string `protobuf:"bytes,8,opt,name=badge,proto3" json:"badge,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
SortOrder int32 `protobuf:"varint,10,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenProductView) Reset() { *m = TokenProductView{} }
|
||||||
|
func (m *TokenProductView) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TokenProductView) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TokenGrantView struct {
|
||||||
|
GrantId uint64 `protobuf:"varint,1,opt,name=grant_id,json=grantId,proto3" json:"grant_id,omitempty"`
|
||||||
|
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||||
|
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
|
||||||
|
SourceLabel string `protobuf:"bytes,4,opt,name=source_label,json=sourceLabel,proto3" json:"source_label,omitempty"`
|
||||||
|
Amount int64 `protobuf:"varint,5,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
QuotaApplied bool `protobuf:"varint,7,opt,name=quota_applied,json=quotaApplied,proto3" json:"quota_applied,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,8,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenGrantView) Reset() { *m = TokenGrantView{} }
|
||||||
|
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"`
|
||||||
|
ProductSnapshot string `protobuf:"bytes,13,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"`
|
||||||
|
ProductName string `protobuf:"bytes,14,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"`
|
||||||
|
Quantity int32 `protobuf:"varint,15,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenOrderView) Reset() { *m = TokenOrderView{} }
|
||||||
|
func (m *TokenOrderView) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TokenOrderView) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetTokenSummaryRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetTokenSummaryRequest) Reset() { *m = GetTokenSummaryRequest{} }
|
||||||
|
func (m *GetTokenSummaryRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetTokenSummaryRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetTokenSummaryResponse struct {
|
||||||
|
Summary *TokenSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetTokenSummaryResponse) Reset() { *m = GetTokenSummaryResponse{} }
|
||||||
|
func (m *GetTokenSummaryResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetTokenSummaryResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenProductsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenProductsRequest) Reset() { *m = ListTokenProductsRequest{} }
|
||||||
|
func (m *ListTokenProductsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenProductsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenProductsResponse struct {
|
||||||
|
Items []*TokenProductView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenProductsResponse) Reset() { *m = ListTokenProductsResponse{} }
|
||||||
|
func (m *ListTokenProductsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenProductsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateTokenOrderRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
ProductId uint64 `protobuf:"varint,2,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
|
||||||
|
Quantity int32 `protobuf:"varint,3,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateTokenOrderRequest) Reset() { *m = CreateTokenOrderRequest{} }
|
||||||
|
func (m *CreateTokenOrderRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateTokenOrderRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateTokenOrderResponse struct {
|
||||||
|
Order *TokenOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateTokenOrderResponse) Reset() { *m = CreateTokenOrderResponse{} }
|
||||||
|
func (m *CreateTokenOrderResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateTokenOrderResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenOrdersRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenOrdersRequest) Reset() { *m = ListTokenOrdersRequest{} }
|
||||||
|
func (m *ListTokenOrdersRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenOrdersRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenOrdersResponse struct {
|
||||||
|
Items []*TokenOrderView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenOrdersResponse) Reset() { *m = ListTokenOrdersResponse{} }
|
||||||
|
func (m *ListTokenOrdersResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenOrdersResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetTokenOrderRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetTokenOrderRequest) Reset() { *m = GetTokenOrderRequest{} }
|
||||||
|
func (m *GetTokenOrderRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetTokenOrderRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetTokenOrderResponse struct {
|
||||||
|
Order *TokenOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetTokenOrderResponse) Reset() { *m = GetTokenOrderResponse{} }
|
||||||
|
func (m *GetTokenOrderResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetTokenOrderResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type MockPaidOrderRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||||
|
MockChannel string `protobuf:"bytes,3,opt,name=mock_channel,json=mockChannel,proto3" json:"mock_channel,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPaidOrderRequest) Reset() { *m = MockPaidOrderRequest{} }
|
||||||
|
func (m *MockPaidOrderRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*MockPaidOrderRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type MockPaidOrderResponse struct {
|
||||||
|
Order *TokenOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPaidOrderResponse) Reset() { *m = MockPaidOrderResponse{} }
|
||||||
|
func (m *MockPaidOrderResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*MockPaidOrderResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenGrantsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenGrantsRequest) Reset() { *m = ListTokenGrantsRequest{} }
|
||||||
|
func (m *ListTokenGrantsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenGrantsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListTokenGrantsResponse struct {
|
||||||
|
Items []*TokenGrantView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListTokenGrantsResponse) Reset() { *m = ListTokenGrantsResponse{} }
|
||||||
|
func (m *ListTokenGrantsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListTokenGrantsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type RecordForumRewardGrantRequest struct {
|
||||||
|
EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||||
|
ReceiverUserId uint64 `protobuf:"varint,2,opt,name=receiver_user_id,json=receiverUserId,proto3" json:"receiver_user_id,omitempty"`
|
||||||
|
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
|
||||||
|
SourceRefId string `protobuf:"bytes,4,opt,name=source_ref_id,json=sourceRefId,proto3" json:"source_ref_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RecordForumRewardGrantRequest) Reset() { *m = RecordForumRewardGrantRequest{} }
|
||||||
|
func (m *RecordForumRewardGrantRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*RecordForumRewardGrantRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type RecordForumRewardGrantResponse struct {
|
||||||
|
Grant *TokenGrantView `protobuf:"bytes,1,opt,name=grant,proto3" json:"grant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} }
|
||||||
|
func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*RecordForumRewardGrantResponse) ProtoMessage() {}
|
||||||
185
backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go
Normal file
185
backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
|
||||||
|
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
|
||||||
|
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
|
||||||
|
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
|
||||||
|
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
|
||||||
|
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
|
||||||
|
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
|
||||||
|
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenStoreServiceClient interface {
|
||||||
|
GetSummary(ctx context.Context, in *GetTokenSummaryRequest, opts ...grpc.CallOption) (*GetTokenSummaryResponse, error)
|
||||||
|
ListProducts(ctx context.Context, in *ListTokenProductsRequest, opts ...grpc.CallOption) (*ListTokenProductsResponse, error)
|
||||||
|
CreateOrder(ctx context.Context, in *CreateTokenOrderRequest, opts ...grpc.CallOption) (*CreateTokenOrderResponse, error)
|
||||||
|
ListOrders(ctx context.Context, in *ListTokenOrdersRequest, opts ...grpc.CallOption) (*ListTokenOrdersResponse, error)
|
||||||
|
GetOrder(ctx context.Context, in *GetTokenOrderRequest, opts ...grpc.CallOption) (*GetTokenOrderResponse, error)
|
||||||
|
MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error)
|
||||||
|
ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error)
|
||||||
|
RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenStoreServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenStoreServiceClient(cc grpc.ClientConnInterface) TokenStoreServiceClient {
|
||||||
|
return &tokenStoreServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) GetSummary(ctx context.Context, in *GetTokenSummaryRequest, opts ...grpc.CallOption) (*GetTokenSummaryResponse, error) {
|
||||||
|
return invokeTokenStore[GetTokenSummaryResponse](ctx, c.cc, TokenStoreService_GetSummary_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) ListProducts(ctx context.Context, in *ListTokenProductsRequest, opts ...grpc.CallOption) (*ListTokenProductsResponse, error) {
|
||||||
|
return invokeTokenStore[ListTokenProductsResponse](ctx, c.cc, TokenStoreService_ListProducts_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) CreateOrder(ctx context.Context, in *CreateTokenOrderRequest, opts ...grpc.CallOption) (*CreateTokenOrderResponse, error) {
|
||||||
|
return invokeTokenStore[CreateTokenOrderResponse](ctx, c.cc, TokenStoreService_CreateOrder_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) ListOrders(ctx context.Context, in *ListTokenOrdersRequest, opts ...grpc.CallOption) (*ListTokenOrdersResponse, error) {
|
||||||
|
return invokeTokenStore[ListTokenOrdersResponse](ctx, c.cc, TokenStoreService_ListOrders_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) GetOrder(ctx context.Context, in *GetTokenOrderRequest, opts ...grpc.CallOption) (*GetTokenOrderResponse, error) {
|
||||||
|
return invokeTokenStore[GetTokenOrderResponse](ctx, c.cc, TokenStoreService_GetOrder_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error) {
|
||||||
|
return invokeTokenStore[MockPaidOrderResponse](ctx, c.cc, TokenStoreService_MockPaidOrder_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error) {
|
||||||
|
return invokeTokenStore[ListTokenGrantsResponse](ctx, c.cc, TokenStoreService_ListGrants_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error) {
|
||||||
|
return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
|
||||||
|
out := new(Resp)
|
||||||
|
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenStoreServiceServer interface {
|
||||||
|
GetSummary(context.Context, *GetTokenSummaryRequest) (*GetTokenSummaryResponse, error)
|
||||||
|
ListProducts(context.Context, *ListTokenProductsRequest) (*ListTokenProductsResponse, error)
|
||||||
|
CreateOrder(context.Context, *CreateTokenOrderRequest) (*CreateTokenOrderResponse, error)
|
||||||
|
ListOrders(context.Context, *ListTokenOrdersRequest) (*ListTokenOrdersResponse, error)
|
||||||
|
GetOrder(context.Context, *GetTokenOrderRequest) (*GetTokenOrderResponse, error)
|
||||||
|
MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error)
|
||||||
|
ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error)
|
||||||
|
RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnimplementedTokenStoreServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) GetSummary(context.Context, *GetTokenSummaryRequest) (*GetTokenSummaryResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetSummary not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) ListProducts(context.Context, *ListTokenProductsRequest) (*ListTokenProductsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListProducts not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) CreateOrder(context.Context, *CreateTokenOrderRequest) (*CreateTokenOrderResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateOrder not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) ListOrders(context.Context, *ListTokenOrdersRequest) (*ListTokenOrdersResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListOrders not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) GetOrder(context.Context, *GetTokenOrderRequest) (*GetTokenOrderResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetOrder not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method MockPaidOrder not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListGrants not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) {
|
||||||
|
s.RegisterService(&TokenStoreService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenStoreUnaryHandler[Req any](methodName string, fullMethod string, invoke func(TokenStoreServiceServer, context.Context, *Req) (interface{}, error)) grpc.MethodDesc {
|
||||||
|
return grpc.MethodDesc{
|
||||||
|
MethodName: methodName,
|
||||||
|
Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Req)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return invoke(srv.(TokenStoreServiceServer), ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: fullMethod,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return invoke(srv.(TokenStoreServiceServer), ctx, req.(*Req))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var TokenStoreService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "smartflow.tokenstore.TokenStoreService",
|
||||||
|
HandlerType: (*TokenStoreServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
tokenStoreUnaryHandler[GetTokenSummaryRequest]("GetSummary", TokenStoreService_GetSummary_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetTokenSummaryRequest) (interface{}, error) {
|
||||||
|
return s.GetSummary(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[ListTokenProductsRequest]("ListProducts", TokenStoreService_ListProducts_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListTokenProductsRequest) (interface{}, error) {
|
||||||
|
return s.ListProducts(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[CreateTokenOrderRequest]("CreateOrder", TokenStoreService_CreateOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *CreateTokenOrderRequest) (interface{}, error) {
|
||||||
|
return s.CreateOrder(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[ListTokenOrdersRequest]("ListOrders", TokenStoreService_ListOrders_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListTokenOrdersRequest) (interface{}, error) {
|
||||||
|
return s.ListOrders(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[GetTokenOrderRequest]("GetOrder", TokenStoreService_GetOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetTokenOrderRequest) (interface{}, error) {
|
||||||
|
return s.GetOrder(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[MockPaidOrderRequest]("MockPaidOrder", TokenStoreService_MockPaidOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *MockPaidOrderRequest) (interface{}, error) {
|
||||||
|
return s.MockPaidOrder(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[ListTokenGrantsRequest]("ListGrants", TokenStoreService_ListGrants_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListTokenGrantsRequest) (interface{}, error) {
|
||||||
|
return s.ListGrants(ctx, req)
|
||||||
|
}),
|
||||||
|
tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) {
|
||||||
|
return s.RecordForumRewardGrant(ctx, req)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "tokenstore.proto",
|
||||||
|
}
|
||||||
73
backend/services/tokenstore/rpc/server.go
Normal file
73
backend/services/tokenstore/rpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
"github.com/zeromicro/go-zero/core/service"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListenOn = "0.0.0.0:9095"
|
||||||
|
defaultTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerOptions struct {
|
||||||
|
ListenOn string
|
||||||
|
Timeout time.Duration
|
||||||
|
Service *tokenstoresv.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动 token-store zrpc 服务。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责装配 go-zero zrpc server 和注册 protobuf service;
|
||||||
|
// 2. 不创建 DB 连接,也不接入 user/auth 授额出口,这些依赖由 cmd 入口注入;
|
||||||
|
// 3. 启动后阻塞当前进程,保持后续“一服务一进程”的迁移方向。
|
||||||
|
func Start(opts ServerOptions) {
|
||||||
|
server, listenOn, err := NewServer(opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build tokenstore zrpc server: %v", err)
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
log.Printf("tokenstore zrpc service starting on %s", listenOn)
|
||||||
|
server.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer 负责创建 token-store RPC server,供 cmd 启动和测试复用。
|
||||||
|
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||||
|
if opts.Service == nil {
|
||||||
|
return nil, "", errors.New("tokenstore service dependency not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||||
|
if listenOn == "" {
|
||||||
|
listenOn = defaultListenOn
|
||||||
|
}
|
||||||
|
timeout := opts.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||||
|
ServiceConf: service.ServiceConf{
|
||||||
|
Name: "tokenstore.rpc",
|
||||||
|
Mode: service.DevMode,
|
||||||
|
},
|
||||||
|
ListenOn: listenOn,
|
||||||
|
Timeout: int64(timeout / time.Millisecond),
|
||||||
|
}, func(grpcServer *grpc.Server) {
|
||||||
|
pb.RegisterTokenStoreServiceServer(grpcServer, NewHandler(opts.Service))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return server, listenOn, nil
|
||||||
|
}
|
||||||
156
backend/services/tokenstore/rpc/tokenstore.proto
Normal file
156
backend/services/tokenstore/rpc/tokenstore.proto
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package smartflow.tokenstore;
|
||||||
|
|
||||||
|
option go_package = "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb";
|
||||||
|
|
||||||
|
service TokenStoreService {
|
||||||
|
rpc GetSummary(GetTokenSummaryRequest) returns (GetTokenSummaryResponse);
|
||||||
|
rpc ListProducts(ListTokenProductsRequest) returns (ListTokenProductsResponse);
|
||||||
|
rpc CreateOrder(CreateTokenOrderRequest) returns (CreateTokenOrderResponse);
|
||||||
|
rpc ListOrders(ListTokenOrdersRequest) returns (ListTokenOrdersResponse);
|
||||||
|
rpc GetOrder(GetTokenOrderRequest) returns (GetTokenOrderResponse);
|
||||||
|
rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse);
|
||||||
|
rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse);
|
||||||
|
rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message PageResponse {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
int32 total = 3;
|
||||||
|
bool has_more = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenSummary {
|
||||||
|
int64 recorded_token_total = 1;
|
||||||
|
int64 applied_token_total = 2;
|
||||||
|
int64 pending_apply_token_total = 3;
|
||||||
|
string quota_sync_status = 4;
|
||||||
|
string tip = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenProductView {
|
||||||
|
uint64 product_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string description = 3;
|
||||||
|
int64 token_amount = 4;
|
||||||
|
int64 price_cent = 5;
|
||||||
|
string price_text = 6;
|
||||||
|
string currency = 7;
|
||||||
|
string badge = 8;
|
||||||
|
string status = 9;
|
||||||
|
int32 sort_order = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenGrantView {
|
||||||
|
uint64 grant_id = 1;
|
||||||
|
string event_id = 2;
|
||||||
|
string source = 3;
|
||||||
|
string source_label = 4;
|
||||||
|
int64 amount = 5;
|
||||||
|
string status = 6;
|
||||||
|
bool quota_applied = 7;
|
||||||
|
string description = 8;
|
||||||
|
string created_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenOrderView {
|
||||||
|
uint64 order_id = 1;
|
||||||
|
string order_no = 2;
|
||||||
|
string status = 3;
|
||||||
|
int64 token_amount = 4;
|
||||||
|
int64 amount_cent = 5;
|
||||||
|
string price_text = 6;
|
||||||
|
string currency = 7;
|
||||||
|
string payment_mode = 8;
|
||||||
|
TokenGrantView grant = 9;
|
||||||
|
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 {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetTokenSummaryResponse {
|
||||||
|
TokenSummary summary = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenProductsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenProductsResponse {
|
||||||
|
repeated TokenProductView items = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateTokenOrderRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 product_id = 2;
|
||||||
|
int32 quantity = 3;
|
||||||
|
string idempotency_key = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateTokenOrderResponse {
|
||||||
|
TokenOrderView order = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenOrdersRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 page = 2;
|
||||||
|
int32 page_size = 3;
|
||||||
|
string status = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenOrdersResponse {
|
||||||
|
repeated TokenOrderView items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetTokenOrderRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 order_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetTokenOrderResponse {
|
||||||
|
TokenOrderView order = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MockPaidOrderRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 order_id = 2;
|
||||||
|
string mock_channel = 3;
|
||||||
|
string idempotency_key = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MockPaidOrderResponse {
|
||||||
|
TokenOrderView order = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenGrantsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 page = 2;
|
||||||
|
int32 page_size = 3;
|
||||||
|
string source = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListTokenGrantsResponse {
|
||||||
|
repeated TokenGrantView items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RecordForumRewardGrantRequest {
|
||||||
|
string event_id = 1;
|
||||||
|
uint64 receiver_user_id = 2;
|
||||||
|
string source = 3;
|
||||||
|
string source_ref_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RecordForumRewardGrantResponse {
|
||||||
|
TokenGrantView grant = 1;
|
||||||
|
}
|
||||||
84
backend/services/tokenstore/sv/grant.go
Normal file
84
backend/services/tokenstore/sv/grant.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSummary 聚合当前用户在 token-store 账本中的获得记录。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只统计 token_grants,不读取 user/auth 的权威额度。
|
||||||
|
// 2. 只汇总正向获取额度,避免把未来的冲正或补偿误算进 P0 展示口径。
|
||||||
|
// 3. quota_sync_status 在 P0 固定为 not_connected,明确告知尚未打通权威额度。
|
||||||
|
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if actorUserID == 0 {
|
||||||
|
return nil, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := s.tokenDAO.SummarizePositiveGrants(ctx, actorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := summary.RecordedTokenTotal - summary.AppliedTokenTotal
|
||||||
|
if pending < 0 {
|
||||||
|
pending = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokencontracts.TokenSummary{
|
||||||
|
RecordedTokenTotal: summary.RecordedTokenTotal,
|
||||||
|
AppliedTokenTotal: summary.AppliedTokenTotal,
|
||||||
|
PendingApplyTokenTotal: pending,
|
||||||
|
QuotaSyncStatus: tokenSummaryQuotaStatusNotConnected,
|
||||||
|
Tip: tokenSummaryTipP0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGrants 按用户分页查询 Token 获得记录。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只支持 user_id 维度分页和 source 过滤,不做跨用户检索。
|
||||||
|
// 2. 负责把空 source 归一化为“不筛选”,避免 DAO 层重复处理入口噪音。
|
||||||
|
// 3. 结果只来自账本事实,不推导 user/auth 可用额度。
|
||||||
|
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, tokencontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if req.ActorUserID == 0 {
|
||||||
|
return nil, tokencontracts.PageResult{}, respond.MissingParam
|
||||||
|
}
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||||
|
query := tokenstoredao.ListTokenGrantsQuery{
|
||||||
|
UserID: req.ActorUserID,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Source: strings.TrimSpace(req.Source),
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := s.tokenDAO.CountGrants(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, tokencontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
grants, err := s.tokenDAO.ListGrants(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, tokencontracts.PageResult{}, err
|
||||||
|
}
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return []tokencontracts.TokenGrantView{}, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]tokencontracts.TokenGrantView, 0, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
result = append(result, grantViewFromModel(grant))
|
||||||
|
}
|
||||||
|
return result, pageResult(page, pageSize, total), nil
|
||||||
|
}
|
||||||
238
backend/services/tokenstore/sv/helpers.go
Normal file
238
backend/services/tokenstore/sv/helpers.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||||
|
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPage = 1
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 50
|
||||||
|
|
||||||
|
tokenSummaryQuotaStatusNotConnected = "not_connected"
|
||||||
|
tokenSummaryTipP0 = "当前仅统计 Token 商店已记录的获得记录,尚未同步到 user/auth 可用额度。"
|
||||||
|
)
|
||||||
|
|
||||||
|
type productSnapshot struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
PriceCent int64 `json:"price_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePage(page int, pageSize int) (int, int) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = defaultPage
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = defaultPageSize
|
||||||
|
}
|
||||||
|
if pageSize > maxPageSize {
|
||||||
|
pageSize = maxPageSize
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageResult(page int, pageSize int, total int64) tokencontracts.PageResult {
|
||||||
|
return tokencontracts.PageResult{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Total: int(total),
|
||||||
|
HasMore: int64(page*pageSize) < total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTimePtr(value *time.Time) *string {
|
||||||
|
if value == nil || value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formatted := value.Format(time.RFC3339)
|
||||||
|
return &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPriceText(currency string, amountCent int64) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(currency), "CNY") {
|
||||||
|
return fmt.Sprintf("¥%.2f", float64(amountCent)/100)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %.2f", strings.ToUpper(strings.TrimSpace(currency)), float64(amountCent)/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtrFromNonEmpty(value string) *string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func productViewFromModel(product tokenmodel.TokenProduct) tokencontracts.TokenProductView {
|
||||||
|
return tokencontracts.TokenProductView{
|
||||||
|
ProductID: product.ID,
|
||||||
|
Name: product.Name,
|
||||||
|
Description: product.Description,
|
||||||
|
TokenAmount: product.TokenAmount,
|
||||||
|
PriceCent: product.PriceCent,
|
||||||
|
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||||
|
Currency: product.Currency,
|
||||||
|
Badge: product.Badge,
|
||||||
|
Status: product.Status,
|
||||||
|
SortOrder: product.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantViewFromModel(grant tokenmodel.TokenGrant) tokencontracts.TokenGrantView {
|
||||||
|
return tokencontracts.TokenGrantView{
|
||||||
|
GrantID: grant.ID,
|
||||||
|
EventID: grant.EventID,
|
||||||
|
Source: grant.Source,
|
||||||
|
SourceLabel: grantSourceLabel(grant.Source, grant.SourceLabel),
|
||||||
|
Amount: grant.Amount,
|
||||||
|
Status: grant.Status,
|
||||||
|
QuotaApplied: grant.QuotaApplied,
|
||||||
|
Description: grant.Description,
|
||||||
|
CreatedAt: formatTime(grant.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderViewFromModel(order tokenmodel.TokenOrder, grant *tokenmodel.TokenGrant) tokencontracts.TokenOrderView {
|
||||||
|
var grantView *tokencontracts.TokenGrantView
|
||||||
|
if grant != nil {
|
||||||
|
view := grantViewFromModel(*grant)
|
||||||
|
grantView = &view
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokencontracts.TokenOrderView{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
Status: order.Status,
|
||||||
|
ProductSnapshot: order.ProductSnapshotJSON,
|
||||||
|
ProductName: order.ProductName,
|
||||||
|
Quantity: order.Quantity,
|
||||||
|
TokenAmount: order.TokenAmount,
|
||||||
|
AmountCent: order.AmountCent,
|
||||||
|
PriceText: formatPriceText(order.Currency, order.AmountCent),
|
||||||
|
Currency: order.Currency,
|
||||||
|
PaymentMode: order.PaymentMode,
|
||||||
|
Grant: grantView,
|
||||||
|
CreatedAt: formatTime(order.CreatedAt),
|
||||||
|
PaidAt: formatTimePtr(order.PaidAt),
|
||||||
|
GrantedAt: formatTimePtr(order.GrantedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantSourceLabel(source string, fallback string) string {
|
||||||
|
if strings.TrimSpace(fallback) != "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(source) {
|
||||||
|
case tokenmodel.TokenGrantSourcePurchase:
|
||||||
|
return "购买充值"
|
||||||
|
case tokenmodel.TokenGrantSourceForumLike:
|
||||||
|
return "计划被点赞"
|
||||||
|
case tokenmodel.TokenGrantSourceForumImport:
|
||||||
|
return "计划被导入"
|
||||||
|
case tokenmodel.TokenGrantSourceManual:
|
||||||
|
return "人工补发"
|
||||||
|
default:
|
||||||
|
return "Token 获得记录"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProductSnapshot(product tokenmodel.TokenProduct) (string, error) {
|
||||||
|
snapshot := productSnapshot{
|
||||||
|
ProductID: product.ID,
|
||||||
|
SKU: product.SKU,
|
||||||
|
Name: product.Name,
|
||||||
|
Description: product.Description,
|
||||||
|
TokenAmount: product.TokenAmount,
|
||||||
|
PriceCent: product.PriceCent,
|
||||||
|
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||||
|
Currency: product.Currency,
|
||||||
|
Badge: product.Badge,
|
||||||
|
Status: product.Status,
|
||||||
|
SortOrder: product.SortOrder,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderNo() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"TS%s%s",
|
||||||
|
time.Now().Format("20060102150405"),
|
||||||
|
strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseGrantEventID(orderID uint64) string {
|
||||||
|
return fmt.Sprintf("order:%d:paid", orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseGrantDescription(productName string) string {
|
||||||
|
trimmed := strings.TrimSpace(productName)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "购买 Token 商品"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("购买%s", trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicateKeyError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(lower, "duplicate entry") ||
|
||||||
|
strings.Contains(lower, "duplicate key") ||
|
||||||
|
strings.Contains(lower, "unique constraint") ||
|
||||||
|
strings.Contains(lower, "unique violation") ||
|
||||||
|
strings.Contains(lower, "error 1062")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordNotFound(err error, fallback error) error {
|
||||||
|
if errorsIsRecordNotFound(err) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIsRecordNotFound(err error) bool {
|
||||||
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。
|
||||||
|
// 具体错误原因仍放在 Info,避免为每个商品/订单校验分支提前扩散大量细分码。
|
||||||
|
const tokenStoreBadRequestStatus = "40067"
|
||||||
|
|
||||||
|
func tokenStoreBadRequest(message string) respond.Response {
|
||||||
|
return respond.Response{
|
||||||
|
Status: tokenStoreBadRequestStatus,
|
||||||
|
Info: strings.TrimSpace(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
312
backend/services/tokenstore/sv/order.go
Normal file
312
backend/services/tokenstore/sv/order.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/shared/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
|
||||||
|
}
|
||||||
157
backend/services/tokenstore/sv/outbox.go
Normal file
157
backend/services/tokenstore/sv/outbox.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||||
|
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
|
||||||
|
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutboxBus 是 token-store 注册论坛奖励 handler 需要的最小总线接口。
|
||||||
|
type OutboxBus interface {
|
||||||
|
RegisterEventHandler(eventType string, handler outboxinfra.MessageHandler) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterForumRewardRoutes 只登记 token-store 负责消费的论坛奖励事件归属。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责把事件类型路由到 token-store 的服务级 outbox;
|
||||||
|
// 2. 不注册 handler,也不启动 consumer;
|
||||||
|
// 3. 供发布方和消费方在不同进程内共享同一份事件归属映射。
|
||||||
|
func RegisterForumRewardRoutes() error {
|
||||||
|
if err := outboxinfra.RegisterEventService(sharedevents.ForumPostLikedEventType, outboxinfra.ServiceTokenStore); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return outboxinfra.RegisterEventService(sharedevents.ForumPostImportedEventType, outboxinfra.ServiceTokenStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterForumRewardHandlers 注册 token-store 对论坛奖励事件的消费处理器。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只消费 forum.post.liked / forum.post.imported 两类事件;
|
||||||
|
// 2. 论坛奖励账本写入由 token-store Service 负责,handler 不自行计算金额;
|
||||||
|
// 3. grant 写入成功后再标记 consumed;若标记失败,可依赖 event_id 幂等安全重试。
|
||||||
|
func RegisterForumRewardHandlers(bus OutboxBus, outboxRepo *outboxinfra.Repository, svc *Service) error {
|
||||||
|
if bus == nil {
|
||||||
|
return errors.New("event bus is nil")
|
||||||
|
}
|
||||||
|
if outboxRepo == nil {
|
||||||
|
return errors.New("outbox repository is nil")
|
||||||
|
}
|
||||||
|
if svc == nil {
|
||||||
|
return errors.New("tokenstore service is nil")
|
||||||
|
}
|
||||||
|
if err := RegisterForumRewardRoutes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := registerForumRewardHandler(bus, outboxRepo, svc, sharedevents.ForumPostLikedEventType, sharedevents.ForumRewardSourceLike); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return registerForumRewardHandler(bus, outboxRepo, svc, sharedevents.ForumPostImportedEventType, sharedevents.ForumRewardSourceImport)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerForumRewardHandler(
|
||||||
|
bus OutboxBus,
|
||||||
|
outboxRepo *outboxinfra.Repository,
|
||||||
|
svc *Service,
|
||||||
|
eventType string,
|
||||||
|
source string,
|
||||||
|
) error {
|
||||||
|
route, ok := outboxinfra.ResolveEventRoute(eventType)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("forum reward outbox route is missing: eventType=%s", eventType)
|
||||||
|
}
|
||||||
|
eventOutboxRepo := outboxRepo.WithRoute(route)
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
|
||||||
|
if !isAllowedForumRewardEventVersion(envelope.EventVersion) {
|
||||||
|
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件版本不受支持: %s", envelope.EventVersion)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload sharedevents.ForumPostRewardPayload
|
||||||
|
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||||
|
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析论坛奖励载荷失败: "+err.Error()); markErr != nil {
|
||||||
|
return markErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := payload.Validate(); err != nil {
|
||||||
|
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励载荷非法: "+err.Error()); markErr != nil {
|
||||||
|
return markErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if payload.EventType() != eventType {
|
||||||
|
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件类型不匹配: envelope=%s payload=%s", eventType, payload.EventType())); markErr != nil {
|
||||||
|
return markErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID := strings.TrimSpace(envelope.EventID)
|
||||||
|
if eventID == "" {
|
||||||
|
eventID = strings.TrimSpace(payload.EventID)
|
||||||
|
}
|
||||||
|
if eventID == "" {
|
||||||
|
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励 event_id 为空"); markErr != nil {
|
||||||
|
return markErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
|
||||||
|
EventID: eventID,
|
||||||
|
ReceiverUserID: payload.RewardReceiverUserID,
|
||||||
|
Source: forumRewardSource(payload, source),
|
||||||
|
SourceRefID: forumRewardSourceRefID(payload, source),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"forum reward event consumed by tokenstore: event_type=%s event_id=%s grant_id=%d outbox_id=%d",
|
||||||
|
eventType,
|
||||||
|
eventID,
|
||||||
|
grant.GrantID,
|
||||||
|
envelope.OutboxID,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return bus.RegisterEventHandler(eventType, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedForumRewardEventVersion(version string) bool {
|
||||||
|
version = strings.TrimSpace(version)
|
||||||
|
return version == "" || version == sharedevents.ForumRewardEventVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumRewardSource(payload sharedevents.ForumPostRewardPayload, fallback string) string {
|
||||||
|
source := strings.TrimSpace(payload.Source)
|
||||||
|
if source != "" {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumRewardSourceRefID(payload sharedevents.ForumPostRewardPayload, source string) string {
|
||||||
|
if source == sharedevents.ForumRewardSourceImport && payload.ImportID > 0 {
|
||||||
|
return strconv.FormatUint(payload.ImportID, 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(payload.PostID, 10)
|
||||||
|
}
|
||||||
34
backend/services/tokenstore/sv/product.go
Normal file
34
backend/services/tokenstore/sv/product.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListProducts 返回当前可售商品列表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只返回 active 商品,不负责后台商品管理。
|
||||||
|
// 2. 负责补齐 price_text,保持前端不必重复格式化价格。
|
||||||
|
// 3. actorUserID 当前仅保留为统一接口形状,P0 不参与筛选。
|
||||||
|
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
|
||||||
|
_ = actorUserID
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
products, err := s.tokenDAO.ListActiveProducts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(products) == 0 {
|
||||||
|
return []tokencontracts.TokenProductView{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]tokencontracts.TokenProductView, 0, len(products))
|
||||||
|
for _, product := range products {
|
||||||
|
result = append(result, productViewFromModel(product))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
234
backend/services/tokenstore/sv/reward.go
Normal file
234
backend/services/tokenstore/sv/reward.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
forumLikeRewardConfigKey = "tokenstore.reward.forumLikeAmount"
|
||||||
|
forumImportRewardConfigKey = "tokenstore.reward.forumImportAmount"
|
||||||
|
|
||||||
|
defaultForumLikeRewardAmount int64 = 1
|
||||||
|
defaultForumImportRewardAmount int64 = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type forumRewardGrantRequest struct {
|
||||||
|
EventID string
|
||||||
|
ReceiverUserID uint64
|
||||||
|
Source string
|
||||||
|
SourceRefID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type forumRewardDecision struct {
|
||||||
|
Amount int64
|
||||||
|
Status string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理 forum_like / forum_import 两类奖励账本写入,不修改 users,也不调用 user/auth;
|
||||||
|
// 2. 以 event_id 作为最终幂等边界,重复请求校验一致后返回既有 grant;
|
||||||
|
// 3. 奖励金额优先读取 token_reward_rules,配置和代码默认值只作为兜底。
|
||||||
|
func (s *Service) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
|
||||||
|
if err := s.Ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeForumRewardGrantRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先按 event_id 回查,命中时直接视为成功,避免 outbox 重试重复写账本。
|
||||||
|
// 2. 命中后必须校验用户、来源和来源业务 ID,避免错误复用 event_id 时静默吞掉错账。
|
||||||
|
// 3. 校验通过才返回既有 grant,兼容“首次已成功、调用方超时后重试”的常见场景。
|
||||||
|
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
view := grantViewFromModel(*existing)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceRefID := normalized.SourceRefID
|
||||||
|
decision, err := s.forumRewardDecision(ctx, normalized.Source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grant := tokenmodel.TokenGrant{
|
||||||
|
EventID: normalized.EventID,
|
||||||
|
UserID: normalized.ReceiverUserID,
|
||||||
|
Source: normalized.Source,
|
||||||
|
SourceLabel: grantSourceLabel(normalized.Source, ""),
|
||||||
|
SourceRefID: &sourceRefID,
|
||||||
|
Amount: decision.Amount,
|
||||||
|
Status: decision.Status,
|
||||||
|
QuotaApplied: false,
|
||||||
|
Description: decision.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 账本写入只依赖 token_grants.event_id 唯一约束兜底并发幂等。
|
||||||
|
// 2. 若并发下插入触发唯一键冲突,立刻回查 event_id,把已有 grant 当作成功结果返回。
|
||||||
|
// 3. 只有“冲突后仍查不到旧记录”这种异常态才上抛内部错误,避免吞掉真实一致性问题。
|
||||||
|
if err := s.tokenDAO.CreateGrant(ctx, &grant); err != nil {
|
||||||
|
if !isDuplicateKeyError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
return nil, errors.New("forum reward grant duplicated but not found by event_id")
|
||||||
|
}
|
||||||
|
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
view := grantViewFromModel(*existing)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
view := grantViewFromModel(grant)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantRequest) (forumRewardGrantRequest, error) {
|
||||||
|
normalized := forumRewardGrantRequest{
|
||||||
|
EventID: strings.TrimSpace(req.EventID),
|
||||||
|
ReceiverUserID: req.ReceiverUserID,
|
||||||
|
Source: strings.ToLower(strings.TrimSpace(req.Source)),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case normalized.EventID == "":
|
||||||
|
return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空")
|
||||||
|
case normalized.ReceiverUserID == 0:
|
||||||
|
return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID)
|
||||||
|
if err != nil {
|
||||||
|
return forumRewardGrantRequest{}, err
|
||||||
|
}
|
||||||
|
normalized.SourceRefID = sourceRefID
|
||||||
|
|
||||||
|
switch normalized.Source {
|
||||||
|
case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport:
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForumRewardSourceRefID(raw string) (uint64, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return 0, tokenStoreBadRequest("source_ref_id 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseUint(trimmed, 10, 64)
|
||||||
|
if err != nil || parsed == 0 {
|
||||||
|
return 0, tokenStoreBadRequest("source_ref_id 必须是正整数")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateExistingForumRewardGrant 校验重复 event_id 是否真的是同一条论坛奖励。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只比较幂等所需的最小字段:接收人、来源和来源业务 ID;
|
||||||
|
// 2. 不比较金额和状态,避免规则调整后重放旧事件被误判;
|
||||||
|
// 3. 不一致时返回业务校验错误,让上游暴露这类错账风险。
|
||||||
|
func validateExistingForumRewardGrant(existing tokenmodel.TokenGrant, req forumRewardGrantRequest) error {
|
||||||
|
sourceRefID := uint64(0)
|
||||||
|
if existing.SourceRefID != nil {
|
||||||
|
sourceRefID = *existing.SourceRefID
|
||||||
|
}
|
||||||
|
if existing.UserID != req.ReceiverUserID || existing.Source != req.Source || sourceRefID != req.SourceRefID {
|
||||||
|
return tokenStoreBadRequest("event_id 幂等冲突:已有奖励记录与本次论坛奖励请求不一致")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forumRewardDecision 解析论坛奖励发放决策。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 优先读取 token_reward_rules,保持“从表里读”的 P0 口径;
|
||||||
|
// 2. 规则停用或金额非正时写 skipped 账本,消费 outbox 但不增加 Token;
|
||||||
|
// 3. 表规则缺失时再读取配置和代码默认值,兼容旧环境尚未 seed 的情况。
|
||||||
|
func (s *Service) forumRewardDecision(ctx context.Context, source string) (forumRewardDecision, error) {
|
||||||
|
rule, err := s.tokenDAO.FindRewardRuleBySource(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
return forumRewardDecision{}, err
|
||||||
|
}
|
||||||
|
if rule != nil {
|
||||||
|
if strings.TrimSpace(rule.Status) != tokenmodel.TokenRewardRuleStatusActive {
|
||||||
|
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Token"), nil
|
||||||
|
}
|
||||||
|
if rule.Amount <= 0 {
|
||||||
|
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Token"), nil
|
||||||
|
}
|
||||||
|
return recordedForumRewardDecision(source, rule.Amount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.TrimSpace(source) {
|
||||||
|
case tokenmodel.TokenGrantSourceForumLike:
|
||||||
|
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumLikeRewardConfigKey, defaultForumLikeRewardAmount)), nil
|
||||||
|
case tokenmodel.TokenGrantSourceForumImport:
|
||||||
|
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumImportRewardConfigKey, defaultForumImportRewardAmount)), nil
|
||||||
|
default:
|
||||||
|
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Token"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedForumRewardDecision(source string, amount int64) forumRewardDecision {
|
||||||
|
if amount <= 0 {
|
||||||
|
return skippedForumRewardDecision(source, "奖励金额非正,未发放 Token")
|
||||||
|
}
|
||||||
|
return forumRewardDecision{
|
||||||
|
Amount: amount,
|
||||||
|
Status: tokenmodel.TokenGrantStatusRecorded,
|
||||||
|
Description: forumRewardDescription(source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skippedForumRewardDecision(source string, description string) forumRewardDecision {
|
||||||
|
return forumRewardDecision{
|
||||||
|
Amount: 0,
|
||||||
|
Status: tokenmodel.TokenGrantStatusSkipped,
|
||||||
|
Description: strings.TrimSpace(description),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 {
|
||||||
|
amount := viper.GetInt64(configKey)
|
||||||
|
if amount <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumRewardDescription(source string) string {
|
||||||
|
switch strings.TrimSpace(source) {
|
||||||
|
case tokenmodel.TokenGrantSourceForumLike:
|
||||||
|
return "计划被点赞奖励"
|
||||||
|
case tokenmodel.TokenGrantSourceForumImport:
|
||||||
|
return "计划被导入奖励"
|
||||||
|
default:
|
||||||
|
return "论坛奖励入账"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/services/tokenstore/sv/service.go
Normal file
60
backend/services/tokenstore/sv/service.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package sv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||||
|
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
|
||||||
|
var ErrNotImplemented = errors.New("tokenstore service method not implemented")
|
||||||
|
|
||||||
|
// TokenGrantOutlet 是 token-store 后续切到 user/auth 权威额度的内部发放出口。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 只记录 token-store 自己的获取事实和账本;
|
||||||
|
// 2. 禁止直接修改 users 表;
|
||||||
|
// 3. 后续切 user/auth 时新增 adapter,服务编排层不重写。
|
||||||
|
type TokenGrantOutlet interface {
|
||||||
|
RecordAcquisition(ctx context.Context, grant tokencontracts.TokenGrantRecord) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options 是 token-store 服务的依赖注入参数。
|
||||||
|
type Options struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
GrantOutlet TokenGrantOutlet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 承载 Token 商店服务内部业务编排。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责商品、订单、mock paid、grant 账本和奖励规则;
|
||||||
|
// 2. 不负责登录鉴权,也不直接修改 user/auth 权威额度;
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready 用于第二步骨架阶段的依赖检查。
|
||||||
|
func (s *Service) Ready() error {
|
||||||
|
if s == nil {
|
||||||
|
return errors.New("tokenstore service is nil")
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return errors.New("tokenstore db is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
145
backend/shared/contracts/taskclassforum/types.go
Normal file
145
backend/shared/contracts/taskclassforum/types.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package taskclassforum
|
||||||
|
|
||||||
|
// PageResult 是计划广场分页响应的跨层契约。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只描述分页元数据,不负责查询和排序逻辑;
|
||||||
|
// 2. Items 由具体接口决定,避免为了 P0 引入复杂泛型到 RPC 边界;
|
||||||
|
// 3. HTTP 层和 RPC 层需要保持字段语义一致。
|
||||||
|
type PageResult struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBrief 是计划广场前端展示作者和评论人的最小用户信息。
|
||||||
|
type UserBrief struct {
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateSummary 是列表卡片里的模板摘要。
|
||||||
|
type TemplateSummary struct {
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
StrategyLabels []string `json:"strategy_labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostCounters 是帖子计数字段快照。
|
||||||
|
type ForumPostCounters struct {
|
||||||
|
LikeCount int64 `json:"like_count"`
|
||||||
|
CommentCount int64 `json:"comment_count"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostViewerState 是当前登录用户相对该帖子的状态。
|
||||||
|
type ForumPostViewerState struct {
|
||||||
|
Liked bool `json:"liked"`
|
||||||
|
ImportedOnce bool `json:"imported_once"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumTagItem 是计划广场标签筛选区的最小展示单元。
|
||||||
|
type ForumTagItem struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
PostCount int `json:"post_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostBrief 是计划列表和详情头部共用的帖子摘要。
|
||||||
|
type ForumPostBrief struct {
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
TemplateSummary TemplateSummary `json:"template_summary"`
|
||||||
|
Counters ForumPostCounters `json:"counters"`
|
||||||
|
ViewerState ForumPostViewerState `json:"viewer_state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateItemPreview 是详情页展示的任务条目快照。
|
||||||
|
type TemplateItemPreview struct {
|
||||||
|
ItemID uint64 `json:"item_id"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateDetail 是论坛模板快照的前端展示结构。
|
||||||
|
type TemplateDetail struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
StrategyLabels []string `json:"strategy_labels"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
ItemsPreview []TemplateItemPreview `json:"items_preview"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostDetail 是计划详情接口响应主体。
|
||||||
|
type ForumPostDetail struct {
|
||||||
|
Post ForumPostBrief `json:"post"`
|
||||||
|
Template TemplateDetail `json:"template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumCommentNode 是服务层组装后的多层评论树节点。
|
||||||
|
type ForumCommentNode struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
CanDelete bool `json:"can_delete"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
Children []ForumCommentNode `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumPostRequest 是发布计划请求契约。
|
||||||
|
type CreateForumPostRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumCommentRequest 是发表评论或回复请求契约。
|
||||||
|
type CreateForumCommentRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportForumPostRequest 是一键导入请求契约。
|
||||||
|
type ImportForumPostRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteForumCommentResult 是删除评论后的状态回执。
|
||||||
|
type DeleteForumCommentResult struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportForumPostResult 是一键导入后的回执。
|
||||||
|
type ImportForumPostResult struct {
|
||||||
|
ImportID uint64 `json:"import_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
NewTaskClassID uint64 `json:"new_task_class_id"`
|
||||||
|
TaskClassTitle string `json:"task_class_title"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
125
backend/shared/contracts/tokenstore/types.go
Normal file
125
backend/shared/contracts/tokenstore/types.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package tokenstore
|
||||||
|
|
||||||
|
// PageResult 是 token-store 分页响应的跨层契约。
|
||||||
|
type PageResult struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenSummary 是 Token 商店概览响应。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 展示 token-store 已记录的获取事实;
|
||||||
|
// 2. 不承诺这些 Token 已经同步到 user/auth 权威额度;
|
||||||
|
// 3. 后续接入 user/auth 后可把 QuotaSyncStatus 调整为 synced。
|
||||||
|
type TokenSummary struct {
|
||||||
|
RecordedTokenTotal int64 `json:"recorded_token_total"`
|
||||||
|
AppliedTokenTotal int64 `json:"applied_token_total"`
|
||||||
|
PendingApplyTokenTotal int64 `json:"pending_apply_token_total"`
|
||||||
|
QuotaSyncStatus string `json:"quota_sync_status"`
|
||||||
|
Tip string `json:"tip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenProductView 是商品卡片展示结构。
|
||||||
|
type TokenProductView struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrantView 是 Token 获取记录展示结构。
|
||||||
|
type TokenGrantView struct {
|
||||||
|
GrantID uint64 `json:"grant_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceLabel string `json:"source_label"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
QuotaApplied bool `json:"quota_applied"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenOrderView 是订单展示结构。
|
||||||
|
type TokenOrderView struct {
|
||||||
|
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 是创建订单请求契约。
|
||||||
|
type CreateTokenOrderRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenOrdersRequest 是订单列表查询契约。
|
||||||
|
type ListTokenOrdersRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockPaidOrderRequest 是 P0 mock paid 请求契约。
|
||||||
|
type MockPaidOrderRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
MockChannel string `json:"mock_channel"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenGrantsRequest 是 Token 获取记录列表查询契约。
|
||||||
|
type ListTokenGrantsRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordForumRewardGrantRequest 是论坛奖励入账的内部 RPC 契约。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只描述一条待记录到 token_grants 的论坛奖励事实;
|
||||||
|
// 2. 不携带最终奖励金额,金额由 token-store 按 source 和配置解析;
|
||||||
|
// 3. source_ref_id 使用字符串承接 post_id / import_id,服务层再按当前库表结构落成整数。
|
||||||
|
type RecordForumRewardGrantRequest struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
ReceiverUserID uint64 `json:"receiver_user_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceRefID string `json:"source_ref_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrantRecord 是 token-store 内部发放出口使用的获取事实。
|
||||||
|
type TokenGrantRecord struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceRefID uint64 `json:"source_ref_id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
128
backend/shared/events/forum.go
Normal file
128
backend/shared/events/forum.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ForumPostLikedEventType = "forum.post.liked"
|
||||||
|
ForumPostImportedEventType = "forum.post.imported"
|
||||||
|
ForumRewardEventVersion = "v1"
|
||||||
|
|
||||||
|
ForumRewardSourceLike = "forum_like"
|
||||||
|
ForumRewardSourceImport = "forum_import"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForumPostRewardPayload 是计划广场作者奖励事件的统一载荷。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只描述“哪个帖子因什么互动触发了作者奖励”,不直接携带最终 Token 数额;
|
||||||
|
// 2. source 负责表达奖励来源,真正的奖励规则仍由 token-store 自己解析;
|
||||||
|
// 3. event_id 必须稳定,供 outbox 重试和下游记账幂等共同使用。
|
||||||
|
type ForumPostRewardPayload struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
ImportID uint64 `json:"import_id"`
|
||||||
|
AuthorUserID uint64 `json:"author_user_id"`
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
RewardReceiverUserID uint64 `json:"reward_receiver_user_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewForumPostLikedPayload(postID uint64, authorUserID uint64, actorUserID uint64, occurredAt time.Time) ForumPostRewardPayload {
|
||||||
|
return newForumPostRewardPayload(
|
||||||
|
ForumPostLikedEventType,
|
||||||
|
ForumRewardSourceLike,
|
||||||
|
postID,
|
||||||
|
0,
|
||||||
|
authorUserID,
|
||||||
|
actorUserID,
|
||||||
|
occurredAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewForumPostImportedPayload(postID uint64, importID uint64, authorUserID uint64, actorUserID uint64, occurredAt time.Time) ForumPostRewardPayload {
|
||||||
|
return newForumPostRewardPayload(
|
||||||
|
ForumPostImportedEventType,
|
||||||
|
ForumRewardSourceImport,
|
||||||
|
postID,
|
||||||
|
importID,
|
||||||
|
authorUserID,
|
||||||
|
actorUserID,
|
||||||
|
occurredAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newForumPostRewardPayload(
|
||||||
|
eventType string,
|
||||||
|
source string,
|
||||||
|
postID uint64,
|
||||||
|
importID uint64,
|
||||||
|
authorUserID uint64,
|
||||||
|
actorUserID uint64,
|
||||||
|
occurredAt time.Time,
|
||||||
|
) ForumPostRewardPayload {
|
||||||
|
if occurredAt.IsZero() {
|
||||||
|
occurredAt = time.Now()
|
||||||
|
}
|
||||||
|
return ForumPostRewardPayload{
|
||||||
|
EventID: ForumRewardEventID(eventType, postID, actorUserID),
|
||||||
|
PostID: postID,
|
||||||
|
ImportID: importID,
|
||||||
|
AuthorUserID: authorUserID,
|
||||||
|
ActorUserID: actorUserID,
|
||||||
|
RewardReceiverUserID: authorUserID,
|
||||||
|
Source: strings.TrimSpace(source),
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumRewardEventID(eventType string, postID uint64, actorUserID uint64) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%d", strings.TrimSpace(eventType), postID, actorUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventType 根据 source 反推出当前奖励事件类型。
|
||||||
|
func (p ForumPostRewardPayload) EventType() string {
|
||||||
|
switch strings.TrimSpace(p.Source) {
|
||||||
|
case ForumRewardSourceLike:
|
||||||
|
return ForumPostLikedEventType
|
||||||
|
case ForumRewardSourceImport:
|
||||||
|
return ForumPostImportedEventType
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ForumPostRewardPayload) MessageKey() string {
|
||||||
|
return strings.TrimSpace(p.EventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ForumPostRewardPayload) AggregateID() string {
|
||||||
|
return fmt.Sprintf("post:%d", p.PostID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ForumPostRewardPayload) Validate() error {
|
||||||
|
if strings.TrimSpace(p.EventID) == "" {
|
||||||
|
return errors.New("forum reward event_id 不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.EventType()) == "" {
|
||||||
|
return errors.New("forum reward source 非法")
|
||||||
|
}
|
||||||
|
if p.PostID == 0 {
|
||||||
|
return errors.New("forum reward post_id 不能为空")
|
||||||
|
}
|
||||||
|
if p.AuthorUserID == 0 || p.ActorUserID == 0 || p.RewardReceiverUserID == 0 {
|
||||||
|
return errors.New("forum reward user_id 不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Source) == ForumRewardSourceImport && p.ImportID == 0 {
|
||||||
|
return errors.New("forum import reward import_id 不能为空")
|
||||||
|
}
|
||||||
|
if p.OccurredAt.IsZero() {
|
||||||
|
return errors.New("forum reward occurred_at 不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
29
backend/shared/infra/outbox/migration.go
Normal file
29
backend/shared/infra/outbox/migration.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package outbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
runtimemodel "github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoMigrateServiceTable 按服务目录迁移单个服务拥有的 outbox 表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责创建或补齐服务级 outbox 物理表,不迁移任何业务表;
|
||||||
|
// 2. table 名统一从 service catalog 解析,避免独立服务和 core 进程各写一份默认值;
|
||||||
|
// 3. 失败时返回带 service/table 的错误,方便启动期直接定位配置漂移。
|
||||||
|
func AutoMigrateServiceTable(db *gorm.DB, serviceName string) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("auto migrate outbox table failed for %s: db is nil", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := ResolveServiceConfig(serviceName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("resolve outbox config failed for service %s", serviceName)
|
||||||
|
}
|
||||||
|
if err := db.Table(cfg.TableName).AutoMigrate(&runtimemodel.AgentOutboxMessage{}); err != nil {
|
||||||
|
return fmt.Errorf("auto migrate outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
backend/shared/infra/outbox/repository_publisher.go
Normal file
103
backend/shared/infra/outbox/repository_publisher.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package outbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepositoryPublisher 只负责把事件写入服务级 outbox 表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责复用 Repository 的 eventType -> service -> table 路由能力写入 outbox;
|
||||||
|
// 2. 不启动 Kafka relay / consumer,也不注册任何 handler;
|
||||||
|
// 3. 适合独立 RPC 服务进程只发布事件、统一由 worker 进程消费的迁移期场景。
|
||||||
|
type RepositoryPublisher struct {
|
||||||
|
repo *Repository
|
||||||
|
maxRetry int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepositoryPublisher 基于 outbox 仓储创建轻量发布器。
|
||||||
|
func NewRepositoryPublisher(repo *Repository, maxRetry int) *RepositoryPublisher {
|
||||||
|
return &RepositoryPublisher{
|
||||||
|
repo: repo,
|
||||||
|
maxRetry: maxRetry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish 写入统一事件外壳,保持与 Engine.Publish 相同的 outbox payload 格式。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先校验事件类型和业务 payload,明显坏入参直接返回错误,避免写入不可消费消息;
|
||||||
|
// 2. 再把业务 payload 序列化成 RawMessage,并包进统一事件外壳,保证 worker 解析口径一致;
|
||||||
|
// 3. 最后交给 Repository 按事件路由落表;路由缺失时返回错误,由业务侧决定是否降级。
|
||||||
|
func (p *RepositoryPublisher) Publish(ctx context.Context, req PublishRequest) error {
|
||||||
|
if p == nil || p.repo == nil {
|
||||||
|
return errors.New("outbox repository publisher is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := strings.TrimSpace(req.EventType)
|
||||||
|
if eventType == "" {
|
||||||
|
return errors.New("eventType is empty")
|
||||||
|
}
|
||||||
|
if req.Payload == nil {
|
||||||
|
return errors.New("payload is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadJSON, err := json.Marshal(req.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
eventVersion := strings.TrimSpace(req.EventVersion)
|
||||||
|
if eventVersion == "" {
|
||||||
|
eventVersion = DefaultEventVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID := strings.TrimSpace(req.EventID)
|
||||||
|
messageKey := strings.TrimSpace(req.MessageKey)
|
||||||
|
if messageKey == "" {
|
||||||
|
messageKey = eventID
|
||||||
|
}
|
||||||
|
if messageKey == "" {
|
||||||
|
messageKey = eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregateID := strings.TrimSpace(req.AggregateID)
|
||||||
|
if aggregateID == "" {
|
||||||
|
aggregateID = messageKey
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = p.repo.CreateMessage(ctx, eventType, messageKey, OutboxEventPayload{
|
||||||
|
EventID: eventID,
|
||||||
|
EventType: eventType,
|
||||||
|
EventVersion: eventVersion,
|
||||||
|
AggregateID: aggregateID,
|
||||||
|
Payload: payloadJSON,
|
||||||
|
}, p.maxRetry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishWithTx 使用外部事务写入 outbox 消息。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只把底层 Repository 切到调用方传入的事务句柄,事件外壳和路由逻辑仍复用 Publish;
|
||||||
|
// 2. 不提交或回滚事务,事务生命周期由业务用例控制;
|
||||||
|
// 3. 适合“业务表更新 + outbox 入队”必须原子提交的场景。
|
||||||
|
func (p *RepositoryPublisher) PublishWithTx(ctx context.Context, tx *gorm.DB, req PublishRequest) error {
|
||||||
|
if p == nil || p.repo == nil {
|
||||||
|
return errors.New("outbox repository publisher 未初始化")
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return errors.New("gorm 事务句柄为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
txPublisher := &RepositoryPublisher{
|
||||||
|
repo: p.repo.WithTx(tx),
|
||||||
|
maxRetry: p.maxRetry,
|
||||||
|
}
|
||||||
|
return txPublisher.Publish(ctx, req)
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ const (
|
|||||||
ServiceMemory = "memory"
|
ServiceMemory = "memory"
|
||||||
ServiceActiveScheduler = "active-scheduler"
|
ServiceActiveScheduler = "active-scheduler"
|
||||||
ServiceNotification = "notification"
|
ServiceNotification = "notification"
|
||||||
|
ServiceTaskClassForum = "taskclass-forum"
|
||||||
|
ServiceTokenStore = "token-store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceConfig 描述一个服务级 outbox 的固定归属。
|
// ServiceConfig 描述一个服务级 outbox 的固定归属。
|
||||||
@@ -83,6 +85,18 @@ func LoadServiceConfigs() map[string]ServiceConfig {
|
|||||||
GroupID: "smartflow-notification-outbox-consumer",
|
GroupID: "smartflow-notification-outbox-consumer",
|
||||||
TableName: "notification_outbox_messages",
|
TableName: "notification_outbox_messages",
|
||||||
},
|
},
|
||||||
|
ServiceTaskClassForum: {
|
||||||
|
Name: ServiceTaskClassForum,
|
||||||
|
Topic: "smartflow.taskclass-forum.outbox",
|
||||||
|
GroupID: "smartflow-taskclass-forum-outbox-consumer",
|
||||||
|
TableName: "taskclass_forum_outbox_messages",
|
||||||
|
},
|
||||||
|
ServiceTokenStore: {
|
||||||
|
Name: ServiceTokenStore,
|
||||||
|
Topic: "smartflow.token-store.outbox",
|
||||||
|
GroupID: "smartflow-token-store-outbox-consumer",
|
||||||
|
TableName: "token_store_outbox_messages",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, entry := range entries {
|
for name, entry := range entries {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const (
|
|||||||
ServiceNameMemory = "memory"
|
ServiceNameMemory = "memory"
|
||||||
ServiceNameActiveScheduler = "active-scheduler"
|
ServiceNameActiveScheduler = "active-scheduler"
|
||||||
ServiceNameNotification = "notification"
|
ServiceNameNotification = "notification"
|
||||||
|
ServiceNameTaskClassForum = "taskclass-forum"
|
||||||
|
ServiceNameTokenStore = "token-store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceRoute 描述一个 outbox 服务的终态路由信息。
|
// ServiceRoute 描述一个 outbox 服务的终态路由信息。
|
||||||
@@ -56,6 +58,18 @@ var builtinServiceRoutes = map[string]ServiceRoute{
|
|||||||
Topic: "smartflow.notification.outbox",
|
Topic: "smartflow.notification.outbox",
|
||||||
GroupID: "smartflow-notification-outbox-consumer",
|
GroupID: "smartflow-notification-outbox-consumer",
|
||||||
},
|
},
|
||||||
|
ServiceNameTaskClassForum: {
|
||||||
|
ServiceName: ServiceNameTaskClassForum,
|
||||||
|
TableName: "taskclass_forum_outbox_messages",
|
||||||
|
Topic: "smartflow.taskclass-forum.outbox",
|
||||||
|
GroupID: "smartflow-taskclass-forum-outbox-consumer",
|
||||||
|
},
|
||||||
|
ServiceNameTokenStore: {
|
||||||
|
ServiceName: ServiceNameTokenStore,
|
||||||
|
TableName: "token_store_outbox_messages",
|
||||||
|
Topic: "smartflow.token-store.outbox",
|
||||||
|
GroupID: "smartflow-token-store-outbox-consumer",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultServiceRoutes 返回当前已知服务的默认路由清单。
|
// DefaultServiceRoutes 返回当前已知服务的默认路由清单。
|
||||||
@@ -71,6 +85,8 @@ func DefaultServiceRoutes() []ServiceRoute {
|
|||||||
builtinServiceRoutes[ServiceNameMemory],
|
builtinServiceRoutes[ServiceNameMemory],
|
||||||
builtinServiceRoutes[ServiceNameActiveScheduler],
|
builtinServiceRoutes[ServiceNameActiveScheduler],
|
||||||
builtinServiceRoutes[ServiceNameNotification],
|
builtinServiceRoutes[ServiceNameNotification],
|
||||||
|
builtinServiceRoutes[ServiceNameTaskClassForum],
|
||||||
|
builtinServiceRoutes[ServiceNameTokenStore],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "smartflow-frontend",
|
"name": "smartmate-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smartflow-frontend",
|
"name": "smartmate-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"@vue/shared": "^3.5.0",
|
"@vue/shared": "^3.5.0",
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
"element-plus": "^2.9.0",
|
"element-plus": "^2.9.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@vue/shared": "^3.5.0",
|
"@vue/shared": "^3.5.0",
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
"element-plus": "^2.9.0",
|
"element-plus": "^2.9.0",
|
||||||
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import MainSidebar from '@/components/common/MainSidebar.vue'
|
import MainSidebar from '@/components/common/MainSidebar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const showLayout = computed(() => {
|
const showLayout = computed(() => {
|
||||||
return ['dashboard', 'assistant', 'schedule'].includes(route.name as string)
|
return ['dashboard', 'assistant', 'schedule', 'forum', 'store', 'plan-detail'].includes(route.name as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局加载进度条逻辑
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const progress = ref(0)
|
||||||
|
let progressTimer: any = null
|
||||||
|
|
||||||
|
const startLoading = () => {
|
||||||
|
isLoading.value = true
|
||||||
|
progress.value = 0
|
||||||
|
if (progressTimer) clearInterval(progressTimer)
|
||||||
|
progressTimer = setInterval(() => {
|
||||||
|
if (progress.value < 90) {
|
||||||
|
progress.value += Math.random() * 10
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishLoading = () => {
|
||||||
|
progress.value = 100
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoading.value = false
|
||||||
|
progress.value = 0
|
||||||
|
if (progressTimer) clearInterval(progressTimer)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听路由变化模拟进度条
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (to.path !== from.path) startLoading()
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
router.afterEach(() => {
|
||||||
|
finishLoading()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div v-if="isLoading" class="global-progress-bar" :style="{ width: progress + '%' }"></div>
|
||||||
<div v-if="showLayout" class="smartmate-layout">
|
<div v-if="showLayout" class="smartmate-layout">
|
||||||
<MainSidebar />
|
<MainSidebar />
|
||||||
<div class="smartmate-content">
|
<div class="smartmate-content">
|
||||||
@@ -28,6 +65,17 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-progress-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(to right, #3b82f6, #60a5fa);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
.smartmate-layout {
|
.smartmate-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
@@ -41,6 +89,32 @@ body {
|
|||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全局自定义滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox 兼容性 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(15, 23, 42, 0.08) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.smartmate-content {
|
.smartmate-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
BIN
frontend/src/assets/feature-ai.png
Normal file
BIN
frontend/src/assets/feature-ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
BIN
frontend/src/assets/feature-schedule.png
Normal file
BIN
frontend/src/assets/feature-schedule.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
BIN
frontend/src/assets/feature-tools.png
Normal file
BIN
frontend/src/assets/feature-tools.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
frontend/src/assets/hero-dashboard.png
Normal file
BIN
frontend/src/assets/hero-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -4,16 +4,18 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
interface SidebarItem {
|
interface SidebarItem {
|
||||||
key: 'home' | 'task' | 'calendar' | 'ai'
|
key: 'home' | 'task' | 'calendar' | 'ai' | 'forum' | 'store'
|
||||||
label: string
|
label: string
|
||||||
short: string
|
short: string
|
||||||
to?: '/dashboard' | '/assistant' | '/schedule'
|
to?: '/dashboard' | '/assistant' | '/schedule' | '/forum' | '/store'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
||||||
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
|
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
|
||||||
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
||||||
|
{ key: 'forum', label: '社区', short: '区', to: '/forum' },
|
||||||
|
{ key: 'store', label: '商店', short: '商', to: '/store' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -22,6 +24,8 @@ const router = useRouter()
|
|||||||
const activeSidebarKey = computed<SidebarItem['key']>(() => {
|
const activeSidebarKey = computed<SidebarItem['key']>(() => {
|
||||||
if (route.path.startsWith('/assistant')) return 'ai'
|
if (route.path.startsWith('/assistant')) return 'ai'
|
||||||
if (route.path.startsWith('/schedule')) return 'calendar'
|
if (route.path.startsWith('/schedule')) return 'calendar'
|
||||||
|
if (route.path.startsWith('/forum')) return 'forum'
|
||||||
|
if (route.path.startsWith('/store')) return 'store'
|
||||||
return 'home'
|
return 'home'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ const activeSidebarIndex = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const activeIndicatorStyle = computed(() => {
|
const activeIndicatorStyle = computed(() => {
|
||||||
|
// 每个项高度 60px + 间隔 12px = 72px
|
||||||
return {
|
return {
|
||||||
transform: `translateY(${activeSidebarIndex.value * 72}px)`
|
transform: `translateY(${activeSidebarIndex.value * 72}px)`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,6 +333,11 @@ const visibleTasks = computed(() => props.tasks)
|
|||||||
.action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); }
|
.action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); }
|
||||||
|
|
||||||
/* --- 骨架屏 --- */
|
/* --- 骨架屏 --- */
|
||||||
|
.quadrant-card__skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.quadrant-card__skeleton-item {
|
.quadrant-card__skeleton-item {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
|
|||||||
@@ -109,9 +109,13 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<transition name="grid-pop" mode="out-in">
|
|
||||||
<div v-if="loading" key="loading" class="pastel-grid">
|
<div v-if="loading" key="loading" class="pastel-grid">
|
||||||
<div v-for="n in 8" :key="n" class="skeleton-pill" />
|
<div
|
||||||
|
v-for="slot in slotBlueprint"
|
||||||
|
:key="slot.key"
|
||||||
|
class="skeleton-pill"
|
||||||
|
:class="{ 'is-pause': slot.kind === 'pause' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else key="content" class="pastel-grid">
|
<div v-else key="content" class="pastel-grid">
|
||||||
@@ -137,7 +141,6 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -187,16 +190,9 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 140px;
|
min-height: 140px;
|
||||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pastel-item:hover {
|
|
||||||
transform: scale(1.03) translateY(-4px);
|
|
||||||
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-time {
|
.item-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -266,20 +262,16 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
animation: pill-shimmer 1.5s infinite linear;
|
animation: pill-shimmer 1.5s infinite linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton-pill.is-pause {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pill-shimmer {
|
@keyframes pill-shimmer {
|
||||||
0% { opacity: 0.5; }
|
0% { opacity: 0.5; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
100% { opacity: 0.5; }
|
100% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画效果 */
|
|
||||||
.grid-pop-enter-active {
|
|
||||||
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
.grid-pop-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.pastel-grid { grid-template-columns: repeat(4, 1fr); }
|
.pastel-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import DashboardView from '@/views/DashboardView.vue'
|
|||||||
import ScheduleView from '@/views/ScheduleView.vue'
|
import ScheduleView from '@/views/ScheduleView.vue'
|
||||||
import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
|
import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
|
||||||
|
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard',
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/auth',
|
path: '/auth',
|
||||||
@@ -46,6 +49,30 @@ const router = createRouter({
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/forum',
|
||||||
|
name: 'forum',
|
||||||
|
component: () => import('@/views/ForumView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forum/:id',
|
||||||
|
name: 'plan-detail',
|
||||||
|
component: () => import('@/views/PlanDetailView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/store',
|
||||||
|
name: 'store',
|
||||||
|
component: () => import('@/views/StoreView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/debug/tool-card',
|
path: '/debug/tool-card',
|
||||||
name: 'debug-tool-card',
|
name: 'debug-tool-card',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
||||||
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
|
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
|
||||||
@@ -277,7 +277,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref="dashboardContentRef" class="dashboard-content page-shell">
|
<div ref="dashboardContentRef" class="dashboard-content page-shell">
|
||||||
<TodayTimeline class="dashboard-item-pop" :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
|
<TodayTimeline :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
|
||||||
|
|
||||||
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
|
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
|
||||||
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
|
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
|
||||||
@@ -375,15 +375,15 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; }
|
::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); }
|
::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); }
|
||||||
|
|
||||||
@keyframes dashboard-item-spring {
|
@keyframes dashboard-item-fade-in {
|
||||||
0% { opacity: 0; transform: scale(0.9) translateY(20px); }
|
0% { opacity: 0; transform: translateY(10px); }
|
||||||
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-item-pop {
|
.dashboard-item-pop {
|
||||||
animation: dashboard-item-spring 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
animation: dashboard-item-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
animation-delay: var(--anim-delay, 0s);
|
animation-delay: var(--anim-delay, 0s);
|
||||||
|
--anim-delay: 0s;
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,12 +543,11 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
|
|
||||||
/* 弹出动画覆写 */
|
/* 弹出动画覆写 */
|
||||||
:global(.dialog-fade-enter-active .premium-dialog) {
|
:global(.dialog-fade-enter-active .premium-dialog) {
|
||||||
animation: premium-dialog-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
animation: premium-dialog-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes premium-dialog-pop {
|
@keyframes premium-dialog-fade-in {
|
||||||
0% { opacity: 0; transform: scale(0.92) translateY(20px); }
|
0% { opacity: 0; transform: scale(0.98) translateY(10px); }
|
||||||
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
|
|
||||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
967
frontend/src/views/ForumView.vue
Normal file
967
frontend/src/views/ForumView.vue
Normal file
@@ -0,0 +1,967 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Connection,
|
||||||
|
ChatDotRound,
|
||||||
|
Star,
|
||||||
|
ArrowRight,
|
||||||
|
Filter,
|
||||||
|
Sort,
|
||||||
|
Check,
|
||||||
|
Delete
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
interface UserBrief {
|
||||||
|
user_id: number
|
||||||
|
nickname: string
|
||||||
|
avatar_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanSquarePost {
|
||||||
|
post_id: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
tags: string[]
|
||||||
|
author: UserBrief
|
||||||
|
template_summary: {
|
||||||
|
task_count: number
|
||||||
|
mode: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
strategy_labels: string[]
|
||||||
|
}
|
||||||
|
counters: {
|
||||||
|
like_count: number
|
||||||
|
comment_count: number
|
||||||
|
import_count: number
|
||||||
|
}
|
||||||
|
viewer_state: {
|
||||||
|
liked: boolean
|
||||||
|
imported_once: boolean
|
||||||
|
}
|
||||||
|
status: 'published'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentNode {
|
||||||
|
comment_id: number
|
||||||
|
post_id: number
|
||||||
|
parent_comment_id: number | null
|
||||||
|
content: string
|
||||||
|
status: 'visible' | 'deleted'
|
||||||
|
author: UserBrief
|
||||||
|
can_delete: boolean
|
||||||
|
created_at: string
|
||||||
|
deleted_at: string | null
|
||||||
|
children: CommentNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock 数据 ---
|
||||||
|
const mockTags = ['全部', '考研', '高数', '期末', '30天', '英语', '雅思', '自律']
|
||||||
|
const activeTag = ref('全部')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const sortBy = ref('latest')
|
||||||
|
|
||||||
|
const mockPosts = ref<PlanSquarePost[]>([
|
||||||
|
{
|
||||||
|
post_id: 10001,
|
||||||
|
title: "30 天高数强化复习计划",
|
||||||
|
summary: "适合期末前一个月快速过完重点题型。本计划涵盖了极限、导数、积分等核心考点,配合历年真题演练,助你高分过关。",
|
||||||
|
tags: ["高数", "期末", "30天"],
|
||||||
|
author: { user_id: 88, nickname: "小鹿同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" },
|
||||||
|
template_summary: {
|
||||||
|
task_count: 24,
|
||||||
|
mode: "date_range",
|
||||||
|
start_date: "2026-05-05",
|
||||||
|
end_date: "2026-06-04",
|
||||||
|
strategy_labels: ["每日推进", "错题复盘"]
|
||||||
|
},
|
||||||
|
counters: { like_count: 128, comment_count: 32, import_count: 45 },
|
||||||
|
viewer_state: { liked: false, imported_once: true },
|
||||||
|
status: "published",
|
||||||
|
created_at: "2026-05-04T20:30:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
post_id: 10002,
|
||||||
|
title: "雅思口语 7.5 分冲刺手册",
|
||||||
|
summary: "重点攻克 Part 2 和 Part 3。精选 50 个高频话题,包含地道表达和逻辑连接词,适合短期提分。",
|
||||||
|
tags: ["英语", "雅思", "口语"],
|
||||||
|
author: { user_id: 89, nickname: "杰森英语", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka" },
|
||||||
|
template_summary: {
|
||||||
|
task_count: 15,
|
||||||
|
mode: "quantity",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
strategy_labels: ["录音回听", "范文精读"]
|
||||||
|
},
|
||||||
|
counters: { like_count: 256, comment_count: 48, import_count: 89 },
|
||||||
|
viewer_state: { liked: true, imported_once: false },
|
||||||
|
status: "published",
|
||||||
|
created_at: "2026-05-03T10:15:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
post_id: 10003,
|
||||||
|
title: "程序员减脂健康餐计划",
|
||||||
|
summary: "针对久坐人群设计的营养方案。简单易做,控制热量的同时保证脑力输出。包含详细的买菜清单和烹饪步骤。",
|
||||||
|
tags: ["自律", "健康", "减脂"],
|
||||||
|
author: { user_id: 90, nickname: "代码养生家", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=James" },
|
||||||
|
template_summary: {
|
||||||
|
task_count: 21,
|
||||||
|
mode: "daily",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
strategy_labels: ["控糖", "轻断食"]
|
||||||
|
},
|
||||||
|
counters: { like_count: 64, comment_count: 12, import_count: 28 },
|
||||||
|
viewer_state: { liked: false, imported_once: false },
|
||||||
|
status: "published",
|
||||||
|
created_at: "2026-05-02T15:20:00+08:00"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockComments = ref<CommentNode[]>([
|
||||||
|
{
|
||||||
|
comment_id: 50001,
|
||||||
|
post_id: 10001,
|
||||||
|
parent_comment_id: null,
|
||||||
|
content: "这个计划很适合期末冲刺,我已经导入了,感谢分享!",
|
||||||
|
status: "visible",
|
||||||
|
author: { user_id: 91, nickname: "西瓜同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lily" },
|
||||||
|
can_delete: true,
|
||||||
|
created_at: "2026-05-04T20:40:00+08:00",
|
||||||
|
deleted_at: null,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
comment_id: 50002,
|
||||||
|
post_id: 10001,
|
||||||
|
parent_comment_id: 50001,
|
||||||
|
content: "同感,特别是错题复盘那个环节设置得很好。",
|
||||||
|
status: "visible",
|
||||||
|
author: { user_id: 92, nickname: "青柠同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Jack" },
|
||||||
|
can_delete: false,
|
||||||
|
created_at: "2026-05-04T20:42:00+08:00",
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
comment_id: 50003,
|
||||||
|
post_id: 10001,
|
||||||
|
parent_comment_id: null,
|
||||||
|
content: "博主能分享一下具体的参考书目吗?",
|
||||||
|
status: "visible",
|
||||||
|
author: { user_id: 93, nickname: "爱学习的橘子", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bella" },
|
||||||
|
can_delete: false,
|
||||||
|
created_at: "2026-05-04T21:00:00+08:00",
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// --- 状态变量 ---
|
||||||
|
const selectedPost = ref<PlanSquarePost | null>(null)
|
||||||
|
const publishDialogVisible = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const newComment = ref('')
|
||||||
|
|
||||||
|
// --- 计算属性 ---
|
||||||
|
const filteredPosts = computed(() => {
|
||||||
|
let result = [...mockPosts.value]
|
||||||
|
|
||||||
|
if (activeTag.value !== '全部') {
|
||||||
|
result = result.filter(p => p.tags.includes(activeTag.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(p => p.title.toLowerCase().includes(q) || p.summary.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.value === 'likes') {
|
||||||
|
result.sort((a, b) => b.counters.like_count - a.counters.like_count)
|
||||||
|
} else if (sortBy.value === 'imports') {
|
||||||
|
result.sort((a, b) => b.counters.import_count - a.counters.import_count)
|
||||||
|
} else {
|
||||||
|
result.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 方法 ---
|
||||||
|
function openDetails(post: PlanSquarePost) {
|
||||||
|
router.push(`/forum/${post.post_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLike(post: PlanSquarePost) {
|
||||||
|
if (post.viewer_state.liked) {
|
||||||
|
post.viewer_state.liked = false
|
||||||
|
post.counters.like_count--
|
||||||
|
ElMessage.info('已取消点赞')
|
||||||
|
} else {
|
||||||
|
post.viewer_state.liked = true
|
||||||
|
post.counters.like_count++
|
||||||
|
ElMessage.success('点赞成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(post: PlanSquarePost) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要将《${post.title}》导入到你的计划中吗?`,
|
||||||
|
'导入确认',
|
||||||
|
{ confirmButtonText: '立即导入', cancelButtonText: '取消', type: 'info' }
|
||||||
|
)
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
// 模拟 API 调用延迟
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
|
||||||
|
post.viewer_state.imported_once = true
|
||||||
|
post.counters.import_count++
|
||||||
|
|
||||||
|
ElMessage.success({
|
||||||
|
message: '导入成功!已为你创建新的任务计划。',
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
if (!newComment.value.trim()) return
|
||||||
|
|
||||||
|
const comment: CommentNode = {
|
||||||
|
comment_id: Date.now(),
|
||||||
|
post_id: selectedPost.value!.post_id,
|
||||||
|
parent_comment_id: null,
|
||||||
|
content: newComment.value,
|
||||||
|
status: 'visible',
|
||||||
|
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
|
||||||
|
can_delete: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
|
||||||
|
mockComments.value.unshift(comment)
|
||||||
|
newComment.value = ''
|
||||||
|
ElMessage.success('评论发表成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteComment(comment: CommentNode) {
|
||||||
|
comment.status = 'deleted'
|
||||||
|
comment.content = '该评论已删除'
|
||||||
|
ElMessage.info('评论已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="forum-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="forum-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>计划广场</h1>
|
||||||
|
<p>发现并分享优质的任务计划模板</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="search-box">
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索计划、关键词..."
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" :icon="Plus" round @click="publishDialogVisible = true">
|
||||||
|
发布计划
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filters & Tabs -->
|
||||||
|
<div class="forum-filters">
|
||||||
|
<div class="tags-scroller">
|
||||||
|
<button
|
||||||
|
v-for="tag in mockTags"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-chip"
|
||||||
|
:class="{ active: activeTag === tag }"
|
||||||
|
@click="activeTag = tag"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sort-dropdown">
|
||||||
|
<el-select v-model="sortBy" placeholder="排序方式" style="width: 120px">
|
||||||
|
<el-option label="最新发布" value="latest" />
|
||||||
|
<el-option label="最多点赞" value="likes" />
|
||||||
|
<el-option label="最多导入" value="imports" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Grid -->
|
||||||
|
<main class="forum-grid">
|
||||||
|
<transition-group name="post-list">
|
||||||
|
<div
|
||||||
|
v-for="post in filteredPosts"
|
||||||
|
:key="post.post_id"
|
||||||
|
class="post-card"
|
||||||
|
@click="openDetails(post)"
|
||||||
|
>
|
||||||
|
<div class="post-card__header">
|
||||||
|
<h3 class="post-title">{{ post.title }}</h3>
|
||||||
|
<div class="post-tags">
|
||||||
|
<span v-for="tag in post.tags.slice(0, 3)" :key="tag" class="small-tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="post-summary">{{ post.summary }}</p>
|
||||||
|
|
||||||
|
<div class="post-card__footer">
|
||||||
|
<div class="author-info">
|
||||||
|
<img :src="post.author.avatar_url" class="author-avatar" />
|
||||||
|
<span class="author-name">{{ post.author.nickname }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-stats">
|
||||||
|
<span class="stat-item" :class="{ active: post.viewer_state.liked }" @click.stop="handleLike(post)">
|
||||||
|
<el-icon><Star /></el-icon> {{ post.counters.like_count }}
|
||||||
|
</span>
|
||||||
|
<span class="stat-item">
|
||||||
|
<el-icon><ChatDotRound /></el-icon> {{ post.counters.comment_count }}
|
||||||
|
</span>
|
||||||
|
<span class="stat-item" :class="{ imported: post.viewer_state.imported_once }" @click.stop="handleImport(post)">
|
||||||
|
<el-icon><Connection /></el-icon> {{ post.counters.import_count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="filteredPosts.length === 0" class="empty-state">
|
||||||
|
<el-empty description="暂无符合条件的计划" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Publish Dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="publishDialogVisible"
|
||||||
|
title="发布新计划"
|
||||||
|
width="500px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="选择现有计划模板" required>
|
||||||
|
<el-select placeholder="请选择你的 TaskClass" style="width: 100%">
|
||||||
|
<el-option label="我的 2026 高数笔记" value="1" />
|
||||||
|
<el-option label="每日算法 100 题" value="2" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题" required>
|
||||||
|
<el-input placeholder="给你的计划起个吸引人的名字 (4-40字)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="简介">
|
||||||
|
<el-input type="textarea" :rows="3" placeholder="详细描述一下这个计划的适用人群和优势..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签">
|
||||||
|
<el-select multiple filterable allow-create default-first-option placeholder="添加标签 (最多5个)">
|
||||||
|
<el-option v-for="t in mockTags.slice(1)" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="publishDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="publishDialogVisible = false; ElMessage.success('发布成功!审核通过后将展示在广场。')">
|
||||||
|
确认发布
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forum-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.forum-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px 40px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #3b82f6 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left p {
|
||||||
|
color: #64748b;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.forum-filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-scroller {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-scroller::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog Styling */
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
border-radius: 24px !important;
|
||||||
|
padding: 32px !important;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__header) {
|
||||||
|
padding: 0 0 24px 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__title) {
|
||||||
|
font-size: 24px !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn {
|
||||||
|
padding: 12px 32px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post Grid */
|
||||||
|
.forum-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.04);
|
||||||
|
border-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global Form Overrides (Flat & Clean) --- */
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
color: #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner:focus) {
|
||||||
|
background-color: #fff !important;
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-textarea__inner) {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-textarea__inner:focus) {
|
||||||
|
background-color: #fff !important;
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select .el-input__inner) {
|
||||||
|
background-color: #fff !important;
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed duplicate .forum-container and merged .forum-header */
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input :deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
padding: 4px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag {
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tag.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-summary {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card__footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item.active {
|
||||||
|
color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item.active .el-icon {
|
||||||
|
fill: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item.imported {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer Styles */
|
||||||
|
.drawer-content {
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-meta {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-meta .name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-meta .time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary {
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items li {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-items li.more {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments Section */
|
||||||
|
.comment-input-box {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar.small {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-user {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content.deleted {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-children {
|
||||||
|
margin-left: 44px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 2px solid #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.post-list-enter-active,
|
||||||
|
.post-list-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
.post-list-enter-from,
|
||||||
|
.post-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
610
frontend/src/views/HomeView.vue
Normal file
610
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
// 导入资产
|
||||||
|
import heroImg from '@/assets/hero-dashboard.png'
|
||||||
|
import scheduleImg from '@/assets/feature-schedule.png'
|
||||||
|
import aiImg from '@/assets/feature-ai.png'
|
||||||
|
import toolsImg from '@/assets/feature-tools.png'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const ctaLink = computed(() => authStore.isAuthenticated ? '/dashboard' : '/auth')
|
||||||
|
const ctaText = computed(() => authStore.isAuthenticated ? '进入工作台' : '开始使用')
|
||||||
|
|
||||||
|
const handleCta = () => {
|
||||||
|
router.push(ctaLink.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: 'AI 随口记',
|
||||||
|
desc: '一句话记录作业、DDL、生活小事。AI 自动提取关键时间与内容。',
|
||||||
|
img: toolsImg
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '四象限任务池',
|
||||||
|
desc: '系统化承接日常待办,清晰标注优先级,告别混乱。',
|
||||||
|
img: heroImg // 复用首页截图展示任务池部分,或在此处根据需要调整
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '课表智能编排',
|
||||||
|
desc: '不仅仅是列出任务,而是智能寻找课间或空闲时段,把任务真正排进日程。',
|
||||||
|
img: scheduleImg
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '长期记忆',
|
||||||
|
desc: 'AI 会记住你的课程节奏、个人习惯、偏好和长期目标,越用越懂你。',
|
||||||
|
img: aiImg
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home-container">
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="home-nav">
|
||||||
|
<div class="nav-content">
|
||||||
|
<div class="brand">时伴 SmartMate</div>
|
||||||
|
<button class="nav-cta" @click="handleCta">{{ ctaText }}</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<header class="hero-section">
|
||||||
|
<div class="hero-content">
|
||||||
|
<p class="hero-eyebrow">成长型 AI 排程伙伴</p>
|
||||||
|
<h1 class="hero-title gradient-text">越用越懂你的<br/>成长型 AI 排程伙伴</h1>
|
||||||
|
<p class="hero-desc">
|
||||||
|
不仅仅是待办清单。我们将课表、DDL、个人习惯与长期记忆深度融合,<br/>
|
||||||
|
通过 AI 为你打造一张真正“能落地”的日程表。
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button class="primary-cta" @click="handleCta">{{ ctaText }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual">
|
||||||
|
<img :src="heroImg" alt="时伴工作台" class="main-screenshot" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Trust/Stats Section -->
|
||||||
|
<section class="trust-section">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">12,000+</span>
|
||||||
|
<span class="stat-label">活跃大学生用户</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">850,000+</span>
|
||||||
|
<span class="stat-label">AI 编排日程任务</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">98.2%</span>
|
||||||
|
<span class="stat-label">计划执行达成率</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Problem vs Solution -->
|
||||||
|
<section class="comparison-section">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="comparison-header">
|
||||||
|
<h2 class="section-title">告别碎片化与混乱</h2>
|
||||||
|
<p class="section-subtitle">为了解决大学生真实的计划难题而生</p>
|
||||||
|
</div>
|
||||||
|
<div class="comparison-grid">
|
||||||
|
<div class="comparison-column problem">
|
||||||
|
<h4>传统工具的困境</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>日历太“空”:</strong>
|
||||||
|
只有格子,不知道课间 20 分钟能干什么。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>清单太“满”:</strong>
|
||||||
|
列了一堆 DDL,却不知道该从哪一个开始。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>AI 太“乱”:</strong>
|
||||||
|
直接改乱日程,让人感到失去掌控。
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="comparison-column solution">
|
||||||
|
<h4>时伴的解决之道</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>感知课表缝隙:</strong>
|
||||||
|
自动识别课间与空课,填入最适合的任务。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>智能排序优先级:</strong>
|
||||||
|
基于 DDL 与个人状态,自动给出当日最优解。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>预览确认机制:</strong>
|
||||||
|
AI 负责提案,你负责最终决定,完美平衡。
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Core Loop -->
|
||||||
|
<section class="loop-section">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="loop-header">
|
||||||
|
<h2 class="section-title">极简操作闭环</h2>
|
||||||
|
</div>
|
||||||
|
<div class="loop-grid">
|
||||||
|
<div class="loop-item">
|
||||||
|
<div class="loop-icon-box">💬</div>
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<strong>随口说需求</strong>
|
||||||
|
</div>
|
||||||
|
<div class="loop-arrow">→</div>
|
||||||
|
<div class="loop-item">
|
||||||
|
<div class="loop-icon-box">🧠</div>
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<strong>AI 识别任务</strong>
|
||||||
|
</div>
|
||||||
|
<div class="loop-arrow">→</div>
|
||||||
|
<div class="loop-item">
|
||||||
|
<div class="loop-icon-box">🗓️</div>
|
||||||
|
<span class="step-num">03</span>
|
||||||
|
<strong>基于课表编排</strong>
|
||||||
|
</div>
|
||||||
|
<div class="loop-arrow">→</div>
|
||||||
|
<div class="loop-item">
|
||||||
|
<div class="loop-icon-box">👀</div>
|
||||||
|
<span class="step-num">04</span>
|
||||||
|
<strong>预览微调</strong>
|
||||||
|
</div>
|
||||||
|
<div class="loop-arrow">→</div>
|
||||||
|
<div class="loop-item">
|
||||||
|
<div class="loop-icon-box">✅</div>
|
||||||
|
<span class="step-num">05</span>
|
||||||
|
<strong>确认应用</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Key Capabilities -->
|
||||||
|
<section class="features-section">
|
||||||
|
<div class="section-container">
|
||||||
|
<h2 class="section-title">核心能力</h2>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div v-for="feature in features" :key="feature.title" class="feature-item">
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>{{ feature.title }}</h3>
|
||||||
|
<p>{{ feature.desc }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-media">
|
||||||
|
<img :src="feature.img" :alt="feature.title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Final CTA -->
|
||||||
|
<section class="final-cta-section">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="final-content">
|
||||||
|
<h2 class="gradient-text">让计划不再停在待办列表里</h2>
|
||||||
|
<p>加入数万名大学生,开启高效的智能校园生活。</p>
|
||||||
|
<button class="primary-cta" @click="handleCta">{{ ctaText }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="home-footer">
|
||||||
|
<div class="footer-content">
|
||||||
|
<p>© 2026 时伴 SmartMate · 大学生的智能排程伙伴</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1e293b;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utils */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #2563eb 50%, #3b82f6 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.home-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 72px;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2563eb;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta:hover {
|
||||||
|
background: #0f172a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero-section {
|
||||||
|
padding: 120px 0 80px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-eyebrow {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-desc {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta {
|
||||||
|
padding: 18px 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 15px 30px -5px rgba(37, 99, 235, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-screenshot {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 40px 80px -12px rgba(15, 23, 42, 0.18), 0 20px 40px -20px rgba(15, 23, 42, 0.12);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.trust-section {
|
||||||
|
padding: 60px 0;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comparison */
|
||||||
|
.comparison-section {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: #fcfdfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column {
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column h4 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column.problem {
|
||||||
|
border-left: 4px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column.solution {
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column li {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column li strong {
|
||||||
|
display: block;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loop */
|
||||||
|
.loop-section {
|
||||||
|
padding: 100px 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-icon-box {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-num {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-item strong {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-arrow {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.features-section {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.5fr;
|
||||||
|
gap: 80px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:nth-child(even) {
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:nth-child(even) .feature-text {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text h3 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text p {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-media img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.08);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final CTA */
|
||||||
|
.final-cta-section {
|
||||||
|
padding: 160px 0;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-content h2 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 850;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-content p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.home-footer {
|
||||||
|
padding: 60px 0;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.hero-title { font-size: 56px; }
|
||||||
|
.feature-item { grid-template-columns: 1fr !important; gap: 40px; text-align: center; }
|
||||||
|
.feature-item .feature-text { order: 1 !important; }
|
||||||
|
.feature-media { order: 2; }
|
||||||
|
.comparison-grid { grid-template-columns: 1fr; gap: 32px; }
|
||||||
|
.loop-grid { flex-wrap: wrap; justify-content: center; gap: 32px; }
|
||||||
|
.loop-arrow { display: none; }
|
||||||
|
.stats-grid { flex-wrap: wrap; gap: 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero-title { font-size: 40px; }
|
||||||
|
.hero-desc { font-size: 18px; }
|
||||||
|
.section-title { font-size: 32px; }
|
||||||
|
.final-content h2 { font-size: 36px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
868
frontend/src/views/PlanDetailView.vue
Normal file
868
frontend/src/views/PlanDetailView.vue
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Connection,
|
||||||
|
ChatDotRound,
|
||||||
|
Star,
|
||||||
|
Filter,
|
||||||
|
Check
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
interface UserBrief {
|
||||||
|
user_id: number
|
||||||
|
nickname: string
|
||||||
|
avatar_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanSquarePost {
|
||||||
|
post_id: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
tags: string[]
|
||||||
|
author: UserBrief
|
||||||
|
template_summary: {
|
||||||
|
task_count: number
|
||||||
|
mode: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
strategy_labels: string[]
|
||||||
|
}
|
||||||
|
counters: {
|
||||||
|
like_count: number
|
||||||
|
comment_count: number
|
||||||
|
import_count: number
|
||||||
|
}
|
||||||
|
viewer_state: {
|
||||||
|
liked: boolean
|
||||||
|
imported_once: boolean
|
||||||
|
}
|
||||||
|
status: 'published'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentNode {
|
||||||
|
comment_id: number
|
||||||
|
post_id: number
|
||||||
|
parent_comment_id: number | null
|
||||||
|
content: string
|
||||||
|
status: 'visible' | 'deleted'
|
||||||
|
author: UserBrief
|
||||||
|
can_delete: boolean
|
||||||
|
created_at: string
|
||||||
|
deleted_at: string | null
|
||||||
|
children: CommentNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const selectedPost = ref<PlanSquarePost | null>(null)
|
||||||
|
const mockComments = ref<CommentNode[]>([])
|
||||||
|
const newComment = ref('')
|
||||||
|
const replyingToId = ref<number | null>(null)
|
||||||
|
const replyText = ref('')
|
||||||
|
|
||||||
|
// --- 初始化 Mock 数据 ---
|
||||||
|
onMounted(async () => {
|
||||||
|
// 模拟加载延迟
|
||||||
|
await new Promise(r => setTimeout(r, 600))
|
||||||
|
|
||||||
|
const postId = Number(route.params.id)
|
||||||
|
|
||||||
|
// 模拟从后端获取详情
|
||||||
|
selectedPost.value = {
|
||||||
|
post_id: postId,
|
||||||
|
title: postId === 10001 ? "30 天高数强化复习计划 (深度进阶版)" : "雅思口语 7.5 分冲刺手册",
|
||||||
|
summary: `这是一份经过验证的高质量计划,帮助你快速达成目标。
|
||||||
|
|
||||||
|
本计划不仅涵盖了基础知识点,还深入探讨了高数中最为棘手的证明题与综合题型。
|
||||||
|
在接下来的30天里,我们将通过系统的拆解,将复杂的微积分问题简化为可执行的每日任务。
|
||||||
|
无论你是为了考研冲刺,还是期末突击,这份计划都将是你最坚实的后盾。
|
||||||
|
|
||||||
|
我们将重点关注以下几个模块:
|
||||||
|
1. 函数、极限与连续的深度理解
|
||||||
|
2. 一元函数微分学的应用技巧
|
||||||
|
3. 积分学的各种变换与计算模型
|
||||||
|
4. 空间解析几何与向量代数
|
||||||
|
5. 多元函数微分与积分学
|
||||||
|
6. 常微分方程的特殊解法
|
||||||
|
|
||||||
|
每个模块都配备了精选的例题和课后练习,确保你能学以致用。
|
||||||
|
请务必严格按照计划执行,不要遗漏任何一个复盘环节。
|
||||||
|
祝你在数学的海洋中乘风破浪,取得优异成绩!`,
|
||||||
|
tags: ["高数", "复习", "冲刺", "考研", "干货"],
|
||||||
|
author: { user_id: 88, nickname: "小鹿同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" },
|
||||||
|
template_summary: {
|
||||||
|
task_count: 30,
|
||||||
|
mode: "date_range",
|
||||||
|
start_date: "2026-05-05",
|
||||||
|
end_date: "2026-06-04",
|
||||||
|
strategy_labels: ["每日推进", "错题复盘", "阶段测试", "脑图总结"]
|
||||||
|
},
|
||||||
|
counters: { like_count: 128, comment_count: 32, import_count: 45 },
|
||||||
|
viewer_state: { liked: false, imported_once: false },
|
||||||
|
status: "published",
|
||||||
|
created_at: "2026-05-04T20:30:00+08:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
mockComments.value = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
comment_id: 50000 + i,
|
||||||
|
post_id: postId,
|
||||||
|
parent_comment_id: null,
|
||||||
|
content: `这是第 ${i + 1} 条测试评论。这份计划真的太详细了,特别是关于${['微积分', '中值定理', '泰勒公式', '多重积分'][i % 4]}的部分,讲得非常透彻。`,
|
||||||
|
status: "visible",
|
||||||
|
author: {
|
||||||
|
user_id: 100 + i,
|
||||||
|
nickname: `学霸${i + 1}号`,
|
||||||
|
avatar_url: `https://api.dicebear.com/7.x/avataaars/svg?seed=User${i}`
|
||||||
|
},
|
||||||
|
can_delete: true, // 全部允许删除以便测试
|
||||||
|
created_at: "2026-05-04T20:40:00+08:00",
|
||||||
|
deleted_at: null,
|
||||||
|
children: i === 0 ? [
|
||||||
|
{
|
||||||
|
comment_id: 60001,
|
||||||
|
post_id: postId,
|
||||||
|
parent_comment_id: 50000,
|
||||||
|
content: "我也这么觉得,博主太用心了!",
|
||||||
|
status: "visible",
|
||||||
|
author: { user_id: 201, nickname: "回复人A", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=A" },
|
||||||
|
can_delete: true,
|
||||||
|
created_at: "2026-05-04T21:00:00+08:00",
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
] : []
|
||||||
|
}))
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/forum')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLike() {
|
||||||
|
if (!selectedPost.value) return
|
||||||
|
if (selectedPost.value.viewer_state.liked) {
|
||||||
|
selectedPost.value.viewer_state.liked = false
|
||||||
|
selectedPost.value.counters.like_count--
|
||||||
|
} else {
|
||||||
|
selectedPost.value.viewer_state.liked = true
|
||||||
|
selectedPost.value.counters.like_count++
|
||||||
|
ElMessage.success('点赞成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!selectedPost.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
await new Promise(r => setTimeout(r, 800))
|
||||||
|
selectedPost.value.viewer_state.imported_once = true
|
||||||
|
selectedPost.value.counters.import_count++
|
||||||
|
ElMessage.success('导入成功')
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
if (!newComment.value.trim()) return
|
||||||
|
const comment: CommentNode = {
|
||||||
|
comment_id: Date.now(),
|
||||||
|
post_id: selectedPost.value!.post_id,
|
||||||
|
parent_comment_id: null,
|
||||||
|
content: newComment.value,
|
||||||
|
status: 'visible',
|
||||||
|
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
|
||||||
|
can_delete: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
mockComments.value.unshift(comment)
|
||||||
|
newComment.value = ''
|
||||||
|
ElMessage.success('发表成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(commentId: number) {
|
||||||
|
replyingToId.value = commentId
|
||||||
|
replyText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReply(parentComment: CommentNode) {
|
||||||
|
if (!replyText.value.trim()) return
|
||||||
|
const reply: CommentNode = {
|
||||||
|
comment_id: Date.now(),
|
||||||
|
post_id: selectedPost.value!.post_id,
|
||||||
|
parent_comment_id: parentComment.comment_id,
|
||||||
|
content: replyText.value,
|
||||||
|
status: 'visible',
|
||||||
|
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
|
||||||
|
can_delete: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
deleted_at: null,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
parentComment.children.push(reply)
|
||||||
|
replyingToId.value = null
|
||||||
|
replyText.value = ''
|
||||||
|
ElMessage.success('回复成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteComment(commentId: number) {
|
||||||
|
// 递归删除逻辑
|
||||||
|
const removeRecursive = (list: CommentNode[], id: number): boolean => {
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
if (list[i].comment_id === id) {
|
||||||
|
list.splice(i, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (list[i].children && removeRecursive(list[i].children, id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeRecursive(mockComments.value, commentId)) {
|
||||||
|
ElMessage.success('评论已删除')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="detail-page-container" v-loading="isLoading">
|
||||||
|
<div v-if="selectedPost" class="detail-wrapper">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<nav class="detail-nav">
|
||||||
|
<el-button :icon="ArrowLeft" circle @click="goBack" />
|
||||||
|
<span class="nav-title">计划详情</span>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
round
|
||||||
|
:icon="selectedPost.viewer_state.imported_once ? Check : Connection"
|
||||||
|
@click="handleImport"
|
||||||
|
>
|
||||||
|
{{ selectedPost.viewer_state.imported_once ? '已导入' : '立即导入' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="detail-main-layout">
|
||||||
|
<aside class="detail-sidebar">
|
||||||
|
<div class="author-card">
|
||||||
|
<img :src="selectedPost.author.avatar_url" class="author-avatar" />
|
||||||
|
<div class="author-name">{{ selectedPost.author.nickname }}</div>
|
||||||
|
<div class="publish-time">发布于 {{ formatDate(selectedPost.created_at) }}</div>
|
||||||
|
<div class="author-stats">
|
||||||
|
<div class="stat-box">
|
||||||
|
<span class="num">{{ selectedPost.counters.like_count }}</span>
|
||||||
|
<span class="lbl">获赞</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<span class="num">{{ selectedPost.counters.import_count }}</span>
|
||||||
|
<span class="lbl">导入</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-summary-card">
|
||||||
|
<h4><el-icon><Filter /></el-icon> 计划概览</h4>
|
||||||
|
<div class="summary-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">任务总数</span>
|
||||||
|
<span class="value">{{ selectedPost.template_summary.task_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">排程模式</span>
|
||||||
|
<span class="value">{{ selectedPost.template_summary.mode === 'date_range' ? '日期范围' : '固定天数' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="strategy-list">
|
||||||
|
<span v-for="tag in selectedPost.template_summary.strategy_labels" :key="tag" class="strategy-tag">
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tasks-overview-card">
|
||||||
|
<h4><el-icon><Filter /></el-icon> 任务预览</h4>
|
||||||
|
<div class="items-list">
|
||||||
|
<div v-for="i in 10" :key="i" class="item-node">
|
||||||
|
<div class="node-idx">{{ String(i).padStart(2, '0') }}</div>
|
||||||
|
<div class="node-content">
|
||||||
|
{{ [
|
||||||
|
'复习极限与连续的基础概念,完成课后习题。',
|
||||||
|
'导数与微分的中值定理深度解析,配合真题演练。',
|
||||||
|
'泰勒展开式及其在近似计算中的应用技巧。',
|
||||||
|
'不定积分的换元法与分部积分法专项突破。',
|
||||||
|
'定积分的几何意义与物理应用案例分析。',
|
||||||
|
'多元函数偏导数与全微分的计算模型。',
|
||||||
|
'二重积分在极坐标下的变换与计算方法。',
|
||||||
|
'常微分方程的一阶线性方程求解步骤。',
|
||||||
|
'向量代数与空间解析几何的综合练习。',
|
||||||
|
'全书重点难点回顾与思维导图梳理。'
|
||||||
|
][(i-1) % 10] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-node more">
|
||||||
|
... 更多 {{ selectedPost.template_summary.task_count - 10 }} 个任务项 ...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="detail-content-area">
|
||||||
|
<section class="content-header">
|
||||||
|
<h1 class="plan-title">{{ selectedPost.title }}</h1>
|
||||||
|
<div class="plan-tags">
|
||||||
|
<el-tag v-for="tag in selectedPost.tags" :key="tag" class="mx-1" effect="plain">{{ tag }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="plan-description">{{ selectedPost.summary }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="comments-section">
|
||||||
|
<h3><el-icon><ChatDotRound /></el-icon> 互动区 ({{ selectedPost.counters.comment_count }})</h3>
|
||||||
|
|
||||||
|
<div class="comment-post-box">
|
||||||
|
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" class="current-user-avatar" />
|
||||||
|
<div class="input-container">
|
||||||
|
<el-input
|
||||||
|
v-model="newComment"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="写下你的想法,与大家交流..."
|
||||||
|
resize="none"
|
||||||
|
class="premium-input"
|
||||||
|
/>
|
||||||
|
<div class="post-actions">
|
||||||
|
<el-button type="primary" round size="default" @click="submitComment">发布评论</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-list">
|
||||||
|
<div v-for="comment in mockComments" :key="comment.comment_id" class="comment-item">
|
||||||
|
<img :src="comment.author.avatar_url" class="c-avatar" />
|
||||||
|
<div class="c-body">
|
||||||
|
<div class="c-user">
|
||||||
|
{{ comment.author.nickname }}
|
||||||
|
<span class="c-time">{{ formatDate(comment.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="c-content">{{ comment.content }}</div>
|
||||||
|
<div class="c-actions">
|
||||||
|
<span class="btn" @click="startReply(comment.comment_id)">回复</span>
|
||||||
|
<span v-if="comment.can_delete" class="btn del" @click="deleteComment(comment.comment_id)">删除</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Input -->
|
||||||
|
<transition name="el-zoom-in-top">
|
||||||
|
<div v-if="replyingToId === comment.comment_id" class="reply-input-wrapper">
|
||||||
|
<el-input
|
||||||
|
v-model="replyText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="1"
|
||||||
|
auto-grow
|
||||||
|
placeholder="写下你的回复..."
|
||||||
|
class="reply-field"
|
||||||
|
@keyup.enter.ctrl="submitReply(comment)"
|
||||||
|
/>
|
||||||
|
<div class="reply-btns">
|
||||||
|
<el-button size="small" link @click="replyingToId = null">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" round @click="submitReply(comment)">提交回复</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Child Comments -->
|
||||||
|
<div v-if="comment.children.length > 0" class="comment-children">
|
||||||
|
<div v-for="child in comment.children" :key="child.comment_id" class="comment-item child">
|
||||||
|
<img :src="child.author.avatar_url" class="c-avatar small" />
|
||||||
|
<div class="c-body">
|
||||||
|
<div class="c-user">
|
||||||
|
{{ child.author.nickname }}
|
||||||
|
<span class="c-time">{{ formatDate(child.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="c-content">{{ child.content }}</div>
|
||||||
|
<div class="c-actions">
|
||||||
|
<span class="btn" @click="startReply(child.comment_id)">回复</span>
|
||||||
|
<span v-if="child.can_delete" class="btn del" @click="deleteComment(child.comment_id)">删除</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Input for child -->
|
||||||
|
<transition name="el-zoom-in-top">
|
||||||
|
<div v-if="replyingToId === child.comment_id" class="reply-input-wrapper">
|
||||||
|
<el-input
|
||||||
|
v-model="replyText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="1"
|
||||||
|
placeholder="写下你的回复..."
|
||||||
|
class="reply-field"
|
||||||
|
@keyup.enter.ctrl="submitReply(child)"
|
||||||
|
/>
|
||||||
|
<div class="reply-btns">
|
||||||
|
<el-button size="small" link @click="replyingToId = null">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" round @click="submitReply(child)">提交回复</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-page-container {
|
||||||
|
height: 100%;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* 彻底禁止整页滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff; /* 独立滚动后不再需要毛玻璃,纯白更稳重 */
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1e293b;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-main-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch; /* 占满高度 */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0; /* 关键:允许 flex 子项缩小 */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.detail-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px; /* 为侧边栏滚动留出空间 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏侧边栏总滚动条,仅内部卡片滚动或整体滚动 */
|
||||||
|
.detail-sidebar::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 3px solid #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-stats {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box .num {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box .lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-summary-card, .tasks-overview-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview-card h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview-card .items-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview-card .items-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview-card .items-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-summary-card h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row .label { color: #64748b; }
|
||||||
|
.info-row .value { font-weight: 600; color: #1e293b; }
|
||||||
|
|
||||||
|
.strategy-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.detail-content-area {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 40px 60px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content-area::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content-area::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content-area::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-tags { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.plan-description {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #475569;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body h3, .comments-section h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-node {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-idx {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-content {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-node.more {
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
.comment-post-box {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-post-box:focus-within {
|
||||||
|
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.08);
|
||||||
|
border-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-input :deep(.el-textarea__inner) {
|
||||||
|
border: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1e293b;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-input :deep(.el-textarea__inner:focus) {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-body { flex: 1; }
|
||||||
|
|
||||||
|
.c-user {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-time {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-content {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { color: #3b82f6; }
|
||||||
|
.btn.del:hover { color: #ef4444; }
|
||||||
|
|
||||||
|
.reply-input-wrapper {
|
||||||
|
margin: 12px 0 20px 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-field :deep(.el-textarea__inner) {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-btns {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-children {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 2px solid #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-avatar.small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
672
frontend/src/views/StoreView.vue
Normal file
672
frontend/src/views/StoreView.vue
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Coin,
|
||||||
|
ShoppingCart,
|
||||||
|
Timer,
|
||||||
|
TrendCharts,
|
||||||
|
Wallet,
|
||||||
|
Check,
|
||||||
|
Refresh,
|
||||||
|
InfoFilled
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
interface CreditSummary {
|
||||||
|
recorded_credit_total: number
|
||||||
|
applied_credit_total: number
|
||||||
|
pending_apply_credit_total: number
|
||||||
|
valid_until: string | null // 有效期至
|
||||||
|
quota_sync_status: 'not_connected' | 'partial' | 'synced'
|
||||||
|
tip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditProduct {
|
||||||
|
product_id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
credit_amount: number
|
||||||
|
price_cent: number
|
||||||
|
price_text: string
|
||||||
|
currency: 'CNY'
|
||||||
|
badge: string
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditGrant {
|
||||||
|
grant_id: number
|
||||||
|
source_label: string
|
||||||
|
amount: number
|
||||||
|
status: 'recorded' | 'applied' | 'skipped' | 'failed'
|
||||||
|
description: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock 数据 ---
|
||||||
|
const summary = ref<CreditSummary>({
|
||||||
|
recorded_credit_total: 120,
|
||||||
|
applied_credit_total: 0,
|
||||||
|
pending_apply_credit_total: 120,
|
||||||
|
valid_until: "2026-06-05", // 默认显示一个月后
|
||||||
|
quota_sync_status: 'not_connected',
|
||||||
|
tip: '当前为 Credit 获取记录,后续会切换到 user/auth 权威额度。'
|
||||||
|
})
|
||||||
|
|
||||||
|
const products = ref<CreditProduct[]>([
|
||||||
|
{
|
||||||
|
product_id: 0,
|
||||||
|
name: "Free",
|
||||||
|
description: "每日免费发放,适合基础功能体验。",
|
||||||
|
credit_amount: 100,
|
||||||
|
price_cent: 0,
|
||||||
|
price_text: "免费",
|
||||||
|
currency: "CNY",
|
||||||
|
badge: "每日",
|
||||||
|
status: "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_id: 1,
|
||||||
|
name: "Starter",
|
||||||
|
description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
|
||||||
|
credit_amount: 1000,
|
||||||
|
price_cent: 990,
|
||||||
|
price_text: "¥9.9",
|
||||||
|
currency: "CNY",
|
||||||
|
badge: "入门",
|
||||||
|
status: "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_id: 2,
|
||||||
|
name: "Lite",
|
||||||
|
description: "经济型套餐,有效期 1 个月。适合日常轻度规划。",
|
||||||
|
credit_amount: 3000,
|
||||||
|
price_cent: 1990,
|
||||||
|
price_text: "¥19.9",
|
||||||
|
currency: "CNY",
|
||||||
|
badge: "经济",
|
||||||
|
status: "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_id: 3,
|
||||||
|
name: "Pro",
|
||||||
|
description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。",
|
||||||
|
credit_amount: 10000,
|
||||||
|
price_cent: 3990,
|
||||||
|
price_text: "¥39.9",
|
||||||
|
currency: "CNY",
|
||||||
|
badge: "Most Popular",
|
||||||
|
status: "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_id: 4,
|
||||||
|
name: "Max",
|
||||||
|
description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。",
|
||||||
|
credit_amount: 40000,
|
||||||
|
price_cent: 9990,
|
||||||
|
price_text: "¥99.9",
|
||||||
|
currency: "CNY",
|
||||||
|
badge: "旗舰",
|
||||||
|
status: "active"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const grants = ref<CreditGrant[]>([
|
||||||
|
{
|
||||||
|
grant_id: 90002,
|
||||||
|
source_label: "计划被点赞",
|
||||||
|
amount: 1,
|
||||||
|
status: "recorded",
|
||||||
|
description: "你的计划《30 天高数强化复习计划》获得点赞",
|
||||||
|
created_at: "2026-05-04T21:05:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
grant_id: 90001,
|
||||||
|
source_label: "购买 Credit 包",
|
||||||
|
amount: 1000,
|
||||||
|
status: "recorded",
|
||||||
|
description: "购买 Starter Credit 包",
|
||||||
|
created_at: "2026-05-04T21:00:01+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
grant_id: 90000,
|
||||||
|
source_label: "导入奖励",
|
||||||
|
amount: 2,
|
||||||
|
status: "recorded",
|
||||||
|
description: "成功导入《雅思口语冲刺手册》",
|
||||||
|
created_at: "2026-05-04T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// --- 状态变量 ---
|
||||||
|
const isBuying = ref(false)
|
||||||
|
const historyLoading = ref(false)
|
||||||
|
|
||||||
|
// --- 方法 ---
|
||||||
|
async function handlePurchase(product: CreditProduct) {
|
||||||
|
try {
|
||||||
|
const isFree = product.price_cent === 0
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
isFree
|
||||||
|
? `确定要领取每日免费的 ${product.credit_amount} Credit 吗?`
|
||||||
|
: `确定要花费 ${product.price_text} 购买 ${product.credit_amount} Credit 吗?\n有效期一个月,续费可累加。`,
|
||||||
|
isFree ? '领取确认' : '支付确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: isFree ? '立即领取' : '确认支付',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'info',
|
||||||
|
center: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
isBuying.value = true
|
||||||
|
// 模拟订单创建与支付流程
|
||||||
|
await new Promise(r => setTimeout(r, 1500))
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
summary.value.recorded_credit_total += product.credit_amount
|
||||||
|
summary.value.pending_apply_credit_total += product.credit_amount
|
||||||
|
|
||||||
|
const newGrant: CreditGrant = {
|
||||||
|
grant_id: Date.now(),
|
||||||
|
source_label: isFree ? "每日领取" : "购买 Credit 包",
|
||||||
|
amount: product.credit_amount,
|
||||||
|
status: "recorded",
|
||||||
|
description: isFree ? "每日免费额度领取" : `购买${product.name}`,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
grants.value.unshift(newGrant)
|
||||||
|
|
||||||
|
ElMessage({
|
||||||
|
type: 'success',
|
||||||
|
message: isFree ? `领取成功!已获得 ${product.credit_amount} Credit` : `支付成功!已充值 ${product.credit_amount} Credit`,
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
} finally {
|
||||||
|
isBuying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshGrants() {
|
||||||
|
historyLoading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
historyLoading.value = false
|
||||||
|
ElMessage.success('记录已更新')
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="store-container" v-loading="isBuying">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="store-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Credit 商店</h1>
|
||||||
|
<p>获取更多 Credit,解锁 AI 增强规划能力</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-button :icon="Refresh" circle @click="refreshGrants" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Balance Summary Card -->
|
||||||
|
<div class="balance-card">
|
||||||
|
<div class="balance-content">
|
||||||
|
<div class="balance-main">
|
||||||
|
<div class="label">累计获取 Credit</div>
|
||||||
|
<div class="value">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
<span>{{ summary.recorded_credit_total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-details">
|
||||||
|
<div class="detail-item" v-if="summary.valid_until">
|
||||||
|
<el-icon><Timer /></el-icon>
|
||||||
|
<span class="text">有效期至: {{ summary.valid_until }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="dot warning"></span>
|
||||||
|
<span class="text">待同步: {{ summary.pending_apply_credit_total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="dot success"></span>
|
||||||
|
<span class="text">已同步: {{ summary.applied_credit_total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-info">
|
||||||
|
<el-alert
|
||||||
|
:title="summary.tip"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Background Ornament -->
|
||||||
|
<div class="card-glow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Grid -->
|
||||||
|
<section class="store-section">
|
||||||
|
<h3 class="section-title"><el-icon><ShoppingCart /></el-icon> Credit 套餐</h3>
|
||||||
|
<div class="product-grid">
|
||||||
|
<div
|
||||||
|
v-for="product in products"
|
||||||
|
:key="product.product_id"
|
||||||
|
class="product-card"
|
||||||
|
:class="{ 'is-popular': product.badge === 'Most Popular' }"
|
||||||
|
>
|
||||||
|
<div v-if="product.badge" class="product-badge">{{ product.badge }}</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<h4 class="product-name">{{ product.name }}</h4>
|
||||||
|
<p class="product-desc">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="product-amount">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
<span>{{ product.credit_amount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-footer">
|
||||||
|
<div class="price">{{ product.price_text }}</div>
|
||||||
|
<el-button
|
||||||
|
:type="product.badge === 'Most Popular' ? 'warning' : 'primary'"
|
||||||
|
round
|
||||||
|
@click="handlePurchase(product)"
|
||||||
|
>
|
||||||
|
{{ product.price_cent === 0 ? '立即领取' : '立即购买' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
|
<section class="store-section">
|
||||||
|
<h3 class="section-title"><el-icon><TrendCharts /></el-icon> 获取记录</h3>
|
||||||
|
<div class="history-list" v-loading="historyLoading">
|
||||||
|
<div v-for="grant in grants" :key="grant.grant_id" class="history-item">
|
||||||
|
<div class="history-icon" :class="grant.status">
|
||||||
|
<el-icon v-if="grant.status === 'recorded'"><Check /></el-icon>
|
||||||
|
<el-icon v-else><InfoFilled /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="history-top">
|
||||||
|
<span class="source">{{ grant.source_label }}</span>
|
||||||
|
<span class="amount">+{{ grant.amount }} Credit</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-bottom">
|
||||||
|
<span class="desc">{{ grant.description }}</span>
|
||||||
|
<span class="time">{{ formatDate(grant.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div class="history-footer">
|
||||||
|
<el-button link>查看更多记录 <el-icon><Timer /></el-icon></el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.store-container {
|
||||||
|
padding: 24px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden; /* 强制禁用水平滚动 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
background: #f8fafc;
|
||||||
|
scrollbar-width: thin; /* Firefox */
|
||||||
|
scrollbar-color: rgba(15, 23, 42, 0.1) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条样式 */
|
||||||
|
.store-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.store-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #3b82f6 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left p {
|
||||||
|
color: #64748b;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Balance Card */
|
||||||
|
.balance-card {
|
||||||
|
position: relative;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px; /* 统一内部垂直间距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-main {
|
||||||
|
/* 允许自然撑开 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-main .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-main .value {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
flex-wrap: wrap; /* 允许图标和数字在极窄屏下换行 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-main .value span {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-info {
|
||||||
|
/* 移除 margin-top,改由父容器 gap 控制 */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-main .value .el-icon {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8em; /* 随字号缩放 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.warning { background: #fbbf24; }
|
||||||
|
.dot.success { background: #10b981; }
|
||||||
|
|
||||||
|
.balance-info {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-info :deep(.el-alert) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
right: -10%;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.store-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Grid */
|
||||||
|
.product-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 20px 30px -10px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card.is-popular {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #fffbeb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card.is-popular .product-badge {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-amount {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1e293b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-amount .el-icon {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History List */
|
||||||
|
.history-list {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-icon.recorded {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-top .source {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-top .amount {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-bottom .desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-bottom .time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-footer {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fcfdfe;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user