feat: add forum and token store service skeletons

This commit is contained in:
Losita
2026-05-04 18:33:09 +08:00
parent 9742dc8b1c
commit 786c8925a0
25 changed files with 5344 additions and 203 deletions

View File

@@ -0,0 +1,61 @@
package dao
import (
"fmt"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// OpenDBFromConfig 创建计划广场服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 forum_* 表,不迁移 task_classes / task_items避免抢占 task-class 拆分线;
// 2. 不负责装配 legacy TaskClass adapteradapter 在服务实现阶段单独注入;
// 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. 唯一约束交给 GORM tag 生成,保证点赞和导入幂等有数据库兜底;
// 3. 失败时直接返回错误,避免服务在 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)
}
return nil
}

View File

@@ -0,0 +1,180 @@
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 (
// ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。
ForumImportStatusImported = "imported"
)
// 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;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数组"`
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;not null;comment:导入后创建的当前用户TaskClass ID"`
TargetTitle string `gorm:"column:target_title;type:varchar(80);comment:导入后的TaskClass标题"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'imported';comment:imported"`
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:导入请求幂等键"`
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"
}

View File

@@ -0,0 +1,76 @@
package rpc
import (
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/respond"
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
"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)
}
if errors.Is(err, forumsv.ErrNotImplemented) {
return status.Error(codes.Unimplemented, err.Error())
}
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
}

View File

@@ -0,0 +1,412 @@
package rpc
import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/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
}

View 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() {}

View 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",
}

View 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:9082"
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
}

View 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;
}

View File

@@ -0,0 +1,179 @@
package sv
import (
"context"
"errors"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"gorm.io/gorm"
)
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
var ErrNotImplemented = errors.New("taskclassforum service method not implemented")
// 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
}
// Service 承载计划广场服务内部业务编排。
//
// 职责边界:
// 1. 负责帖子、模板快照、点赞、评论、导入记录的事务编排;
// 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response
// 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
type Service struct {
db *gorm.DB
taskClassPort TaskClassSnapshotPort
}
func New(opts Options) *Service {
return &Service{
db: opts.DB,
taskClassPort: opts.TaskClassPort,
}
}
// 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
}
// ListPosts 是计划列表用例占位,第三步实现真实查询。
func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) {
_ = ctx
_ = actorUserID
_ = page
_ = pageSize
_ = sort
_ = keyword
_ = tag
return nil, forumcontracts.PageResult{}, ErrNotImplemented
}
// ListTags 是标签列表用例占位,第三步实现真实聚合查询。
func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) {
_ = ctx
_ = actorUserID
_ = limit
return nil, ErrNotImplemented
}
// CreatePost 是发布计划用例占位,第三步会通过 TaskClassSnapshotPort 读取旧计划快照。
func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// GetPost 是计划详情用例占位,第三步实现帖子和模板快照读取。
func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) {
_ = ctx
_ = actorUserID
_ = postID
return nil, ErrNotImplemented
}
// LikePost 是点赞用例占位,第三步实现唯一约束和计数更新。
func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
_ = ctx
_ = actorUserID
_ = postID
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
}
// UnlikePost 是取消点赞用例占位,第三步实现幂等撤销。
func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) {
_ = ctx
_ = actorUserID
_ = postID
return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, ErrNotImplemented
}
// ListComments 是评论树查询用例占位,第三步实现根评论分页和服务层组树。
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
_ = ctx
_ = actorUserID
_ = postID
_ = page
_ = pageSize
_ = sort
return nil, forumcontracts.PageResult{}, ErrNotImplemented
}
// CreateComment 是发表评论或回复用例占位,第三步实现父子评论校验和幂等。
func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// DeleteComment 是删除自己评论用例占位,第三步实现软删除和权限判断。
func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) {
_ = ctx
_ = actorUserID
_ = commentID
return nil, ErrNotImplemented
}
// ImportPost 是一键导入用例占位,第三步会保证同一用户同一帖子只导入一次。
func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}

View File

@@ -0,0 +1,179 @@
package dao
import (
"fmt"
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_* 表,不迁移 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. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入;
// 3. 失败时直接返回错误,避免服务在 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)
}
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: 2,
Status: tokenmodel.TokenRewardRuleStatusActive,
},
}
}

View 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"
}

View File

@@ -0,0 +1,72 @@
package rpc
import (
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/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
}

View File

@@ -0,0 +1,288 @@
package rpc
import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/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
}
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),
}
}
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
}

View File

@@ -0,0 +1,209 @@
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"`
}
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() {}

View File

@@ -0,0 +1,171 @@
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"
)
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)
}
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 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)
}
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 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)
}),
},
Streams: []grpc.StreamDesc{},
Metadata: "tokenstore.proto",
}

View 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:9083"
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
}

View File

@@ -0,0 +1,141 @@
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);
}
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;
}
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;
}

View File

@@ -0,0 +1,107 @@
package sv
import (
"context"
"errors"
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
grantOutlet TokenGrantOutlet
}
func New(opts Options) *Service {
return &Service{
db: 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
}
// ListProducts 是商品列表用例占位,第四步实现真实查询。
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
_ = ctx
_ = actorUserID
return nil, ErrNotImplemented
}
// GetSummary 是 Token 概览用例占位,第四步实现 grant 账本聚合。
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
_ = ctx
_ = actorUserID
return nil, ErrNotImplemented
}
// CreateOrder 是创建订单用例占位,第四步实现商品读取、订单幂等和金额快照。
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// ListOrders 是订单列表用例占位,第四步实现用户维度分页查询。
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
_ = ctx
_ = req
return nil, tokencontracts.PageResult{}, ErrNotImplemented
}
// GetOrder 是订单详情用例占位,第四步实现订单归属校验。
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = actorUserID
_ = orderID
return nil, ErrNotImplemented
}
// MockPaidOrder 是 P0 mock paid 用例占位,第四步实现支付态流转和 grant 账本。
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// ListGrants 是 Token 获取记录用例占位,第四步实现账本分页查询。
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
_ = ctx
_ = req
return nil, tokencontracts.PageResult{}, ErrNotImplemented
}