feat: add forum and token store service skeletons
This commit is contained in:
32
backend/cmd/taskclassforum/main.go
Normal file
32
backend/cmd/taskclassforum/main.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
||||||
|
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
|
||||||
|
forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := bootstrap.LoadConfig(); err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := forumdao.OpenDBFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect taskclassforum database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 当前阶段只启动计划广场自身 RPC 壳。
|
||||||
|
// 2. TaskClass legacy adapter 会在第三步业务主链路接入,避免现在抢改 task 模块。
|
||||||
|
// 3. 未实现的业务方法会明确返回 Unimplemented,而不是伪装成可用能力。
|
||||||
|
svc := forumsv.New(forumsv.Options{DB: db})
|
||||||
|
forumrpc.Start(forumrpc.ServerOptions{
|
||||||
|
ListenOn: viper.GetString("taskclassforum.rpc.listenOn"),
|
||||||
|
Timeout: viper.GetDuration("taskclassforum.rpc.timeout"),
|
||||||
|
Service: svc,
|
||||||
|
})
|
||||||
|
}
|
||||||
32
backend/cmd/tokenstore/main.go
Normal file
32
backend/cmd/tokenstore/main.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/bootstrap"
|
||||||
|
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||||
|
tokenstorerpc "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := bootstrap.LoadConfig(); err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := tokenstoredao.OpenDBFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect tokenstore database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 当前阶段只启动 token-store 自身 RPC 壳和本服务私有表迁移。
|
||||||
|
// 2. user/auth 授额出口后续通过 GrantOutlet adapter 切入,避免现在制造冲突。
|
||||||
|
// 3. 未实现的业务方法会明确返回 Unimplemented,而不是伪装成可用能力。
|
||||||
|
svc := tokenstoresv.New(tokenstoresv.Options{DB: db})
|
||||||
|
tokenstorerpc.Start(tokenstorerpc.ServerOptions{
|
||||||
|
ListenOn: viper.GetString("tokenstore.rpc.listenOn"),
|
||||||
|
Timeout: viper.GetDuration("tokenstore.rpc.timeout"),
|
||||||
|
Service: svc,
|
||||||
|
})
|
||||||
|
}
|
||||||
61
backend/services/taskclassforum/dao/connect.go
Normal file
61
backend/services/taskclassforum/dao/connect.go
Normal 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 adapter,adapter 在服务实现阶段单独注入;
|
||||||
|
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
|
||||||
|
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||||
|
host := viper.GetString("database.host")
|
||||||
|
port := viper.GetString("database.port")
|
||||||
|
user := viper.GetString("database.user")
|
||||||
|
password := viper.GetString("database.password")
|
||||||
|
dbname := viper.GetString("database.dbname")
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
user, password, host, port, dbname,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = AutoMigrate(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate 只迁移计划广场服务拥有的表。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先创建帖子、模板、条目、点赞、评论、导入记录表;
|
||||||
|
// 2. 唯一约束交给 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
|
||||||
|
}
|
||||||
180
backend/services/taskclassforum/model/forum.go
Normal file
180
backend/services/taskclassforum/model/forum.go
Normal 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"
|
||||||
|
}
|
||||||
76
backend/services/taskclassforum/rpc/errors.go
Normal file
76
backend/services/taskclassforum/rpc/errors.go
Normal 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
|
||||||
|
}
|
||||||
412
backend/services/taskclassforum/rpc/handler.go
Normal file
412
backend/services/taskclassforum/rpc/handler.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
pb.UnimplementedTaskClassForumServiceServer
|
||||||
|
svc *forumsv.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(svc *forumsv.Service) *Handler {
|
||||||
|
return &Handler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// service 负责统一校验 RPC 层依赖是否已经注入。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只判断 handler 自身和业务 service 是否可用;
|
||||||
|
// 2. 不负责校验请求参数,也不处理具体业务规则;
|
||||||
|
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
|
||||||
|
func (h *Handler) service() (*forumsv.Service, error) {
|
||||||
|
if h == nil || h.svc == nil {
|
||||||
|
return nil, errors.New("taskclassforum service dependency not initialized")
|
||||||
|
}
|
||||||
|
return h.svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosts 负责把计划广场列表请求从 gRPC 协议转成内部服务调用。
|
||||||
|
func (h *Handler) ListPosts(ctx context.Context, req *pb.ListForumPostsRequest) (*pb.ListForumPostsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListPosts(ctx, req.ActorUserId, int(req.Page), int(req.PageSize), req.Sort, req.Keyword, req.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumPostsResponse{
|
||||||
|
Items: forumPostBriefsToPB(items),
|
||||||
|
Page: forumPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListTags(ctx context.Context, req *pb.ListForumTagsRequest) (*pb.ListForumTagsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := svc.ListTags(ctx, req.ActorUserId, int(req.Limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumTagsResponse{Items: forumTagItemsToPB(items)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePost(ctx context.Context, req *pb.CreateForumPostRequest) (*pb.CreateForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := svc.CreatePost(ctx, forumcontracts.CreateForumPostRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
TaskClassID: req.TaskClassId,
|
||||||
|
Title: req.Title,
|
||||||
|
Summary: req.Summary,
|
||||||
|
Tags: append([]string(nil), req.Tags...),
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.CreateForumPostResponse{Post: forumPostBriefToPB(post)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPost(ctx context.Context, req *pb.GetForumPostRequest) (*pb.GetForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := svc.GetPost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.GetForumPostResponse{Data: forumPostDetailToPB(data)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LikePost(ctx context.Context, req *pb.LikeForumPostRequest) (*pb.LikeForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
counters, viewerState, err := svc.LikePost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.LikeForumPostResponse{
|
||||||
|
Counters: forumPostCountersToPB(counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnlikePost(ctx context.Context, req *pb.UnlikeForumPostRequest) (*pb.UnlikeForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
counters, viewerState, err := svc.UnlikePost(ctx, req.ActorUserId, req.PostId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.UnlikeForumPostResponse{
|
||||||
|
Counters: forumPostCountersToPB(counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(viewerState),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListComments(ctx context.Context, req *pb.ListForumCommentsRequest) (*pb.ListForumCommentsResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, page, err := svc.ListComments(ctx, req.ActorUserId, req.PostId, int(req.Page), int(req.PageSize), req.Sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ListForumCommentsResponse{
|
||||||
|
Items: forumCommentNodesToPB(items),
|
||||||
|
Page: forumPageToPB(page),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateComment(ctx context.Context, req *pb.CreateForumCommentRequest) (*pb.CreateForumCommentResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := svc.CreateComment(ctx, forumcontracts.CreateForumCommentRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
PostID: req.PostId,
|
||||||
|
Content: req.Content,
|
||||||
|
ParentCommentID: forumUint64PtrFromPositive(req.ParentCommentId),
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.CreateForumCommentResponse{Comment: forumCommentNodeToPB(comment)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteComment(ctx context.Context, req *pb.DeleteForumCommentRequest) (*pb.DeleteForumCommentResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.DeleteComment(ctx, req.ActorUserId, req.CommentId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.DeleteForumCommentResponse{
|
||||||
|
CommentId: result.CommentID,
|
||||||
|
Status: result.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportPost(ctx context.Context, req *pb.ImportForumPostRequest) (*pb.ImportForumPostResponse, error) {
|
||||||
|
svc, err := h.service()
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.ImportPost(ctx, forumcontracts.ImportForumPostRequest{
|
||||||
|
ActorUserID: req.ActorUserId,
|
||||||
|
PostID: req.PostId,
|
||||||
|
TargetTitle: req.TargetTitle,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, grpcErrorFromServiceError(err)
|
||||||
|
}
|
||||||
|
return &pb.ImportForumPostResponse{
|
||||||
|
ImportId: result.ImportID,
|
||||||
|
PostId: result.PostID,
|
||||||
|
NewTaskClassId: result.NewTaskClassID,
|
||||||
|
TaskClassTitle: result.TaskClassTitle,
|
||||||
|
ImportCount: result.ImportCount,
|
||||||
|
CreatedAt: result.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPageToPB(page forumcontracts.PageResult) *pb.PageResponse {
|
||||||
|
return &pb.PageResponse{
|
||||||
|
Page: int32(page.Page),
|
||||||
|
PageSize: int32(page.PageSize),
|
||||||
|
Total: int32(page.Total),
|
||||||
|
HasMore: page.HasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUserToPB(user forumcontracts.UserBrief) *pb.UserBrief {
|
||||||
|
return &pb.UserBrief{
|
||||||
|
UserId: user.UserID,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
AvatarUrl: user.AvatarURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateSummaryToPB(summary forumcontracts.TemplateSummary) *pb.TemplateSummary {
|
||||||
|
return &pb.TemplateSummary{
|
||||||
|
TaskCount: int32(summary.TaskCount),
|
||||||
|
Mode: summary.Mode,
|
||||||
|
StartDate: summary.StartDate,
|
||||||
|
EndDate: summary.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), summary.StrategyLabels...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostCountersToPB(counters forumcontracts.ForumPostCounters) *pb.ForumPostCounters {
|
||||||
|
return &pb.ForumPostCounters{
|
||||||
|
LikeCount: counters.LikeCount,
|
||||||
|
CommentCount: counters.CommentCount,
|
||||||
|
ImportCount: counters.ImportCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostViewerStateToPB(viewerState forumcontracts.ForumPostViewerState) *pb.ForumPostViewerState {
|
||||||
|
return &pb.ForumPostViewerState{
|
||||||
|
Liked: viewerState.Liked,
|
||||||
|
ImportedOnce: viewerState.ImportedOnce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefToPB(post *forumcontracts.ForumPostBrief) *pb.ForumPostBrief {
|
||||||
|
if post == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.ForumPostBrief{
|
||||||
|
PostId: post.PostID,
|
||||||
|
Title: post.Title,
|
||||||
|
Summary: post.Summary,
|
||||||
|
Tags: append([]string(nil), post.Tags...),
|
||||||
|
Author: forumUserToPB(post.Author),
|
||||||
|
TemplateSummary: forumTemplateSummaryToPB(post.TemplateSummary),
|
||||||
|
Counters: forumPostCountersToPB(post.Counters),
|
||||||
|
ViewerState: forumPostViewerStateToPB(post.ViewerState),
|
||||||
|
Status: post.Status,
|
||||||
|
CreatedAt: post.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostBriefsToPB(items []forumcontracts.ForumPostBrief) []*pb.ForumPostBrief {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumPostBrief, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, forumPostBriefToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateItemPreviewToPB(item forumcontracts.TemplateItemPreview) *pb.TemplateItemPreview {
|
||||||
|
return &pb.TemplateItemPreview{
|
||||||
|
ItemId: item.ItemID,
|
||||||
|
Order: int32(item.Order),
|
||||||
|
Content: item.Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTemplateDetailToPB(detail forumcontracts.TemplateDetail) *pb.TemplateDetail {
|
||||||
|
preview := make([]*pb.TemplateItemPreview, 0, len(detail.ItemsPreview))
|
||||||
|
for i := range detail.ItemsPreview {
|
||||||
|
item := detail.ItemsPreview[i]
|
||||||
|
preview = append(preview, forumTemplateItemPreviewToPB(item))
|
||||||
|
}
|
||||||
|
return &pb.TemplateDetail{
|
||||||
|
Mode: detail.Mode,
|
||||||
|
StartDate: detail.StartDate,
|
||||||
|
EndDate: detail.EndDate,
|
||||||
|
StrategyLabels: append([]string(nil), detail.StrategyLabels...),
|
||||||
|
TaskCount: int32(detail.TaskCount),
|
||||||
|
ItemsPreview: preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumPostDetailToPB(detail *forumcontracts.ForumPostDetail) *pb.ForumPostDetail {
|
||||||
|
if detail == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pb.ForumPostDetail{
|
||||||
|
Post: forumPostBriefToPB(&detail.Post),
|
||||||
|
Template: forumTemplateDetailToPB(detail.Template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumTagItemsToPB(items []forumcontracts.ForumTagItem) []*pb.ForumTagItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumTagItem, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, &pb.ForumTagItem{
|
||||||
|
Tag: item.Tag,
|
||||||
|
PostCount: int32(item.PostCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodeToPB(node *forumcontracts.ForumCommentNode) *pb.ForumCommentNode {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
children := make([]*pb.ForumCommentNode, 0, len(node.Children))
|
||||||
|
for i := range node.Children {
|
||||||
|
child := node.Children[i]
|
||||||
|
children = append(children, forumCommentNodeToPB(&child))
|
||||||
|
}
|
||||||
|
return &pb.ForumCommentNode{
|
||||||
|
CommentId: node.CommentID,
|
||||||
|
PostId: node.PostID,
|
||||||
|
ParentCommentId: forumUint64FromPtr(node.ParentCommentID),
|
||||||
|
Content: node.Content,
|
||||||
|
Status: node.Status,
|
||||||
|
Author: forumUserToPB(node.Author),
|
||||||
|
CanDelete: node.CanDelete,
|
||||||
|
CreatedAt: node.CreatedAt,
|
||||||
|
DeletedAt: forumStringFromPtr(node.DeletedAt),
|
||||||
|
Children: children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumCommentNodesToPB(items []forumcontracts.ForumCommentNode) []*pb.ForumCommentNode {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*pb.ForumCommentNode, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
item := items[i]
|
||||||
|
result = append(result, forumCommentNodeToPB(&item))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUint64FromPtr(value *uint64) uint64 {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumUint64PtrFromPositive(value uint64) *uint64 {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumStringFromPtr(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
339
backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import proto "github.com/golang/protobuf/proto"
|
||||||
|
|
||||||
|
var _ = proto.Marshal
|
||||||
|
|
||||||
|
const _ = proto.ProtoPackageIsVersion3
|
||||||
|
|
||||||
|
type PageRequest struct {
|
||||||
|
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PageRequest) Reset() { *m = PageRequest{} }
|
||||||
|
func (m *PageRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*PageRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type PageResponse struct {
|
||||||
|
Page int32 `protobuf:"varint,1,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"`
|
||||||
|
HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PageResponse) Reset() { *m = PageResponse{} }
|
||||||
|
func (m *PageResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*PageResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UserBrief struct {
|
||||||
|
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||||
|
Nickname string `protobuf:"bytes,2,opt,name=nickname,proto3" json:"nickname,omitempty"`
|
||||||
|
AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserBrief) Reset() { *m = UserBrief{} }
|
||||||
|
func (m *UserBrief) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UserBrief) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateSummary struct {
|
||||||
|
TaskCount int32 `protobuf:"varint,1,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||||
|
Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||||
|
StartDate string `protobuf:"bytes,3,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||||
|
EndDate string `protobuf:"bytes,4,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||||
|
StrategyLabels []string `protobuf:"bytes,5,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateSummary) Reset() { *m = TemplateSummary{} }
|
||||||
|
func (m *TemplateSummary) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateSummary) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostCounters struct {
|
||||||
|
LikeCount int64 `protobuf:"varint,1,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"`
|
||||||
|
CommentCount int64 `protobuf:"varint,2,opt,name=comment_count,json=commentCount,proto3" json:"comment_count,omitempty"`
|
||||||
|
ImportCount int64 `protobuf:"varint,3,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostCounters) Reset() { *m = ForumPostCounters{} }
|
||||||
|
func (m *ForumPostCounters) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostCounters) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostViewerState struct {
|
||||||
|
Liked bool `protobuf:"varint,1,opt,name=liked,proto3" json:"liked,omitempty"`
|
||||||
|
ImportedOnce bool `protobuf:"varint,2,opt,name=imported_once,json=importedOnce,proto3" json:"imported_once,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostViewerState) Reset() { *m = ForumPostViewerState{} }
|
||||||
|
func (m *ForumPostViewerState) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostViewerState) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostBrief struct {
|
||||||
|
PostId uint64 `protobuf:"varint,1,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
Author *UserBrief `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"`
|
||||||
|
TemplateSummary *TemplateSummary `protobuf:"bytes,6,opt,name=template_summary,json=templateSummary,proto3" json:"template_summary,omitempty"`
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,7,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,8,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostBrief) Reset() { *m = ForumPostBrief{} }
|
||||||
|
func (m *ForumPostBrief) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostBrief) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateItemPreview struct {
|
||||||
|
ItemId uint64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
|
||||||
|
Order int32 `protobuf:"varint,2,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateItemPreview) Reset() { *m = TemplateItemPreview{} }
|
||||||
|
func (m *TemplateItemPreview) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateItemPreview) ProtoMessage() {}
|
||||||
|
|
||||||
|
type TemplateDetail struct {
|
||||||
|
Mode string `protobuf:"bytes,1,opt,name=mode,proto3" json:"mode,omitempty"`
|
||||||
|
StartDate string `protobuf:"bytes,2,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"`
|
||||||
|
EndDate string `protobuf:"bytes,3,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"`
|
||||||
|
StrategyLabels []string `protobuf:"bytes,4,rep,name=strategy_labels,json=strategyLabels,proto3" json:"strategy_labels,omitempty"`
|
||||||
|
TaskCount int32 `protobuf:"varint,5,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
||||||
|
ItemsPreview []*TemplateItemPreview `protobuf:"bytes,6,rep,name=items_preview,json=itemsPreview,proto3" json:"items_preview,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TemplateDetail) Reset() { *m = TemplateDetail{} }
|
||||||
|
func (m *TemplateDetail) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*TemplateDetail) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumPostDetail struct {
|
||||||
|
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||||
|
Template *TemplateDetail `protobuf:"bytes,2,opt,name=template,proto3" json:"template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumPostDetail) Reset() { *m = ForumPostDetail{} }
|
||||||
|
func (m *ForumPostDetail) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumPostDetail) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumCommentNode struct {
|
||||||
|
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
ParentCommentId uint64 `protobuf:"varint,3,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
Author *UserBrief `protobuf:"bytes,6,opt,name=author,proto3" json:"author,omitempty"`
|
||||||
|
CanDelete bool `protobuf:"varint,7,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
DeletedAt string `protobuf:"bytes,9,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"`
|
||||||
|
Children []*ForumCommentNode `protobuf:"bytes,10,rep,name=children,proto3" json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumCommentNode) Reset() { *m = ForumCommentNode{} }
|
||||||
|
func (m *ForumCommentNode) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumCommentNode) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumPostsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Sort string `protobuf:"bytes,4,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||||
|
Keyword string `protobuf:"bytes,5,opt,name=keyword,proto3" json:"keyword,omitempty"`
|
||||||
|
Tag string `protobuf:"bytes,6,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumPostsRequest) Reset() { *m = ListForumPostsRequest{} }
|
||||||
|
func (m *ListForumPostsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumPostsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumPostsResponse struct {
|
||||||
|
Items []*ForumPostBrief `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumPostsResponse) Reset() { *m = ListForumPostsResponse{} }
|
||||||
|
func (m *ListForumPostsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumPostsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumTagsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumTagsRequest) Reset() { *m = ListForumTagsRequest{} }
|
||||||
|
func (m *ListForumTagsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumTagsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ForumTagItem struct {
|
||||||
|
Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||||
|
PostCount int32 `protobuf:"varint,2,opt,name=post_count,json=postCount,proto3" json:"post_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ForumTagItem) Reset() { *m = ForumTagItem{} }
|
||||||
|
func (m *ForumTagItem) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ForumTagItem) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumTagsResponse struct {
|
||||||
|
Items []*ForumTagItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumTagsResponse) Reset() { *m = ListForumTagsResponse{} }
|
||||||
|
func (m *ListForumTagsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumTagsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
TaskClassId uint64 `protobuf:"varint,2,opt,name=task_class_id,json=taskClassId,proto3" json:"task_class_id,omitempty"`
|
||||||
|
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
|
Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"`
|
||||||
|
Tags []string `protobuf:"bytes,5,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,6,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumPostRequest) Reset() { *m = CreateForumPostRequest{} }
|
||||||
|
func (m *CreateForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumPostResponse struct {
|
||||||
|
Post *ForumPostBrief `protobuf:"bytes,1,opt,name=post,proto3" json:"post,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumPostResponse) Reset() { *m = CreateForumPostResponse{} }
|
||||||
|
func (m *CreateForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetForumPostRequest) Reset() { *m = GetForumPostRequest{} }
|
||||||
|
func (m *GetForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type GetForumPostResponse struct {
|
||||||
|
Data *ForumPostDetail `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GetForumPostResponse) Reset() { *m = GetForumPostResponse{} }
|
||||||
|
func (m *GetForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*GetForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type LikeForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *LikeForumPostRequest) Reset() { *m = LikeForumPostRequest{} }
|
||||||
|
func (m *LikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*LikeForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type LikeForumPostResponse struct {
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *LikeForumPostResponse) Reset() { *m = LikeForumPostResponse{} }
|
||||||
|
func (m *LikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*LikeForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UnlikeForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnlikeForumPostRequest) Reset() { *m = UnlikeForumPostRequest{} }
|
||||||
|
func (m *UnlikeForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UnlikeForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type UnlikeForumPostResponse struct {
|
||||||
|
Counters *ForumPostCounters `protobuf:"bytes,1,opt,name=counters,proto3" json:"counters,omitempty"`
|
||||||
|
ViewerState *ForumPostViewerState `protobuf:"bytes,2,opt,name=viewer_state,json=viewerState,proto3" json:"viewer_state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UnlikeForumPostResponse) Reset() { *m = UnlikeForumPostResponse{} }
|
||||||
|
func (m *UnlikeForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*UnlikeForumPostResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumCommentsRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
Sort string `protobuf:"bytes,5,opt,name=sort,proto3" json:"sort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumCommentsRequest) Reset() { *m = ListForumCommentsRequest{} }
|
||||||
|
func (m *ListForumCommentsRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumCommentsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ListForumCommentsResponse struct {
|
||||||
|
Items []*ForumCommentNode `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
|
||||||
|
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ListForumCommentsResponse) Reset() { *m = ListForumCommentsResponse{} }
|
||||||
|
func (m *ListForumCommentsResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ListForumCommentsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumCommentRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
|
||||||
|
ParentCommentId uint64 `protobuf:"varint,4,opt,name=parent_comment_id,json=parentCommentId,proto3" json:"parent_comment_id,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,5,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumCommentRequest) Reset() { *m = CreateForumCommentRequest{} }
|
||||||
|
func (m *CreateForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumCommentRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type CreateForumCommentResponse struct {
|
||||||
|
Comment *ForumCommentNode `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CreateForumCommentResponse) Reset() { *m = CreateForumCommentResponse{} }
|
||||||
|
func (m *CreateForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*CreateForumCommentResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type DeleteForumCommentRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
CommentId uint64 `protobuf:"varint,2,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeleteForumCommentRequest) Reset() { *m = DeleteForumCommentRequest{} }
|
||||||
|
func (m *DeleteForumCommentRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*DeleteForumCommentRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type DeleteForumCommentResponse struct {
|
||||||
|
CommentId uint64 `protobuf:"varint,1,opt,name=comment_id,json=commentId,proto3" json:"comment_id,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeleteForumCommentResponse) Reset() { *m = DeleteForumCommentResponse{} }
|
||||||
|
func (m *DeleteForumCommentResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*DeleteForumCommentResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ImportForumPostRequest struct {
|
||||||
|
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
TargetTitle string `protobuf:"bytes,3,opt,name=target_title,json=targetTitle,proto3" json:"target_title,omitempty"`
|
||||||
|
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ImportForumPostRequest) Reset() { *m = ImportForumPostRequest{} }
|
||||||
|
func (m *ImportForumPostRequest) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ImportForumPostRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
type ImportForumPostResponse struct {
|
||||||
|
ImportId uint64 `protobuf:"varint,1,opt,name=import_id,json=importId,proto3" json:"import_id,omitempty"`
|
||||||
|
PostId uint64 `protobuf:"varint,2,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"`
|
||||||
|
NewTaskClassId uint64 `protobuf:"varint,3,opt,name=new_task_class_id,json=newTaskClassId,proto3" json:"new_task_class_id,omitempty"`
|
||||||
|
TaskClassTitle string `protobuf:"bytes,4,opt,name=task_class_title,json=taskClassTitle,proto3" json:"task_class_title,omitempty"`
|
||||||
|
ImportCount int64 `protobuf:"varint,5,opt,name=import_count,json=importCount,proto3" json:"import_count,omitempty"`
|
||||||
|
CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ImportForumPostResponse) Reset() { *m = ImportForumPostResponse{} }
|
||||||
|
func (m *ImportForumPostResponse) String() string { return proto.CompactTextString(m) }
|
||||||
|
func (*ImportForumPostResponse) ProtoMessage() {}
|
||||||
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
213
backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskClassForumService_ListPosts_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListPosts"
|
||||||
|
TaskClassForumService_ListTags_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListTags"
|
||||||
|
TaskClassForumService_CreatePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreatePost"
|
||||||
|
TaskClassForumService_GetPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/GetPost"
|
||||||
|
TaskClassForumService_LikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/LikePost"
|
||||||
|
TaskClassForumService_UnlikePost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/UnlikePost"
|
||||||
|
TaskClassForumService_ListComments_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ListComments"
|
||||||
|
TaskClassForumService_CreateComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/CreateComment"
|
||||||
|
TaskClassForumService_DeleteComment_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/DeleteComment"
|
||||||
|
TaskClassForumService_ImportPost_FullMethodName = "/smartflow.taskclassforum.TaskClassForumService/ImportPost"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskClassForumServiceClient interface {
|
||||||
|
ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error)
|
||||||
|
ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error)
|
||||||
|
CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error)
|
||||||
|
GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error)
|
||||||
|
LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error)
|
||||||
|
UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error)
|
||||||
|
ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error)
|
||||||
|
CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error)
|
||||||
|
DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error)
|
||||||
|
ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskClassForumServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskClassForumServiceClient(cc grpc.ClientConnInterface) TaskClassForumServiceClient {
|
||||||
|
return &taskClassForumServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListPosts(ctx context.Context, in *ListForumPostsRequest, opts ...grpc.CallOption) (*ListForumPostsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumPostsResponse](ctx, c.cc, TaskClassForumService_ListPosts_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListTags(ctx context.Context, in *ListForumTagsRequest, opts ...grpc.CallOption) (*ListForumTagsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumTagsResponse](ctx, c.cc, TaskClassForumService_ListTags_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) CreatePost(ctx context.Context, in *CreateForumPostRequest, opts ...grpc.CallOption) (*CreateForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[CreateForumPostResponse](ctx, c.cc, TaskClassForumService_CreatePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) GetPost(ctx context.Context, in *GetForumPostRequest, opts ...grpc.CallOption) (*GetForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[GetForumPostResponse](ctx, c.cc, TaskClassForumService_GetPost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) LikePost(ctx context.Context, in *LikeForumPostRequest, opts ...grpc.CallOption) (*LikeForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[LikeForumPostResponse](ctx, c.cc, TaskClassForumService_LikePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) UnlikePost(ctx context.Context, in *UnlikeForumPostRequest, opts ...grpc.CallOption) (*UnlikeForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[UnlikeForumPostResponse](ctx, c.cc, TaskClassForumService_UnlikePost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ListComments(ctx context.Context, in *ListForumCommentsRequest, opts ...grpc.CallOption) (*ListForumCommentsResponse, error) {
|
||||||
|
return invokeTaskClassForum[ListForumCommentsResponse](ctx, c.cc, TaskClassForumService_ListComments_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) CreateComment(ctx context.Context, in *CreateForumCommentRequest, opts ...grpc.CallOption) (*CreateForumCommentResponse, error) {
|
||||||
|
return invokeTaskClassForum[CreateForumCommentResponse](ctx, c.cc, TaskClassForumService_CreateComment_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) DeleteComment(ctx context.Context, in *DeleteForumCommentRequest, opts ...grpc.CallOption) (*DeleteForumCommentResponse, error) {
|
||||||
|
return invokeTaskClassForum[DeleteForumCommentResponse](ctx, c.cc, TaskClassForumService_DeleteComment_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskClassForumServiceClient) ImportPost(ctx context.Context, in *ImportForumPostRequest, opts ...grpc.CallOption) (*ImportForumPostResponse, error) {
|
||||||
|
return invokeTaskClassForum[ImportForumPostResponse](ctx, c.cc, TaskClassForumService_ImportPost_FullMethodName, in, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeTaskClassForum[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
|
||||||
|
out := new(Resp)
|
||||||
|
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskClassForumServiceServer interface {
|
||||||
|
ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error)
|
||||||
|
ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error)
|
||||||
|
CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error)
|
||||||
|
GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error)
|
||||||
|
LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error)
|
||||||
|
UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error)
|
||||||
|
ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error)
|
||||||
|
CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error)
|
||||||
|
DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error)
|
||||||
|
ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnimplementedTaskClassForumServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListPosts(context.Context, *ListForumPostsRequest) (*ListForumPostsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListPosts not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListTags(context.Context, *ListForumTagsRequest) (*ListForumTagsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListTags not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) CreatePost(context.Context, *CreateForumPostRequest) (*CreateForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreatePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) GetPost(context.Context, *GetForumPostRequest) (*GetForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetPost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) LikePost(context.Context, *LikeForumPostRequest) (*LikeForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method LikePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) UnlikePost(context.Context, *UnlikeForumPostRequest) (*UnlikeForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method UnlikePost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ListComments(context.Context, *ListForumCommentsRequest) (*ListForumCommentsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListComments not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) CreateComment(context.Context, *CreateForumCommentRequest) (*CreateForumCommentResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) DeleteComment(context.Context, *DeleteForumCommentRequest) (*DeleteForumCommentResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method DeleteComment not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedTaskClassForumServiceServer) ImportPost(context.Context, *ImportForumPostRequest) (*ImportForumPostResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ImportPost not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterTaskClassForumServiceServer(s grpc.ServiceRegistrar, srv TaskClassForumServiceServer) {
|
||||||
|
s.RegisterService(&TaskClassForumService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskClassForumUnaryHandler[Req any](methodName string, fullMethod string, invoke func(TaskClassForumServiceServer, context.Context, *Req) (interface{}, error)) grpc.MethodDesc {
|
||||||
|
return grpc.MethodDesc{
|
||||||
|
MethodName: methodName,
|
||||||
|
Handler: func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Req)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return invoke(srv.(TaskClassForumServiceServer), ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: fullMethod,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return invoke(srv.(TaskClassForumServiceServer), ctx, req.(*Req))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var TaskClassForumService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "smartflow.taskclassforum.TaskClassForumService",
|
||||||
|
HandlerType: (*TaskClassForumServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
taskClassForumUnaryHandler[ListForumPostsRequest]("ListPosts", TaskClassForumService_ListPosts_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumPostsRequest) (interface{}, error) {
|
||||||
|
return s.ListPosts(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ListForumTagsRequest]("ListTags", TaskClassForumService_ListTags_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumTagsRequest) (interface{}, error) {
|
||||||
|
return s.ListTags(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[CreateForumPostRequest]("CreatePost", TaskClassForumService_CreatePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumPostRequest) (interface{}, error) {
|
||||||
|
return s.CreatePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[GetForumPostRequest]("GetPost", TaskClassForumService_GetPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *GetForumPostRequest) (interface{}, error) {
|
||||||
|
return s.GetPost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[LikeForumPostRequest]("LikePost", TaskClassForumService_LikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *LikeForumPostRequest) (interface{}, error) {
|
||||||
|
return s.LikePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[UnlikeForumPostRequest]("UnlikePost", TaskClassForumService_UnlikePost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *UnlikeForumPostRequest) (interface{}, error) {
|
||||||
|
return s.UnlikePost(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ListForumCommentsRequest]("ListComments", TaskClassForumService_ListComments_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ListForumCommentsRequest) (interface{}, error) {
|
||||||
|
return s.ListComments(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[CreateForumCommentRequest]("CreateComment", TaskClassForumService_CreateComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *CreateForumCommentRequest) (interface{}, error) {
|
||||||
|
return s.CreateComment(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[DeleteForumCommentRequest]("DeleteComment", TaskClassForumService_DeleteComment_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *DeleteForumCommentRequest) (interface{}, error) {
|
||||||
|
return s.DeleteComment(ctx, req)
|
||||||
|
}),
|
||||||
|
taskClassForumUnaryHandler[ImportForumPostRequest]("ImportPost", TaskClassForumService_ImportPost_FullMethodName, func(s TaskClassForumServiceServer, ctx context.Context, req *ImportForumPostRequest) (interface{}, error) {
|
||||||
|
return s.ImportPost(ctx, req)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "taskclassforum.proto",
|
||||||
|
}
|
||||||
73
backend/services/taskclassforum/rpc/server.go
Normal file
73
backend/services/taskclassforum/rpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb"
|
||||||
|
forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv"
|
||||||
|
"github.com/zeromicro/go-zero/core/service"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListenOn = "0.0.0.0: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
|
||||||
|
}
|
||||||
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
222
backend/services/taskclassforum/rpc/taskclassforum.proto
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package smartflow.taskclassforum;
|
||||||
|
|
||||||
|
option go_package = "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb";
|
||||||
|
|
||||||
|
service TaskClassForumService {
|
||||||
|
rpc ListPosts(ListForumPostsRequest) returns (ListForumPostsResponse);
|
||||||
|
rpc ListTags(ListForumTagsRequest) returns (ListForumTagsResponse);
|
||||||
|
rpc CreatePost(CreateForumPostRequest) returns (CreateForumPostResponse);
|
||||||
|
rpc GetPost(GetForumPostRequest) returns (GetForumPostResponse);
|
||||||
|
rpc LikePost(LikeForumPostRequest) returns (LikeForumPostResponse);
|
||||||
|
rpc UnlikePost(UnlikeForumPostRequest) returns (UnlikeForumPostResponse);
|
||||||
|
rpc ListComments(ListForumCommentsRequest) returns (ListForumCommentsResponse);
|
||||||
|
rpc CreateComment(CreateForumCommentRequest) returns (CreateForumCommentResponse);
|
||||||
|
rpc DeleteComment(DeleteForumCommentRequest) returns (DeleteForumCommentResponse);
|
||||||
|
rpc ImportPost(ImportForumPostRequest) returns (ImportForumPostResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message PageRequest {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PageResponse {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
int32 total = 3;
|
||||||
|
bool has_more = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserBrief {
|
||||||
|
uint64 user_id = 1;
|
||||||
|
string nickname = 2;
|
||||||
|
string avatar_url = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateSummary {
|
||||||
|
int32 task_count = 1;
|
||||||
|
string mode = 2;
|
||||||
|
string start_date = 3;
|
||||||
|
string end_date = 4;
|
||||||
|
repeated string strategy_labels = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostCounters {
|
||||||
|
int64 like_count = 1;
|
||||||
|
int64 comment_count = 2;
|
||||||
|
int64 import_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostViewerState {
|
||||||
|
bool liked = 1;
|
||||||
|
bool imported_once = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostBrief {
|
||||||
|
uint64 post_id = 1;
|
||||||
|
string title = 2;
|
||||||
|
string summary = 3;
|
||||||
|
repeated string tags = 4;
|
||||||
|
UserBrief author = 5;
|
||||||
|
TemplateSummary template_summary = 6;
|
||||||
|
ForumPostCounters counters = 7;
|
||||||
|
ForumPostViewerState viewer_state = 8;
|
||||||
|
string status = 9;
|
||||||
|
string created_at = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateItemPreview {
|
||||||
|
uint64 item_id = 1;
|
||||||
|
int32 order = 2;
|
||||||
|
string content = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateDetail {
|
||||||
|
string mode = 1;
|
||||||
|
string start_date = 2;
|
||||||
|
string end_date = 3;
|
||||||
|
repeated string strategy_labels = 4;
|
||||||
|
int32 task_count = 5;
|
||||||
|
repeated TemplateItemPreview items_preview = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumPostDetail {
|
||||||
|
ForumPostBrief post = 1;
|
||||||
|
TemplateDetail template = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumCommentNode {
|
||||||
|
uint64 comment_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
uint64 parent_comment_id = 3;
|
||||||
|
string content = 4;
|
||||||
|
string status = 5;
|
||||||
|
UserBrief author = 6;
|
||||||
|
bool can_delete = 7;
|
||||||
|
string created_at = 8;
|
||||||
|
string deleted_at = 9;
|
||||||
|
repeated ForumCommentNode children = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumPostsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 page = 2;
|
||||||
|
int32 page_size = 3;
|
||||||
|
string sort = 4;
|
||||||
|
string keyword = 5;
|
||||||
|
string tag = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumPostsResponse {
|
||||||
|
repeated ForumPostBrief items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumTagsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
int32 limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ForumTagItem {
|
||||||
|
string tag = 1;
|
||||||
|
int32 post_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumTagsResponse {
|
||||||
|
repeated ForumTagItem items = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 task_class_id = 2;
|
||||||
|
string title = 3;
|
||||||
|
string summary = 4;
|
||||||
|
repeated string tags = 5;
|
||||||
|
string idempotency_key = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumPostResponse {
|
||||||
|
ForumPostBrief post = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetForumPostResponse {
|
||||||
|
ForumPostDetail data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LikeForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LikeForumPostResponse {
|
||||||
|
ForumPostCounters counters = 1;
|
||||||
|
ForumPostViewerState viewer_state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlikeForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnlikeForumPostResponse {
|
||||||
|
ForumPostCounters counters = 1;
|
||||||
|
ForumPostViewerState viewer_state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumCommentsRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
int32 page = 3;
|
||||||
|
int32 page_size = 4;
|
||||||
|
string sort = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumCommentsResponse {
|
||||||
|
repeated ForumCommentNode items = 1;
|
||||||
|
PageResponse page = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumCommentRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
string content = 3;
|
||||||
|
uint64 parent_comment_id = 4;
|
||||||
|
string idempotency_key = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateForumCommentResponse {
|
||||||
|
ForumCommentNode comment = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteForumCommentRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 comment_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteForumCommentResponse {
|
||||||
|
uint64 comment_id = 1;
|
||||||
|
string status = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImportForumPostRequest {
|
||||||
|
uint64 actor_user_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
string target_title = 3;
|
||||||
|
string idempotency_key = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImportForumPostResponse {
|
||||||
|
uint64 import_id = 1;
|
||||||
|
uint64 post_id = 2;
|
||||||
|
uint64 new_task_class_id = 3;
|
||||||
|
string task_class_title = 4;
|
||||||
|
int64 import_count = 5;
|
||||||
|
string created_at = 6;
|
||||||
|
}
|
||||||
179
backend/services/taskclassforum/sv/service.go
Normal file
179
backend/services/taskclassforum/sv/service.go
Normal 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
|
||||||
|
}
|
||||||
179
backend/services/tokenstore/dao/connect.go
Normal file
179
backend/services/tokenstore/dao/connect.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
155
backend/services/tokenstore/model/token.go
Normal file
155
backend/services/tokenstore/model/token.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenProductStatusActive 表示商品可在 Token 商店展示和购买。
|
||||||
|
TokenProductStatusActive = "active"
|
||||||
|
// TokenProductStatusInactive 表示商品已下架,不再对前端展示。
|
||||||
|
TokenProductStatusInactive = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenOrderStatusPending 表示订单已创建,等待支付确认。
|
||||||
|
TokenOrderStatusPending = "pending"
|
||||||
|
// TokenOrderStatusPaid 表示订单已确认支付,等待写入获取账本。
|
||||||
|
TokenOrderStatusPaid = "paid"
|
||||||
|
// TokenOrderStatusGranted 表示订单已经写入 token_grants 获取账本。
|
||||||
|
TokenOrderStatusGranted = "granted"
|
||||||
|
// TokenOrderStatusClosed 表示订单关闭,P0 暂不实现复杂关闭流程。
|
||||||
|
TokenOrderStatusClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenGrantStatusRecorded 表示 Token 获取事实已记录在 token-store 内。
|
||||||
|
TokenGrantStatusRecorded = "recorded"
|
||||||
|
// TokenGrantStatusApplied 表示后续已同步到 user/auth 权威额度。
|
||||||
|
TokenGrantStatusApplied = "applied"
|
||||||
|
// TokenGrantStatusSkipped 表示命中奖励规则或幂等条件后跳过发放。
|
||||||
|
TokenGrantStatusSkipped = "skipped"
|
||||||
|
// TokenGrantStatusFailed 表示记录或后续同步失败,可按 event_id 重试。
|
||||||
|
TokenGrantStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenGrantSourcePurchase 表示购买 Token 商品产生的获取记录。
|
||||||
|
TokenGrantSourcePurchase = "purchase"
|
||||||
|
// TokenGrantSourceForumLike 表示计划被点赞产生的作者奖励。
|
||||||
|
TokenGrantSourceForumLike = "forum_like"
|
||||||
|
// TokenGrantSourceForumImport 表示计划被导入产生的作者奖励。
|
||||||
|
TokenGrantSourceForumImport = "forum_import"
|
||||||
|
// TokenGrantSourceManual 预留人工补偿来源,P0 不做管理后台。
|
||||||
|
TokenGrantSourceManual = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TokenRewardRuleStatusActive 表示奖励规则启用。
|
||||||
|
TokenRewardRuleStatusActive = "active"
|
||||||
|
// TokenRewardRuleStatusInactive 表示奖励规则停用。
|
||||||
|
TokenRewardRuleStatusInactive = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenProduct 是 Token 商店商品表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 从表读取商品,由 seed 初始化 2-3 个固定商品;
|
||||||
|
// 2. 不承载真实支付渠道配置,也不做商品管理后台;
|
||||||
|
// 3. 下单时会复制商品快照到订单,避免后续改价影响历史订单。
|
||||||
|
type TokenProduct struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_token_products_sku;comment:商品稳定编码"`
|
||||||
|
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
|
||||||
|
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
|
||||||
|
TokenAmount int64 `gorm:"column:token_amount;not null;comment:商品包含Token数量"`
|
||||||
|
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
|
||||||
|
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||||
|
Badge string `gorm:"column:badge;type:varchar(32);comment:前端角标"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_products_status_sort,priority:1;comment:active/inactive"`
|
||||||
|
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_token_products_status_sort,priority:2;comment:展示排序"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenProduct) TableName() string {
|
||||||
|
return "token_products"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenOrder 是 Token 商品订单表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 记录用户购买商品的订单状态机;
|
||||||
|
// 2. P0 只支持 mock paid,不接真实支付网关;
|
||||||
|
// 3. granted 只表示已写入 token-store 获取账本,不代表已同步到 user/auth 权威额度。
|
||||||
|
type TokenOrder struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_token_orders_order_no;comment:订单号"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_token_orders_user_idem,priority:1;index:idx_token_orders_user_status_created,priority:1;comment:下单用户ID"`
|
||||||
|
ProductID uint64 `gorm:"column:product_id;not null;index:idx_token_orders_product;comment:商品ID"`
|
||||||
|
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
|
||||||
|
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
|
||||||
|
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
|
||||||
|
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
|
||||||
|
TokenAmount int64 `gorm:"column:token_amount;not null;comment:订单总Token数量"`
|
||||||
|
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
|
||||||
|
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_token_orders_user_status_created,priority:2;comment:pending/paid/granted/closed"`
|
||||||
|
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式,P0为mock"`
|
||||||
|
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_token_orders_user_idem,priority:2;comment:创建订单幂等键"`
|
||||||
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
|
||||||
|
GrantedAt *time.Time `gorm:"column:granted_at;comment:写入获取账本时间"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_orders_user_status_created,priority:3;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenOrder) TableName() string {
|
||||||
|
return "token_orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrant 是 Token 获取账本表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 记录购买、论坛点赞奖励、论坛导入奖励等 Token 获取事实;
|
||||||
|
// 2. event_id 是最终幂等边界,避免订单或 outbox 重试重复发放;
|
||||||
|
// 3. P0 不直接修改 users 表,quota_applied 默认为 false。
|
||||||
|
type TokenGrant struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_token_grants_event;comment:幂等事件ID"`
|
||||||
|
UserID uint64 `gorm:"column:user_id;not null;index:idx_token_grants_user_source_created,priority:1;comment:获得Token的用户ID"`
|
||||||
|
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_token_grants_user_source_created,priority:2;comment:purchase/forum_like/forum_import/manual"`
|
||||||
|
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:前端展示来源"`
|
||||||
|
SourceRefID *uint64 `gorm:"column:source_ref_id;index:idx_token_grants_source_ref;comment:来源业务ID,如order_id/post_id/import_id"`
|
||||||
|
OrderID *uint64 `gorm:"column:order_id;index:idx_token_grants_order;comment:购买订单ID,非购买来源为空"`
|
||||||
|
Amount int64 `gorm:"column:amount;not null;comment:获取Token数量"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'recorded';index:idx_token_grants_status;comment:recorded/applied/skipped/failed"`
|
||||||
|
QuotaApplied bool `gorm:"column:quota_applied;not null;default:false;comment:是否已同步到user/auth权威额度"`
|
||||||
|
Description string `gorm:"column:description;type:varchar(255);comment:前端展示描述"`
|
||||||
|
AppliedAt *time.Time `gorm:"column:applied_at;comment:同步到权威额度时间"`
|
||||||
|
LastError *string `gorm:"column:last_error;type:text;comment:后续同步失败原因"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_grants_user_source_created,priority:3;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenGrant) TableName() string {
|
||||||
|
return "token_grants"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenRewardRule 是社区奖励规则表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 可用 seed 初始化点赞、导入奖励额度;
|
||||||
|
// 2. 不提供管理后台,规则调整先通过配置或 seed 变更;
|
||||||
|
// 3. 规则命中后的最终发放仍以 token_grants.event_id 幂等为准。
|
||||||
|
type TokenRewardRule struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_token_reward_rules_source;comment:forum_like/forum_import"`
|
||||||
|
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
|
||||||
|
Amount int64 `gorm:"column:amount;not null;comment:奖励Token数量"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_reward_rules_status;comment:active/inactive"`
|
||||||
|
ConfigJSON *string `gorm:"column:config_json;type:json;comment:预留扩展配置"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TokenRewardRule) TableName() string {
|
||||||
|
return "token_reward_rules"
|
||||||
|
}
|
||||||
72
backend/services/tokenstore/rpc/errors.go
Normal file
72
backend/services/tokenstore/rpc/errors.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/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
|
||||||
|
}
|
||||||
288
backend/services/tokenstore/rpc/handler.go
Normal file
288
backend/services/tokenstore/rpc/handler.go
Normal 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
|
||||||
|
}
|
||||||
209
backend/services/tokenstore/rpc/pb/tokenstore.pb.go
Normal file
209
backend/services/tokenstore/rpc/pb/tokenstore.pb.go
Normal 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() {}
|
||||||
171
backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go
Normal file
171
backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go
Normal 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",
|
||||||
|
}
|
||||||
73
backend/services/tokenstore/rpc/server.go
Normal file
73
backend/services/tokenstore/rpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||||
|
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
|
||||||
|
"github.com/zeromicro/go-zero/core/service"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListenOn = "0.0.0.0: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
|
||||||
|
}
|
||||||
141
backend/services/tokenstore/rpc/tokenstore.proto
Normal file
141
backend/services/tokenstore/rpc/tokenstore.proto
Normal 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;
|
||||||
|
}
|
||||||
107
backend/services/tokenstore/sv/service.go
Normal file
107
backend/services/tokenstore/sv/service.go
Normal 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
|
||||||
|
}
|
||||||
143
backend/shared/contracts/taskclassforum/types.go
Normal file
143
backend/shared/contracts/taskclassforum/types.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package taskclassforum
|
||||||
|
|
||||||
|
// PageResult 是计划广场分页响应的跨层契约。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只描述分页元数据,不负责查询和排序逻辑;
|
||||||
|
// 2. Items 由具体接口决定,避免为了 P0 引入复杂泛型到 RPC 边界;
|
||||||
|
// 3. HTTP 层和 RPC 层需要保持字段语义一致。
|
||||||
|
type PageResult struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBrief 是计划广场前端展示作者和评论人的最小用户信息。
|
||||||
|
type UserBrief struct {
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateSummary 是列表卡片里的模板摘要。
|
||||||
|
type TemplateSummary struct {
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
StrategyLabels []string `json:"strategy_labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostCounters 是帖子计数字段快照。
|
||||||
|
type ForumPostCounters struct {
|
||||||
|
LikeCount int64 `json:"like_count"`
|
||||||
|
CommentCount int64 `json:"comment_count"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostViewerState 是当前登录用户相对该帖子的状态。
|
||||||
|
type ForumPostViewerState struct {
|
||||||
|
Liked bool `json:"liked"`
|
||||||
|
ImportedOnce bool `json:"imported_once"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumTagItem 是计划广场标签筛选区的最小展示单元。
|
||||||
|
type ForumTagItem struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
PostCount int `json:"post_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostBrief 是计划列表和详情头部共用的帖子摘要。
|
||||||
|
type ForumPostBrief struct {
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
TemplateSummary TemplateSummary `json:"template_summary"`
|
||||||
|
Counters ForumPostCounters `json:"counters"`
|
||||||
|
ViewerState ForumPostViewerState `json:"viewer_state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateItemPreview 是详情页展示的任务条目快照。
|
||||||
|
type TemplateItemPreview struct {
|
||||||
|
ItemID uint64 `json:"item_id"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateDetail 是论坛模板快照的前端展示结构。
|
||||||
|
type TemplateDetail struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
StrategyLabels []string `json:"strategy_labels"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
ItemsPreview []TemplateItemPreview `json:"items_preview"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumPostDetail 是计划详情接口响应主体。
|
||||||
|
type ForumPostDetail struct {
|
||||||
|
Post ForumPostBrief `json:"post"`
|
||||||
|
Template TemplateDetail `json:"template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForumCommentNode 是服务层组装后的多层评论树节点。
|
||||||
|
type ForumCommentNode struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
CanDelete bool `json:"can_delete"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
Children []ForumCommentNode `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumPostRequest 是发布计划请求契约。
|
||||||
|
type CreateForumPostRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumCommentRequest 是发表评论或回复请求契约。
|
||||||
|
type CreateForumCommentRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportForumPostRequest 是一键导入请求契约。
|
||||||
|
type ImportForumPostRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteForumCommentResult 是删除评论后的状态回执。
|
||||||
|
type DeleteForumCommentResult struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportForumPostResult 是一键导入后的回执。
|
||||||
|
type ImportForumPostResult struct {
|
||||||
|
ImportID uint64 `json:"import_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
NewTaskClassID uint64 `json:"new_task_class_id"`
|
||||||
|
TaskClassTitle string `json:"task_class_title"`
|
||||||
|
ImportCount int64 `json:"import_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
109
backend/shared/contracts/tokenstore/types.go
Normal file
109
backend/shared/contracts/tokenstore/types.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package tokenstore
|
||||||
|
|
||||||
|
// PageResult 是 token-store 分页响应的跨层契约。
|
||||||
|
type PageResult struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenSummary 是 Token 商店概览响应。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. P0 展示 token-store 已记录的获取事实;
|
||||||
|
// 2. 不承诺这些 Token 已经同步到 user/auth 权威额度;
|
||||||
|
// 3. 后续接入 user/auth 后可把 QuotaSyncStatus 调整为 synced。
|
||||||
|
type TokenSummary struct {
|
||||||
|
RecordedTokenTotal int64 `json:"recorded_token_total"`
|
||||||
|
AppliedTokenTotal int64 `json:"applied_token_total"`
|
||||||
|
PendingApplyTokenTotal int64 `json:"pending_apply_token_total"`
|
||||||
|
QuotaSyncStatus string `json:"quota_sync_status"`
|
||||||
|
Tip string `json:"tip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenProductView 是商品卡片展示结构。
|
||||||
|
type TokenProductView struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
PriceCent int64 `json:"price_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrantView 是 Token 获取记录展示结构。
|
||||||
|
type TokenGrantView struct {
|
||||||
|
GrantID uint64 `json:"grant_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceLabel string `json:"source_label"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
QuotaApplied bool `json:"quota_applied"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenOrderView 是订单展示结构。
|
||||||
|
type TokenOrderView struct {
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMode string `json:"payment_mode"`
|
||||||
|
Grant *TokenGrantView `json:"grant"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PaidAt *string `json:"paid_at"`
|
||||||
|
GrantedAt *string `json:"granted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenOrderRequest 是创建订单请求契约。
|
||||||
|
type CreateTokenOrderRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenOrdersRequest 是订单列表查询契约。
|
||||||
|
type ListTokenOrdersRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockPaidOrderRequest 是 P0 mock paid 请求契约。
|
||||||
|
type MockPaidOrderRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
MockChannel string `json:"mock_channel"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokenGrantsRequest 是 Token 获取记录列表查询契约。
|
||||||
|
type ListTokenGrantsRequest struct {
|
||||||
|
ActorUserID uint64 `json:"actor_user_id"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGrantRecord 是 token-store 内部发放出口使用的获取事实。
|
||||||
|
type TokenGrantRecord struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceRefID uint64 `json:"source_ref_id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# 学习计划论坛与 Token 商店 PRD
|
|
||||||
|
|
||||||
## 1. 文档定位
|
|
||||||
|
|
||||||
本文只记录当前需要讨论和快速推进的核心产品口径,不展开完整交互稿、运营后台和支付细节。
|
|
||||||
|
|
||||||
本轮目标是新增两个终态服务模块:
|
|
||||||
|
|
||||||
1. `taskclass-forum`:支持用户分享 TaskClass 学习计划,并让其他用户一键导入。
|
|
||||||
2. `token-store`:支持 Token 商品购买、活动奖励和发放账本。
|
|
||||||
|
|
||||||
两个模块后续都放在 `backend/services` 下,以独立服务为目标设计;当前仓库工作区未干净前只讨论 PRD,不进入代码实现。
|
|
||||||
|
|
||||||
## 2. 背景与目标
|
|
||||||
|
|
||||||
当前产品已经具备用户自建 TaskClass、智能排程和 Token 额度门禁能力。下一阶段希望补上社区化与商业化闭环:
|
|
||||||
|
|
||||||
1. 用户可以把自己的复习计划分享出去,形成可浏览、可点赞、可评论、可复用的学习计划论坛。
|
|
||||||
2. 其他用户可以一键导入计划模板,快速生成自己的 TaskClass。
|
|
||||||
3. 被点赞、被导入等社区行为可以转化为 Token 激励。
|
|
||||||
4. 用户可以通过 Token 商店购买或领取 Token,为后续高频 Agent 使用建立基础商业闭环。
|
|
||||||
|
|
||||||
## 3. 模块一:学习计划论坛
|
|
||||||
|
|
||||||
### 3.1 产品定位
|
|
||||||
|
|
||||||
学习计划论坛不是普通帖子论坛,而是“帖子 + TaskClass 模板快照”的社区。
|
|
||||||
|
|
||||||
用户发布时,系统从用户自己的 TaskClass 复制一份模板快照。其他用户导入时,再从快照生成自己的 TaskClass 副本。
|
|
||||||
|
|
||||||
快照原则:
|
|
||||||
|
|
||||||
1. 发布后不直接引用原作者的 `task_classes` / `task_items`。
|
|
||||||
2. 原作者后续修改自己的计划,不影响已发布模板。
|
|
||||||
3. 导入用户拿到的是自己的 TaskClass 副本,后续可自由编辑。
|
|
||||||
4. 不分享 `embedded_time`、schedule 绑定、用户私有排程状态。
|
|
||||||
|
|
||||||
### 3.2 P0 功能
|
|
||||||
|
|
||||||
1. 发布学习计划:用户选择一个 TaskClass,填写标题、简介、标签后发布。
|
|
||||||
2. 浏览列表:支持分页查看公开计划,按最新、点赞数、导入数排序。
|
|
||||||
3. 查看详情:展示计划说明、TaskClass 配置摘要和任务条目预览。
|
|
||||||
4. 点赞:同一用户对同一帖子只能点赞一次,可取消点赞。
|
|
||||||
5. 评论:支持基础评论列表和发表评论,P0 不做楼中楼。
|
|
||||||
6. 一键导入:从论坛模板复制出当前用户自己的 TaskClass。
|
|
||||||
7. 基础激励:模板获得点赞或导入后,可触发 Token 奖励事件。
|
|
||||||
|
|
||||||
### 3.3 P0 不做
|
|
||||||
|
|
||||||
1. 不做复杂推荐算法。
|
|
||||||
2. 不做关注、私信、用户主页。
|
|
||||||
3. 不做富文本编辑器,先用纯文本简介。
|
|
||||||
4. 不做审核后台,先预留状态字段。
|
|
||||||
5. 不直接把模板应用进 schedule;导入后由用户走现有 TaskClass / 排程链路。
|
|
||||||
|
|
||||||
### 3.4 核心实体
|
|
||||||
|
|
||||||
1. `forum_posts`:帖子主体,记录作者、标题、简介、状态、点赞数、评论数、导入数。
|
|
||||||
2. `forum_post_templates`:TaskClass 快照,记录模式、日期范围、策略、约束配置等。
|
|
||||||
3. `forum_post_template_items`:TaskClassItem 快照,只记录 order/content 等模板信息。
|
|
||||||
4. `forum_likes`:点赞幂等记录。
|
|
||||||
5. `forum_comments`:评论记录。
|
|
||||||
6. `forum_imports`:导入记录,记录从哪个帖子导入到哪个用户和新 TaskClass ID。
|
|
||||||
|
|
||||||
### 3.5 关键流程
|
|
||||||
|
|
||||||
发布流程:
|
|
||||||
|
|
||||||
1. 用户选择自己的 TaskClass。
|
|
||||||
2. `taskclass-forum` 通过 TaskClass 读取端口拿到完整模板。
|
|
||||||
3. 服务过滤私有字段,生成论坛快照。
|
|
||||||
4. 写入帖子和模板快照。
|
|
||||||
|
|
||||||
导入流程:
|
|
||||||
|
|
||||||
1. 用户点击一键导入。
|
|
||||||
2. `taskclass-forum` 读取帖子模板快照。
|
|
||||||
3. 通过 TaskClass 写入端口为当前用户创建 TaskClass 副本。
|
|
||||||
4. 写入导入记录并增加导入计数。
|
|
||||||
5. 可异步发布 Token 奖励事件。
|
|
||||||
|
|
||||||
## 4. 模块二:Token 商店
|
|
||||||
|
|
||||||
### 4.1 产品定位
|
|
||||||
|
|
||||||
Token 商店负责 Token 的购买、奖励、发放和账本,不负责登录鉴权,也不直接承载 Agent 消耗统计。
|
|
||||||
|
|
||||||
`user/auth` 继续负责用户 Token quota 的权威判断;`token-store` 只负责产生“发放 Token”的业务事实,并通过跨服务契约通知 `user/auth` 增加用户额度。
|
|
||||||
|
|
||||||
### 4.2 P0 功能
|
|
||||||
|
|
||||||
1. 商品列表:展示可购买 Token 包。
|
|
||||||
2. 创建订单:用户选择商品生成订单。
|
|
||||||
3. 支付确认:P0 先支持 mock paid 或管理端确认 paid,不接真实支付网关。
|
|
||||||
4. Token 发放:订单支付成功后发放 Token。
|
|
||||||
5. 奖励发放:支持论坛点赞、导入等事件触发奖励。
|
|
||||||
6. 发放账本:所有发放必须有幂等 event_id,避免重复加额度。
|
|
||||||
|
|
||||||
### 4.3 P0 不做
|
|
||||||
|
|
||||||
1. 不接真实微信 / 支付宝 / Stripe。
|
|
||||||
2. 不做退款、发票、优惠券。
|
|
||||||
3. 不做复杂会员体系。
|
|
||||||
4. 不直接改 `users.token_usage`,避免和消费统计混淆。
|
|
||||||
|
|
||||||
### 4.4 核心实体
|
|
||||||
|
|
||||||
1. `token_products`:Token 商品。
|
|
||||||
2. `token_orders`:订单。
|
|
||||||
3. `token_grants`:Token 发放账本,记录购买、奖励、补偿等来源。
|
|
||||||
4. `token_reward_rules`:奖励规则,P0 可先用配置或简单表。
|
|
||||||
|
|
||||||
### 4.5 关键流程
|
|
||||||
|
|
||||||
购买流程:
|
|
||||||
|
|
||||||
1. 用户选择商品并创建订单。
|
|
||||||
2. 订单进入 `pending`。
|
|
||||||
3. P0 通过 mock paid 或管理端确认,把订单置为 `paid`。
|
|
||||||
4. `token-store` 写入 token grant 账本。
|
|
||||||
5. `token-store` 调用 `user/auth` 的额度发放能力。
|
|
||||||
6. 发放成功后订单进入 `granted`。
|
|
||||||
|
|
||||||
奖励流程:
|
|
||||||
|
|
||||||
1. 论坛产生点赞或导入事件。
|
|
||||||
2. `token-store` 按奖励规则判断是否发放。
|
|
||||||
3. 写入 token grant 账本。
|
|
||||||
4. 调用 `user/auth` 增加额度。
|
|
||||||
|
|
||||||
## 5. 服务边界
|
|
||||||
|
|
||||||
### 5.1 `taskclass-forum`
|
|
||||||
|
|
||||||
负责:
|
|
||||||
|
|
||||||
1. 论坛帖子、点赞、评论、导入记录。
|
|
||||||
2. TaskClass 模板快照。
|
|
||||||
3. 导入时的模板复制编排。
|
|
||||||
4. 发布社区行为事件,供 Token 激励消费。
|
|
||||||
|
|
||||||
不负责:
|
|
||||||
|
|
||||||
1. TaskClass 原始表所有权。
|
|
||||||
2. schedule 写入和排程应用。
|
|
||||||
3. Token 额度发放。
|
|
||||||
4. 用户登录鉴权。
|
|
||||||
|
|
||||||
### 5.2 `token-store`
|
|
||||||
|
|
||||||
负责:
|
|
||||||
|
|
||||||
1. 商品、订单、发放账本。
|
|
||||||
2. 社区奖励规则。
|
|
||||||
3. 幂等发放。
|
|
||||||
4. 调用 `user/auth` 增加 Token 额度。
|
|
||||||
|
|
||||||
不负责:
|
|
||||||
|
|
||||||
1. JWT、登录、注册。
|
|
||||||
2. Agent 消耗统计。
|
|
||||||
3. TaskClass 论坛内容。
|
|
||||||
4. 真实第三方支付回调,P0 只预留状态机。
|
|
||||||
|
|
||||||
### 5.3 与现有服务关系
|
|
||||||
|
|
||||||
1. 论坛读取和导入 TaskClass 时,先通过端口适配旧 `TaskClassService/DAO`。
|
|
||||||
2. 后续 `task-class` 独立成服务后,只替换端口适配器。
|
|
||||||
3. 论坛 P0 不直接写 schedule,避免被 `schedule` 未拆服务影响。
|
|
||||||
4. Token 商店不直接改 users 表,通过 `user/auth` 契约发放额度。
|
|
||||||
|
|
||||||
## 6. 事件与激励
|
|
||||||
|
|
||||||
P0 建议事件:
|
|
||||||
|
|
||||||
1. `forum.post.liked`:帖子被点赞。
|
|
||||||
2. `forum.post.imported`:帖子被导入。
|
|
||||||
3. `token.grant.requested`:请求发放 Token。
|
|
||||||
4. `token.grant.completed`:Token 发放完成。
|
|
||||||
|
|
||||||
奖励口径先从简单规则开始:
|
|
||||||
|
|
||||||
1. 每个帖子每个用户首次点赞只奖励一次。
|
|
||||||
2. 每个帖子每个用户首次导入只奖励一次。
|
|
||||||
3. 同一 event_id 的 Token 发放必须幂等。
|
|
||||||
4. 奖励额度先走配置,不在 PRD 阶段定死。
|
|
||||||
|
|
||||||
## 7. 当前推进策略
|
|
||||||
|
|
||||||
1. 当前工作区存在其它拆服务改动,本阶段只提交 PRD。
|
|
||||||
2. 等工作区干净后,从集成分支新开功能分支或单独 git worktree。
|
|
||||||
3. 实现时两个服务主体可以并行推进。
|
|
||||||
4. `gateway/router`、`shared/contracts`、`shared/ports`、`outbox route`、`config` 由主代理统一收口。
|
|
||||||
5. 先做 P0 闭环,再扩展审核、真实支付和推荐排序。
|
|
||||||
|
|
||||||
## 8. 待讨论问题
|
|
||||||
|
|
||||||
1. 论坛展示名使用“学习计划论坛”“计划广场”还是“模板市场”。
|
|
||||||
2. 点赞奖励是否给作者、点赞者,还是双方都给。
|
|
||||||
3. 导入奖励是否需要上限,避免刷导入。
|
|
||||||
4. 评论是否需要删除、举报、审核状态。
|
|
||||||
5. Token 发放应增加 `user/auth` 的 `GrantTokenQuota`,还是命名为 `AdjustTokenLimit`。
|
|
||||||
6. P0 是否需要前端先隐藏评论,只保留后端能力。
|
|
||||||
816
docs/backend/计划广场与Token商店后端实施方案.md
Normal file
816
docs/backend/计划广场与Token商店后端实施方案.md
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
# 计划广场与 Token 商店后端实施方案
|
||||||
|
|
||||||
|
## 1. 文档定位
|
||||||
|
|
||||||
|
本文记录当前需要讨论和快速推进的核心产品口径、前后端接口契约、服务边界、事件口径和实施计划,不展开完整交互稿、运营后台和真实支付细节。
|
||||||
|
|
||||||
|
本轮目标是新增两个终态服务模块:
|
||||||
|
|
||||||
|
1. `taskclass-forum`:支持用户分享 TaskClass 学习计划,并让其他用户一键导入。
|
||||||
|
2. `token-store`:支持 Token 商品购买、活动奖励和发放账本。
|
||||||
|
|
||||||
|
两个模块后续都放在 `backend/services` 下,以独立服务为目标设计;当前仓库工作区未干净前只讨论方案,不进入代码实现。
|
||||||
|
|
||||||
|
## 2. 背景与目标
|
||||||
|
|
||||||
|
当前产品已经具备用户自建 TaskClass、智能排程和 Token 额度门禁能力。下一阶段希望补上社区化与商业化闭环:
|
||||||
|
|
||||||
|
1. 用户可以把自己的复习计划分享出去,形成可浏览、可点赞、可评论、可复用的计划广场。
|
||||||
|
2. 其他用户可以一键导入计划模板,快速生成自己的 TaskClass。
|
||||||
|
3. 被点赞、被导入等社区行为可以转化为 Token 激励。
|
||||||
|
4. 用户可以通过 Token 商店购买或领取 Token,为后续高频 Agent 使用建立基础商业闭环。
|
||||||
|
|
||||||
|
## 3. 模块一:学习计划论坛
|
||||||
|
|
||||||
|
### 3.1 产品定位
|
||||||
|
|
||||||
|
计划广场不是普通帖子论坛,而是“帖子 + TaskClass 模板快照”的社区。
|
||||||
|
|
||||||
|
对外展示名先使用“计划广场”。
|
||||||
|
|
||||||
|
用户发布时,系统从用户自己的 TaskClass 复制一份模板快照。其他用户导入时,再从快照生成自己的 TaskClass 副本。
|
||||||
|
|
||||||
|
快照原则:
|
||||||
|
|
||||||
|
1. 发布后不直接引用原作者的 `task_classes` / `task_items`。
|
||||||
|
2. 原作者后续修改自己的计划,不影响已发布模板。
|
||||||
|
3. 导入用户拿到的是自己的 TaskClass 副本,后续可自由编辑。
|
||||||
|
4. 不分享 `embedded_time`、schedule 绑定、用户私有排程状态。
|
||||||
|
|
||||||
|
### 3.2 P0 功能
|
||||||
|
|
||||||
|
1. 发布学习计划:用户选择一个 TaskClass,填写标题、简介、标签后发布。
|
||||||
|
2. 浏览列表:支持分页查看公开计划,按最新、点赞数、导入数排序。
|
||||||
|
3. 查看详情:展示计划说明、TaskClass 配置摘要和任务条目预览。
|
||||||
|
4. 点赞:同一用户对同一帖子只能点赞一次,可取消点赞。
|
||||||
|
5. 评论:支持发表评论、多层回复和删除自己的评论,接口返回评论树 JSON。
|
||||||
|
6. 一键导入:从论坛模板复制出当前用户自己的 TaskClass。
|
||||||
|
7. 基础激励:模板获得点赞或导入后,可触发 Token 奖励事件。
|
||||||
|
|
||||||
|
### 3.3 P0 不做
|
||||||
|
|
||||||
|
1. 不做复杂推荐算法。
|
||||||
|
2. 不做关注、私信、用户主页。
|
||||||
|
3. 不做富文本编辑器,先用纯文本简介。
|
||||||
|
4. 不做审核后台和管理员删评,先预留状态字段。
|
||||||
|
5. 不直接把模板应用进 schedule;导入后由用户走现有 TaskClass / 排程链路。
|
||||||
|
6. P0 暂不做评论举报和管理员审核流。
|
||||||
|
|
||||||
|
### 3.4 核心实体
|
||||||
|
|
||||||
|
1. `forum_posts`:帖子主体,记录作者、标题、简介、状态、点赞数、评论数、导入数。
|
||||||
|
2. `forum_post_templates`:TaskClass 快照,记录模式、日期范围、策略、约束配置等。
|
||||||
|
3. `forum_post_template_items`:TaskClassItem 快照,只记录 order/content 等模板信息。
|
||||||
|
4. `forum_likes`:点赞幂等记录。
|
||||||
|
5. `forum_comments`:评论记录,使用 `parent_comment_id` 表达多层回复关系。
|
||||||
|
6. `forum_imports`:导入记录,记录从哪个帖子导入到哪个用户和新 TaskClass ID。
|
||||||
|
|
||||||
|
### 3.5 关键流程
|
||||||
|
|
||||||
|
发布流程:
|
||||||
|
|
||||||
|
1. 用户选择自己的 TaskClass。
|
||||||
|
2. `taskclass-forum` 通过 TaskClass 读取端口拿到完整模板。
|
||||||
|
3. 服务过滤私有字段,生成论坛快照。
|
||||||
|
4. 写入帖子和模板快照。
|
||||||
|
|
||||||
|
评论流程:
|
||||||
|
|
||||||
|
1. 用户可以直接评论帖子,也可以回复任意一条评论。
|
||||||
|
2. 评论表用 `parent_comment_id` 记录父评论,根评论的 `parent_comment_id` 为空。
|
||||||
|
3. 列表查询先按帖子读取扁平评论,再由服务层组装成多层评论树。
|
||||||
|
4. 删除评论时 P0 只允许用户删除自己的评论,并采用软删除,保留子回复结构,避免整棵回复树断链。
|
||||||
|
5. P0 暂不引入管理员删评、举报和审核流,后续如需治理再单独扩展。
|
||||||
|
|
||||||
|
导入流程:
|
||||||
|
|
||||||
|
1. 用户点击一键导入。
|
||||||
|
2. `taskclass-forum` 读取帖子模板快照。
|
||||||
|
3. 通过 TaskClass 写入端口为当前用户创建 TaskClass 副本。
|
||||||
|
4. 写入导入记录并增加导入计数。
|
||||||
|
5. 可异步发布 Token 奖励事件。
|
||||||
|
|
||||||
|
## 4. 模块二:Token 商店
|
||||||
|
|
||||||
|
### 4.1 产品定位
|
||||||
|
|
||||||
|
Token 商店负责 Token 的购买、奖励、发放和账本,不负责登录鉴权,也不直接承载 Agent 消耗统计。
|
||||||
|
|
||||||
|
`user/auth` 继续负责用户 Token quota 的权威判断;`token-store` 只负责产生“获取 Token / 发放 Token”的业务事实和账本。本轮不新增或修改 `user/auth` 契约,先在 `token-store` 内封装 Token 获取途径和后续发放出口,等主线合并稳定后再切到 `user/auth` 的权威额度发放能力。
|
||||||
|
|
||||||
|
### 4.2 P0 功能
|
||||||
|
|
||||||
|
1. 商品列表:展示可购买 Token 包。
|
||||||
|
2. 创建订单:用户选择商品生成订单。
|
||||||
|
3. 支付确认:P0 先支持 mock paid 或管理端确认 paid,不接真实支付网关。
|
||||||
|
4. Token 发放记录:订单支付成功后写入发放账本,并通过本服务内部端口封装后续发放出口。
|
||||||
|
5. 奖励发放:支持论坛点赞、导入等事件触发奖励。
|
||||||
|
6. 发放账本:所有发放必须有幂等 event_id,避免后续重复加额度。
|
||||||
|
|
||||||
|
### 4.3 P0 不做
|
||||||
|
|
||||||
|
1. 不接真实微信 / 支付宝 / Stripe。
|
||||||
|
2. 不做退款、发票、优惠券。
|
||||||
|
3. 不做复杂会员体系。
|
||||||
|
4. 不直接改 `users.token_usage`,避免和消费统计混淆。
|
||||||
|
|
||||||
|
### 4.4 核心实体
|
||||||
|
|
||||||
|
1. `token_products`:Token 商品,P0 从表读取,并通过 seed 初始化 2-3 个商品,不做管理后台。
|
||||||
|
2. `token_orders`:订单。
|
||||||
|
3. `token_grants`:Token 发放账本,记录购买、奖励、补偿等来源。
|
||||||
|
4. `token_reward_rules`:奖励规则,P0 可先用配置或简单表。
|
||||||
|
|
||||||
|
### 4.5 关键流程
|
||||||
|
|
||||||
|
购买流程:
|
||||||
|
|
||||||
|
1. 用户选择商品并创建订单。
|
||||||
|
2. 订单进入 `pending`。
|
||||||
|
3. P0 通过 mock paid 或管理端确认,把订单置为 `paid`。
|
||||||
|
4. `token-store` 写入 token grant 账本。
|
||||||
|
5. `token-store` 通过内部发放端口记录本次 Token 获取事实,本轮不修改 `user/auth`。
|
||||||
|
6. 发放记录写入成功后订单进入 `granted`;后续切到 `user/auth` 时只替换发放端口实现。
|
||||||
|
|
||||||
|
奖励流程:
|
||||||
|
|
||||||
|
1. 论坛产生点赞或导入事件。
|
||||||
|
2. `token-store` 按奖励规则判断是否发放。
|
||||||
|
3. 写入 token grant 账本。
|
||||||
|
4. 通过内部发放端口记录奖励获取事实;后续切到 `user/auth` 时只替换发放端口实现。
|
||||||
|
|
||||||
|
## 5. 服务边界
|
||||||
|
|
||||||
|
### 5.1 `taskclass-forum`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
1. 论坛帖子、点赞、评论、导入记录。
|
||||||
|
2. TaskClass 模板快照。
|
||||||
|
3. 导入时的模板复制编排。
|
||||||
|
4. 发布社区行为事件,供 Token 激励消费。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
1. TaskClass 原始表所有权。
|
||||||
|
2. schedule 写入和排程应用。
|
||||||
|
3. Token 额度发放。
|
||||||
|
4. 用户登录鉴权。
|
||||||
|
|
||||||
|
### 5.2 `token-store`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
1. 商品、订单、发放账本。
|
||||||
|
2. 社区奖励规则。
|
||||||
|
3. 幂等发放。
|
||||||
|
4. 封装 Token 获取途径和后续发放出口。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
1. JWT、登录、注册。
|
||||||
|
2. Agent 消耗统计。
|
||||||
|
3. TaskClass 论坛内容。
|
||||||
|
4. 真实第三方支付回调,P0 只预留状态机。
|
||||||
|
|
||||||
|
### 5.3 与现有服务关系
|
||||||
|
|
||||||
|
1. 论坛读取和导入 TaskClass 时,先通过端口适配旧 `TaskClassService/DAO`。
|
||||||
|
2. 后续 `task-class` 独立成服务后,只替换端口适配器。
|
||||||
|
3. 论坛 P0 不直接写 schedule,避免被 `schedule` 未拆服务影响。
|
||||||
|
4. Token 商店本轮不直接改 users 表,也不新增或修改 `user/auth` 契约;先封装自己的 Token 获取途径和后续发放出口,等合并后再切到 `user/auth`。
|
||||||
|
|
||||||
|
### 5.4 并行迁移细则
|
||||||
|
|
||||||
|
1. 当前 `task` / `task-class` 还没有完成服务迁移时,本轮不改它们的核心模块,也不提前给它们补新的 RPC 边界,避免和另一条拆分线发生合并冲突。
|
||||||
|
2. `taskclass-forum` 的实现层可以先通过 legacy adapter 复用现有 DAO / Service,必要时也可以直接读旧表,但这类直连只能收敛在 adapter 内,不能扩散到论坛业务层。
|
||||||
|
3. 论坛业务层只依赖“读取 TaskClass 快照”和“写入 TaskClass 副本”这类端口,不关心底层是 DAO、Service 还是后续 RPC。
|
||||||
|
4. 等 `task-class` 侧完成独立服务后,论坛只替换 adapter,不回改论坛领域逻辑,不重写帖子、点赞、评论和导入编排。
|
||||||
|
5. 这轮优先保证论坛和 Token 商店自己的边界稳定,`task` 相关能力保持当前状态即可,等主线合并后再按统一节奏推进下一步迁移。
|
||||||
|
|
||||||
|
### 5.5 理想内部结构
|
||||||
|
|
||||||
|
参考 `userauth` 样板和微服务迁移总纲,两个新模块都按“`cmd` 进程入口 + `gateway/api` HTTP 适配 + `gateway/client` RPC client + `services` 服务主体”的结构推进。
|
||||||
|
|
||||||
|
命名约定:
|
||||||
|
|
||||||
|
1. 产品和事件域继续使用 `taskclass-forum`、`token-store`,方便和方案、topic、事件名保持一致。
|
||||||
|
2. Go 目录和包名建议使用 `taskclassforum`、`tokenstore`,对齐当前 `userauth` 样板,避免包名里出现连字符。
|
||||||
|
3. 如果后续统一目录规范改成带连字符目录,也只调整目录名和 import,不改变服务内职责分层。
|
||||||
|
|
||||||
|
推荐目标树:
|
||||||
|
|
||||||
|
这里是两套服务边缘入口:
|
||||||
|
|
||||||
|
1. `gateway/api` 下放 HTTP API 入口,每个服务一个子目录。
|
||||||
|
2. `gateway/client` 下放 zrpc client / error 反解,每个服务一个子目录。
|
||||||
|
3. 旧 `gateway/userapi`、`gateway/userauth` 暂不在本轮调整,后续合并时再统一处理。
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── taskclassforum/
|
||||||
|
│ │ └── main.go
|
||||||
|
│ └── tokenstore/
|
||||||
|
│ └── main.go
|
||||||
|
├── gateway/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── taskclassforum/
|
||||||
|
│ │ │ ├── handler.go
|
||||||
|
│ │ │ └── routes.go
|
||||||
|
│ │ └── tokenstore/
|
||||||
|
│ │ ├── handler.go
|
||||||
|
│ │ └── routes.go
|
||||||
|
│ └── client/
|
||||||
|
│ ├── taskclassforum/
|
||||||
|
│ │ ├── client.go
|
||||||
|
│ │ └── errors.go
|
||||||
|
│ └── tokenstore/
|
||||||
|
│ ├── client.go
|
||||||
|
│ └── errors.go
|
||||||
|
├── services/
|
||||||
|
│ ├── taskclassforum/
|
||||||
|
│ │ ├── sv/
|
||||||
|
│ │ │ ├── service.go
|
||||||
|
│ │ │ ├── post.go
|
||||||
|
│ │ │ ├── like.go
|
||||||
|
│ │ │ ├── comment.go
|
||||||
|
│ │ │ └── import.go
|
||||||
|
│ │ ├── dao/
|
||||||
|
│ │ │ ├── connect.go
|
||||||
|
│ │ │ ├── post.go
|
||||||
|
│ │ │ ├── template.go
|
||||||
|
│ │ │ ├── like.go
|
||||||
|
│ │ │ ├── comment.go
|
||||||
|
│ │ │ └── import.go
|
||||||
|
│ │ ├── model/
|
||||||
|
│ │ │ ├── forum_post.go
|
||||||
|
│ │ │ ├── forum_post_template.go
|
||||||
|
│ │ │ ├── forum_post_template_item.go
|
||||||
|
│ │ │ ├── forum_like.go
|
||||||
|
│ │ │ ├── forum_comment.go
|
||||||
|
│ │ │ └── forum_import.go
|
||||||
|
│ │ ├── internal/
|
||||||
|
│ │ │ ├── adapter/
|
||||||
|
│ │ │ ├── snapshot/
|
||||||
|
│ │ │ ├── commenttree/
|
||||||
|
│ │ │ └── event/
|
||||||
|
│ │ └── rpc/
|
||||||
|
│ │ ├── pb/
|
||||||
|
│ │ ├── taskclassforum.proto
|
||||||
|
│ │ ├── errors.go
|
||||||
|
│ │ ├── handler.go
|
||||||
|
│ │ └── server.go
|
||||||
|
│ └── tokenstore/
|
||||||
|
│ ├── sv/
|
||||||
|
│ │ ├── service.go
|
||||||
|
│ │ ├── product.go
|
||||||
|
│ │ ├── order.go
|
||||||
|
│ │ ├── grant.go
|
||||||
|
│ │ └── reward.go
|
||||||
|
│ ├── dao/
|
||||||
|
│ │ ├── connect.go
|
||||||
|
│ │ ├── product.go
|
||||||
|
│ │ ├── order.go
|
||||||
|
│ │ ├── grant.go
|
||||||
|
│ │ └── reward_rule.go
|
||||||
|
│ ├── model/
|
||||||
|
│ │ ├── token_product.go
|
||||||
|
│ │ ├── token_order.go
|
||||||
|
│ │ ├── token_grant.go
|
||||||
|
│ │ └── token_reward_rule.go
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── adapter/
|
||||||
|
│ │ ├── paymentmock/
|
||||||
|
│ │ ├── grant/
|
||||||
|
│ │ ├── reward/
|
||||||
|
│ │ └── event/
|
||||||
|
│ └── rpc/
|
||||||
|
│ ├── pb/
|
||||||
|
│ ├── tokenstore.proto
|
||||||
|
│ ├── errors.go
|
||||||
|
│ ├── handler.go
|
||||||
|
│ └── server.go
|
||||||
|
└── shared/
|
||||||
|
├── contracts/
|
||||||
|
│ ├── taskclassforum/
|
||||||
|
│ └── tokenstore/
|
||||||
|
├── events/
|
||||||
|
└── ports/
|
||||||
|
```
|
||||||
|
|
||||||
|
目录职责:
|
||||||
|
|
||||||
|
1. `cmd/<service>/main.go` 只负责读取配置、初始化资源、启动 go-zero zrpc 服务,不承载业务规则。
|
||||||
|
2. `gateway/api/<service>` 只负责 HTTP 参数绑定、鉴权后用户 ID 注入、调用对应 client、复用 `respond` 返回前端,不直接访问服务数据库。
|
||||||
|
3. `gateway/client/<service>` 只放 zrpc client 和错误反解;不要把 client 放进 `cmd`,也不要让 gateway 直接 import 服务端 DAO / model。
|
||||||
|
4. `services/<service>/sv` 是服务主业务编排层,负责事务顺序、幂等判断、状态流转和跨端口调用。
|
||||||
|
5. `services/<service>/dao` 只负责本服务数据库访问;`model` 只放本服务私有表模型,不放跨服务 DTO。
|
||||||
|
6. `services/<service>/internal` 只放服务私有子能力,外部服务禁止直接 import。
|
||||||
|
7. `services/<service>/rpc` 对齐 `userauth`,放 proto、server、handler、errors 和生成后的 `pb`。
|
||||||
|
8. `shared/contracts`、`shared/events`、`shared/ports` 只放跨服务契约、事件 payload 和端口接口,不放 DAO、model、sv 或业务状态机。
|
||||||
|
|
||||||
|
`taskclassforum/internal` 建议职责:
|
||||||
|
|
||||||
|
1. `adapter/`:承载 TaskClass 端口实现。P0 先放 legacy adapter,复用旧 DAO / Service 或临时直读旧表;后续替换为 task-class RPC adapter。
|
||||||
|
2. `snapshot/`:负责 TaskClass 原始数据到论坛模板快照的过滤、复制和字段白名单,避免分享 `embedded_time`、schedule 绑定和私有排程状态。
|
||||||
|
3. `commenttree/`:负责把 `forum_comments` 扁平记录组装成多层评论树,并处理软删除节点的展示兜底。
|
||||||
|
4. `event/`:负责组装 `forum.post.liked`、`forum.post.imported` 等领域事件 payload;事件投递仍走全局 outbox 路由和服务 catalog。
|
||||||
|
|
||||||
|
`tokenstore/internal` 建议职责:
|
||||||
|
|
||||||
|
1. `adapter/`:承载后续 `user/auth` 额度发放适配点。P0 先封装 `token-store` 自己的 Token 获取途径和发放出口,不新增或修改 `user/auth` 契约,也禁止直接改 `users` 表。
|
||||||
|
2. `paymentmock/`:只处理 P0 mock paid 状态确认,不承载真实支付网关逻辑。
|
||||||
|
3. `grant/`:封装 `token_grants` 幂等账本写入、event_id 去重和 grant 状态流转。
|
||||||
|
4. `reward/`:封装论坛点赞、导入等奖励规则判断,P0 可先走配置或简单表。
|
||||||
|
5. `event/`:负责组装 `token.grant.requested`、`token.grant.completed` 等事件 payload;后续真实异步化时复用服务级 outbox 基线。
|
||||||
|
|
||||||
|
## 6. 事件与激励
|
||||||
|
|
||||||
|
P0 建议事件:
|
||||||
|
|
||||||
|
1. `forum.post.liked`:帖子被点赞。
|
||||||
|
2. `forum.post.imported`:帖子被导入。
|
||||||
|
3. `token.grant.requested`:请求发放 Token。
|
||||||
|
4. `token.grant.completed`:Token 发放完成。
|
||||||
|
|
||||||
|
奖励口径先从简单规则开始:
|
||||||
|
|
||||||
|
1. 点赞奖励先只给帖子作者,每个帖子每个用户首次点赞只奖励一次。
|
||||||
|
2. 同一用户对同一计划只允许导入一次,导入奖励随该次导入记录一次。
|
||||||
|
3. 同一 event_id 的 Token 发放必须幂等。
|
||||||
|
4. 奖励额度先走配置,不在本方案阶段定死。
|
||||||
|
|
||||||
|
Outbox 使用口径:
|
||||||
|
|
||||||
|
1. P0 用户可见主链路不强依赖 outbox:发布、点赞、评论、导入、下单、mock paid、写 grant 账本都应在服务自己的同步事务里完成。
|
||||||
|
2. Outbox 用于异步副作用:论坛点赞 / 导入奖励事件、后续搜索索引同步、通知、排行榜刷新、运营统计,以及后续 `token-store` 到 `user/auth` 的权威额度同步。
|
||||||
|
3. 第一版如果为了赶进度先同步写奖励账本,也必须保留事件名、payload 和 `event_id` 规则,后续切到 outbox 消费时不改业务语义。
|
||||||
|
4. 新服务后续接入服务级 outbox 时,建议使用独立表、topic 和 consumer group,不回退到共享 outbox。
|
||||||
|
|
||||||
|
## 7. 后端契约初版
|
||||||
|
|
||||||
|
本节把前端对接草案收束成后端 P0 契约。若后续实现时发现字段和现有模型存在细小出入,允许在保持语义不变的前提下微调字段名,但必须同步更新 `docs/frontend/计划广场与Token商店对接说明.md`。
|
||||||
|
|
||||||
|
### 7.1 通用 HTTP 约定
|
||||||
|
|
||||||
|
1. 所有接口统一挂在 `/api/v1` 下。
|
||||||
|
2. P0 默认都需要登录态,`user_id` 由 gateway 从 JWT 注入,前端不传 `user_id`。
|
||||||
|
3. 响应复用项目统一壳:`status`、`info`、`data`。
|
||||||
|
4. 写接口优先支持 `X-Idempotency-Key`,避免前端重复点击造成重复发布、重复评论、重复导入或重复下单。
|
||||||
|
5. 时间字段统一返回 ISO 8601 字符串,带时区。
|
||||||
|
6. 分页统一使用 `page`、`page_size`、`total`、`has_more`。
|
||||||
|
|
||||||
|
### 7.2 计划广场 HTTP 契约
|
||||||
|
|
||||||
|
| 功能 | 方法 | 路径 | 幂等 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 计划列表 | `GET` | `/api/v1/plan-square/posts` | 否 |
|
||||||
|
| 热门标签 | `GET` | `/api/v1/plan-square/tags` | 否 |
|
||||||
|
| 发布计划 | `POST` | `/api/v1/plan-square/posts` | 是 |
|
||||||
|
| 计划详情 | `GET` | `/api/v1/plan-square/posts/{post_id}` | 否 |
|
||||||
|
| 点赞 | `POST` | `/api/v1/plan-square/posts/{post_id}/like` | 由唯一约束兜底 |
|
||||||
|
| 取消点赞 | `DELETE` | `/api/v1/plan-square/posts/{post_id}/like` | 否 |
|
||||||
|
| 评论树 | `GET` | `/api/v1/plan-square/posts/{post_id}/comments` | 否 |
|
||||||
|
| 发表评论 / 回复 | `POST` | `/api/v1/plan-square/posts/{post_id}/comments` | 是 |
|
||||||
|
| 删除自己的评论 | `DELETE` | `/api/v1/plan-square/comments/{comment_id}` | 否 |
|
||||||
|
| 一键导入 | `POST` | `/api/v1/plan-square/posts/{post_id}/import` | 是 |
|
||||||
|
|
||||||
|
核心请求 DTO:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CreateForumPostRequest struct {
|
||||||
|
TaskClassID uint64 `json:"task_class_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateForumCommentRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportForumPostRequest struct {
|
||||||
|
TargetTitle string `json:"target_title"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
核心响应 DTO:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ForumPostBrief struct {
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
TemplateSummary TemplateSummary `json:"template_summary"`
|
||||||
|
Counters ForumPostCounters `json:"counters"`
|
||||||
|
ViewerState ForumPostViewerState `json:"viewer_state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumPostDetail struct {
|
||||||
|
Post ForumPostBrief `json:"post"`
|
||||||
|
Template TemplateDetail `json:"template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumCommentNode struct {
|
||||||
|
CommentID uint64 `json:"comment_id"`
|
||||||
|
PostID uint64 `json:"post_id"`
|
||||||
|
ParentCommentID *uint64 `json:"parent_comment_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Author UserBrief `json:"author"`
|
||||||
|
CanDelete bool `json:"can_delete"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
Children []ForumCommentNode `json:"children"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段口径:
|
||||||
|
|
||||||
|
1. `ForumPostBrief.status` P0 先使用 `published`,后续审核可扩展 `hidden`、`deleted`、`pending_review`。
|
||||||
|
2. `ForumCommentNode.status` P0 使用 `visible`、`deleted`。
|
||||||
|
3. `CanDelete` 由后端根据当前用户判断,前端只按字段展示删除入口。
|
||||||
|
4. `TemplateDetail.items_preview` 来自论坛快照,不读取原作者当前 TaskClass。
|
||||||
|
5. 一键导入成功只返回新 TaskClass ID,不直接写 schedule。
|
||||||
|
|
||||||
|
### 7.3 Token 商店 HTTP 契约
|
||||||
|
|
||||||
|
| 功能 | 方法 | 路径 | 幂等 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Token 概览 | `GET` | `/api/v1/token-store/summary` | 否 |
|
||||||
|
| 商品列表 | `GET` | `/api/v1/token-store/products` | 否 |
|
||||||
|
| 创建订单 | `POST` | `/api/v1/token-store/orders` | 是 |
|
||||||
|
| 订单列表 | `GET` | `/api/v1/token-store/orders` | 否 |
|
||||||
|
| 订单详情 | `GET` | `/api/v1/token-store/orders/{order_id}` | 否 |
|
||||||
|
| mock paid | `POST` | `/api/v1/token-store/orders/{order_id}/mock-paid` | 是 |
|
||||||
|
| Token 获取记录 | `GET` | `/api/v1/token-store/grants` | 否 |
|
||||||
|
|
||||||
|
核心请求 DTO:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CreateTokenOrderRequest struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockPaidOrderRequest struct {
|
||||||
|
MockChannel string `json:"mock_channel"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
核心响应 DTO:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TokenSummary struct {
|
||||||
|
RecordedTokenTotal int64 `json:"recorded_token_total"`
|
||||||
|
AppliedTokenTotal int64 `json:"applied_token_total"`
|
||||||
|
PendingApplyTokenTotal int64 `json:"pending_apply_token_total"`
|
||||||
|
QuotaSyncStatus string `json:"quota_sync_status"`
|
||||||
|
Tip string `json:"tip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenProductView struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
PriceCent int64 `json:"price_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Badge string `json:"badge"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenOrderView struct {
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TokenAmount int64 `json:"token_amount"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PriceText string `json:"price_text"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
PaymentMode string `json:"payment_mode"`
|
||||||
|
Grant *TokenGrantView `json:"grant"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
PaidAt *string `json:"paid_at"`
|
||||||
|
GrantedAt *string `json:"granted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenGrantView struct {
|
||||||
|
GrantID uint64 `json:"grant_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceLabel string `json:"source_label"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
QuotaApplied bool `json:"quota_applied"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段口径:
|
||||||
|
|
||||||
|
1. `TokenSummary.quota_sync_status` P0 返回 `not_connected`,后续可扩展 `partial`、`synced`。
|
||||||
|
2. `TokenOrderView.status` 使用 `pending`、`paid`、`granted`、`closed`。
|
||||||
|
3. `TokenGrantView.status` 使用 `recorded`、`applied`、`skipped`、`failed`。
|
||||||
|
4. `quota_applied` P0 默认为 `false`,因为本轮不改 `user/auth`。
|
||||||
|
5. 前端 P0 展示“累计获取 Token”,不要展示为权威可用额度。
|
||||||
|
|
||||||
|
### 7.4 RPC 服务契约
|
||||||
|
|
||||||
|
RPC 只承载 gateway 到服务主体的跨进程调用,不暴露给前端。字段可以按 proto 生成规则转成 snake_case JSON,但语义必须和 HTTP DTO 保持一致。
|
||||||
|
|
||||||
|
`taskclassforum` P0 方法:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
service TaskClassForumService {
|
||||||
|
rpc ListPosts(ListForumPostsRequest) returns (ListForumPostsResponse);
|
||||||
|
rpc ListTags(ListForumTagsRequest) returns (ListForumTagsResponse);
|
||||||
|
rpc CreatePost(CreateForumPostRPCRequest) returns (CreateForumPostRPCResponse);
|
||||||
|
rpc GetPost(GetForumPostRequest) returns (GetForumPostResponse);
|
||||||
|
rpc LikePost(LikeForumPostRequest) returns (LikeForumPostResponse);
|
||||||
|
rpc UnlikePost(UnlikeForumPostRequest) returns (UnlikeForumPostResponse);
|
||||||
|
rpc ListComments(ListForumCommentsRequest) returns (ListForumCommentsResponse);
|
||||||
|
rpc CreateComment(CreateForumCommentRPCRequest) returns (CreateForumCommentRPCResponse);
|
||||||
|
rpc DeleteComment(DeleteForumCommentRequest) returns (DeleteForumCommentResponse);
|
||||||
|
rpc ImportPost(ImportForumPostRPCRequest) returns (ImportForumPostRPCResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tokenstore` P0 方法:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
service TokenStoreService {
|
||||||
|
rpc GetSummary(GetTokenSummaryRequest) returns (GetTokenSummaryResponse);
|
||||||
|
rpc ListProducts(ListTokenProductsRequest) returns (ListTokenProductsResponse);
|
||||||
|
rpc CreateOrder(CreateTokenOrderRPCRequest) returns (CreateTokenOrderRPCResponse);
|
||||||
|
rpc ListOrders(ListTokenOrdersRequest) returns (ListTokenOrdersResponse);
|
||||||
|
rpc GetOrder(GetTokenOrderRequest) returns (GetTokenOrderResponse);
|
||||||
|
rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse);
|
||||||
|
rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
RPC 入参通用规则:
|
||||||
|
|
||||||
|
1. 所有需要用户态的方法都带 `actor_user_id`,由 gateway 注入。
|
||||||
|
2. 写方法带 `idempotency_key`,由 gateway 从 `X-Idempotency-Key` 读取。
|
||||||
|
3. 列表方法带 `page`、`page_size`、筛选字段和排序字段。
|
||||||
|
4. RPC 层错误使用 go-zero / gRPC `error` 返回,gateway client 负责转成项目统一响应。
|
||||||
|
|
||||||
|
### 7.5 服务内部端口契约
|
||||||
|
|
||||||
|
`taskclassforum` 依赖 TaskClass 端口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
端口口径:
|
||||||
|
|
||||||
|
1. `GetOwnedTaskClassSnapshot` 只能读取当前用户自己的 TaskClass。
|
||||||
|
2. `TaskClassSnapshot` 必须剔除 `embedded_time`、schedule 绑定和用户私有排程状态。
|
||||||
|
3. P0 实现为 legacy adapter,可复用旧 DAO / Service 或收敛式直读旧表。
|
||||||
|
4. 后续 `task-class` 服务拆出后,只替换 adapter。
|
||||||
|
|
||||||
|
`tokenstore` 内部发放端口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TokenGrantOutlet interface {
|
||||||
|
RecordAcquisition(ctx context.Context, grant TokenGrantRecord) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
端口口径:
|
||||||
|
|
||||||
|
1. P0 只记录 `token-store` 自己的获取事实和账本,不新增或修改 `user/auth` 契约。
|
||||||
|
2. 禁止直接修改 `users` 表。
|
||||||
|
3. 后续切到 `user/auth` 时新增 adapter 实现,服务编排层不重写。
|
||||||
|
|
||||||
|
### 7.6 事件契约
|
||||||
|
|
||||||
|
P0 事件按当前服务级 outbox 思路设计,但不要求主链路第一版强依赖异步投递;如果第一版先同步写账本,也必须按下面 event_id 生成规则保留幂等口径。
|
||||||
|
|
||||||
|
`forum.post.liked`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "forum.post.liked:{post_id}:{actor_user_id}",
|
||||||
|
"post_id": 10001,
|
||||||
|
"author_user_id": 88,
|
||||||
|
"actor_user_id": 91,
|
||||||
|
"reward_receiver_user_id": 88,
|
||||||
|
"reward_amount": 1,
|
||||||
|
"occurred_at": "2026-05-04T21:05:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`forum.post.imported`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "forum.post.imported:{post_id}:{actor_user_id}",
|
||||||
|
"post_id": 10001,
|
||||||
|
"import_id": 70001,
|
||||||
|
"author_user_id": 88,
|
||||||
|
"actor_user_id": 91,
|
||||||
|
"new_task_class_id": 430,
|
||||||
|
"reward_receiver_user_id": 88,
|
||||||
|
"reward_amount": 2,
|
||||||
|
"occurred_at": "2026-05-04T21:10:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`token.grant.requested`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "order:80001:paid",
|
||||||
|
"user_id": 91,
|
||||||
|
"source": "purchase",
|
||||||
|
"amount": 100,
|
||||||
|
"description": "购买基础 Token 包",
|
||||||
|
"occurred_at": "2026-05-04T21:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`token.grant.completed`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "order:80001:paid",
|
||||||
|
"grant_id": 90001,
|
||||||
|
"user_id": 91,
|
||||||
|
"source": "purchase",
|
||||||
|
"amount": 100,
|
||||||
|
"status": "recorded",
|
||||||
|
"quota_applied": false,
|
||||||
|
"occurred_at": "2026-05-04T21:00:01+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
事件口径:
|
||||||
|
|
||||||
|
1. 点赞奖励只给作者,取消点赞不回滚已记录奖励。
|
||||||
|
2. 同一用户对同一计划只允许导入一次,导入奖励随该次导入记录一次。
|
||||||
|
3. Token 发放 P0 的 `quota_applied` 为 `false`,后续接入 `user/auth` 后再变成真实同步状态。
|
||||||
|
4. 事件 payload 放 `backend/shared/events`;服务私有处理逻辑留在各自 `internal/event` 或 `sv`。
|
||||||
|
5. 事件不要阻塞用户主流程;异步消费失败时依赖 `event_id` 和 grant 账本幂等重试。
|
||||||
|
|
||||||
|
### 7.7 幂等与唯一约束
|
||||||
|
|
||||||
|
论坛侧:
|
||||||
|
|
||||||
|
1. `forum_likes` 对 `(post_id, user_id)` 建唯一约束。
|
||||||
|
2. `forum_imports` 对 `(post_id, user_id)` 建唯一约束,同一用户同一计划只允许导入一次,并保留 `event_id`;奖励去重依赖 `token_grants.event_id`。
|
||||||
|
3. 评论创建支持 `X-Idempotency-Key`,避免重复提交。
|
||||||
|
4. 删除评论是软删除,重复删除同一条自己的评论返回成功态。
|
||||||
|
|
||||||
|
Token 侧:
|
||||||
|
|
||||||
|
1. `token_orders.order_no` 唯一。
|
||||||
|
2. `token_grants.event_id` 唯一,是所有发放事实的最终幂等边界。
|
||||||
|
3. mock paid 重复调用同一订单时,不重复创建 grant。
|
||||||
|
4. 创建订单使用 `X-Idempotency-Key` 时,同一用户同一 key 返回同一订单。
|
||||||
|
|
||||||
|
### 7.8 错误码初版
|
||||||
|
|
||||||
|
| status | 场景 | 前端处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `40001` | 参数错误 | 展示 `info` |
|
||||||
|
| `40003` | 未登录或登录态失效 | 跳登录 |
|
||||||
|
| `40004` | 资源不存在或已下架 | 展示空态或返回列表 |
|
||||||
|
| `40009` | 重复操作,如已点赞 | 以返回状态刷新 UI |
|
||||||
|
| `40013` | 无权限,如删除他人评论 | 展示 `info` |
|
||||||
|
| `40029` | 请求过于频繁 | 展示稍后重试 |
|
||||||
|
| `50000` | 服务内部错误 | 展示通用失败提示 |
|
||||||
|
|
||||||
|
错误处理口径:
|
||||||
|
|
||||||
|
1. 业务错误不要把 RPC 内部错误原文透给前端。
|
||||||
|
2. gateway client 负责把服务错误转成统一响应。
|
||||||
|
3. 幂等重复不应默认作为失败;能返回已有结果时优先返回已有结果。
|
||||||
|
|
||||||
|
## 8. 当前推进策略
|
||||||
|
|
||||||
|
1. 当前工作区存在其它拆服务改动,本阶段只提交方案文档。
|
||||||
|
2. 等工作区干净后,从集成分支新开功能分支或单独 git worktree。
|
||||||
|
3. 实现时两个服务主体可以并行推进。
|
||||||
|
4. `gateway/router`、`shared/contracts`、`shared/ports`、`outbox route`、`config` 由主代理统一收口。
|
||||||
|
5. 先做 P0 闭环,再扩展审核、真实支付和推荐排序。
|
||||||
|
6. `task` / `task-class` 未迁出前,论坛侧优先走 legacy adapter,不提前把本轮推进绑死在 task 迁移完成之后。
|
||||||
|
|
||||||
|
## 9. 已确认口径
|
||||||
|
|
||||||
|
1. 论坛展示名使用“计划广场”。
|
||||||
|
2. 点赞奖励只给帖子作者。
|
||||||
|
3. 同一用户对同一计划只允许导入一次,前端根据 `imported_once` 刷新按钮状态,奖励随该次导入记录一次。
|
||||||
|
4. 评论 P0 支持用户删除自己的评论,暂不引入管理员删评、举报和审核流。
|
||||||
|
5. 评论层级后端不硬限制,数据库只存 `parent_comment_id`,服务层负责组装评论树;前端可以按体验做折叠或缩进。
|
||||||
|
6. Token 发放本轮不改 `user/auth`,先在 `token-store` 内封装 Token 获取途径和后续发放出口,后续合并稳定后再切到 `user/auth` 的权威额度发放能力。
|
||||||
|
7. Token 商品从 `token_products` 表读取,P0 通过 seed 初始化商品,不做管理后台。
|
||||||
|
8. 前端正常展示评论,不隐藏评论能力。
|
||||||
|
|
||||||
|
## 10. 实施计划
|
||||||
|
|
||||||
|
### 10.1 P0 实施原则
|
||||||
|
|
||||||
|
1. 本轮先把 `taskclass-forum` 和 `token-store` 两个新服务跑通,不改 `task` / `task-class` 核心模块,不改 `user/auth` 契约。
|
||||||
|
2. 论坛依赖 TaskClass 的能力必须收敛在 legacy adapter 内,后续 `task-class` RPC 合并后只替换 adapter。
|
||||||
|
3. Token 获取和发放先记录在 `token-store` 自己的账本里,不直接改 `users` 表。
|
||||||
|
4. 搜索 P0 先走 MySQL,服务层预留 `PostSearchPort`;后续需要中文分词、相关性排序或内容检索时,再替换为 Elasticsearch / OpenSearch adapter。
|
||||||
|
5. 商品 P0 从 `token_products` 表读取,通过 seed 初始化 2-3 个商品;不做商品管理后台。
|
||||||
|
6. 同一用户对同一计划只允许导入一次,后端通过唯一约束兜底,前端通过 `imported_once` 刷新按钮状态。
|
||||||
|
7. 评论层级不硬限制,表里只存 `parent_comment_id`,服务层按帖子组装评论树。
|
||||||
|
8. Outbox 总线只承载异步副作用,不承载 P0 用户可见主链路,避免联调期被 relay / consumer 状态拖慢。
|
||||||
|
|
||||||
|
### 10.2 表结构与迁移
|
||||||
|
|
||||||
|
第一步先落服务私有表和必要唯一约束:
|
||||||
|
|
||||||
|
1. 计划广场表:`forum_posts`、`forum_post_templates`、`forum_post_template_items`、`forum_likes`、`forum_comments`、`forum_imports`。
|
||||||
|
2. Token 商店表:`token_products`、`token_orders`、`token_grants`、`token_reward_rules`。
|
||||||
|
3. `forum_likes` 建 `(post_id, user_id)` 唯一约束。
|
||||||
|
4. `forum_imports` 建 `(post_id, user_id)` 唯一约束,保证同一用户同一计划只导入一次。
|
||||||
|
5. `forum_comments` 建 `post_id`、`parent_comment_id`、`created_at` 索引,支撑按帖子读取扁平评论后组树。
|
||||||
|
6. `token_orders.order_no` 唯一,`token_grants.event_id` 唯一。
|
||||||
|
7. `token_products` 通过 seed 初始化商品,后续如要管理后台再单独扩展。
|
||||||
|
|
||||||
|
### 10.3 服务骨架
|
||||||
|
|
||||||
|
第二步搭建目录和进程骨架:
|
||||||
|
|
||||||
|
1. 新增 `backend/cmd/taskclassforum`、`backend/cmd/tokenstore`。
|
||||||
|
2. 新增 `backend/services/taskclassforum`、`backend/services/tokenstore`,内部按 `sv`、`dao`、`model`、`internal`、`rpc` 分层。
|
||||||
|
3. 新增 `backend/gateway/api/taskclassforum`、`backend/gateway/api/tokenstore` 承载 HTTP 入口。
|
||||||
|
4. 新增 `backend/gateway/client/taskclassforum`、`backend/gateway/client/tokenstore` 承载 zrpc client 和错误反解。
|
||||||
|
5. `gateway/router`、配置、outbox route、shared contracts 由主代理统一收口,避免多处同时改同一个装配点。
|
||||||
|
|
||||||
|
### 10.4 计划广场 P0
|
||||||
|
|
||||||
|
第三步实现计划广场闭环:
|
||||||
|
|
||||||
|
1. 发布计划:读取用户自己的 TaskClass,生成论坛模板快照,写入帖子和模板表。
|
||||||
|
2. 列表和详情:先用 MySQL 查询 `title`、`summary`、`tags`、状态和计数字段,支持 `latest`、`likes`、`imports` 排序。
|
||||||
|
3. 点赞 / 取消点赞:用唯一约束保证同一用户只点赞一次,点赞奖励事件只给作者。
|
||||||
|
4. 评论:发表评论、回复任意评论、删除自己的评论;删除采用软删除,保留子回复结构。
|
||||||
|
5. 评论树:按帖子读取扁平评论,服务层按 `parent_comment_id` 组装树;后端不限制层级。
|
||||||
|
6. 一键导入:同一用户同一帖子只允许导入一次,只生成当前用户自己的 TaskClass,不写 schedule。
|
||||||
|
|
||||||
|
### 10.5 Token 商店 P0
|
||||||
|
|
||||||
|
第四步实现 Token 商店闭环:
|
||||||
|
|
||||||
|
1. 商品列表从 `token_products` 表读取,P0 商品由 seed 提供。
|
||||||
|
2. 创建订单:写入 `token_orders`,状态为 `pending`。
|
||||||
|
3. mock paid:把订单置为已支付,并写入 `token_grants`。
|
||||||
|
4. 获取记录:从 `token_grants` 分页查询购买、点赞奖励、导入奖励等记录。
|
||||||
|
5. Token 概览:展示 `token-store` 已记录的累计获取 Token;P0 不展示为 `user/auth` 权威可用额度。
|
||||||
|
6. 预留 `TokenGrantOutlet`,后续切到 `user/auth` 时只替换发放出口。
|
||||||
|
|
||||||
|
### 10.6 Outbox 总线接入
|
||||||
|
|
||||||
|
第五步按复杂度分两档推进 outbox:
|
||||||
|
|
||||||
|
1. P0 主链路同步闭环:计划发布、点赞状态、评论、导入、订单、mock paid 和 grant 账本都同步写入本服务数据库。
|
||||||
|
2. P0 可同步写奖励账本,但必须按 `forum.post.liked`、`forum.post.imported` 生成稳定 `event_id`,保证后续切异步时可复用同一幂等边界。
|
||||||
|
3. 若本轮顺手接入服务级 outbox,建议新增 `taskclass_forum_outbox_messages`、`token_store_outbox_messages`,对应 topic 为 `smartflow.taskclass-forum.outbox`、`smartflow.token-store.outbox`。
|
||||||
|
4. 对应 consumer group 建议为 `smartflow-taskclass-forum-outbox-consumer`、`smartflow-token-store-outbox-consumer`,延续当前服务级 topic / group 隔离规则。
|
||||||
|
5. `taskclass-forum` 适合发布 `forum.post.liked`、`forum.post.imported`、后续 `forum.post.published`、`forum.post.updated`、`forum.post.deleted`。
|
||||||
|
6. `token-store` 适合发布 `token.grant.requested`、`token.grant.completed`,后续接 `user/auth` 时再消费或转发到权威额度同步。
|
||||||
|
7. 搜索索引、排行榜、通知、运营统计都走 outbox 异步消费,不进入 HTTP 主事务。
|
||||||
|
8. relay / consumer 失败时不得影响用户已经成功的主操作,通过 outbox 重试和 `token_grants.event_id` 幂等兜底。
|
||||||
|
|
||||||
|
### 10.7 缓存策略
|
||||||
|
|
||||||
|
P0 不引入复杂缓存,优先靠表结构、索引和分页控制复杂度:
|
||||||
|
|
||||||
|
1. 评论树 P0 不做整树缓存。评论是强互动数据,新增、回复、删除都会影响树结构,缓存失效成本高;当前场景多数用户看完即切,直接查库并组树更简单。
|
||||||
|
2. 评论接口按根评论分页,后端读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论。
|
||||||
|
3. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效。
|
||||||
|
4. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。
|
||||||
|
5. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。
|
||||||
|
6. 后续若上 Elasticsearch,只缓存搜索索引,不改变前端接口和论坛业务编排。
|
||||||
|
|
||||||
|
### 10.8 联调与验收
|
||||||
|
|
||||||
|
最后按接口闭环做 smoke:
|
||||||
|
|
||||||
|
1. 发布计划后,列表和详情能看到模板快照。
|
||||||
|
2. 点赞成功后,点赞状态和计数刷新,作者获得一条 Token 获取记录。
|
||||||
|
3. 评论支持多层回复;删除自己的评论后,子回复仍保留。
|
||||||
|
4. 同一用户同一计划第二次导入被后端拒绝或返回已导入状态,前端按钮状态保持一致。
|
||||||
|
5. mock paid 后订单进入 `granted`,`token_grants` 只写一条幂等记录。
|
||||||
|
6. Token 概览展示累计获取 Token,但不声称已经同步到 `user/auth` 权威可用额度。
|
||||||
|
7. 如果本轮接入 outbox,需要额外验证事件写入、投递、消费失败重试和幂等不重复发放。
|
||||||
1062
docs/frontend/计划广场与Token商店对接说明.md
Normal file
1062
docs/frontend/计划广场与Token商店对接说明.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user