From 786c8925a0bde3765bf456706ef1095ac9e2a1c5 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 4 May 2026 18:33:09 +0800 Subject: [PATCH 1/6] feat: add forum and token store service skeletons --- backend/cmd/taskclassforum/main.go | 32 + backend/cmd/tokenstore/main.go | 32 + .../services/taskclassforum/dao/connect.go | 61 + .../services/taskclassforum/model/forum.go | 180 +++ backend/services/taskclassforum/rpc/errors.go | 76 ++ .../services/taskclassforum/rpc/handler.go | 412 +++++++ .../rpc/pb/taskclassforum.pb.go | 339 ++++++ .../rpc/pb/taskclassforum_grpc.pb.go | 213 ++++ backend/services/taskclassforum/rpc/server.go | 73 ++ .../taskclassforum/rpc/taskclassforum.proto | 222 ++++ backend/services/taskclassforum/sv/service.go | 179 +++ backend/services/tokenstore/dao/connect.go | 179 +++ backend/services/tokenstore/model/token.go | 155 +++ backend/services/tokenstore/rpc/errors.go | 72 ++ backend/services/tokenstore/rpc/handler.go | 288 +++++ .../tokenstore/rpc/pb/tokenstore.pb.go | 209 ++++ .../tokenstore/rpc/pb/tokenstore_grpc.pb.go | 171 +++ backend/services/tokenstore/rpc/server.go | 73 ++ .../services/tokenstore/rpc/tokenstore.proto | 141 +++ backend/services/tokenstore/sv/service.go | 107 ++ .../shared/contracts/taskclassforum/types.go | 143 +++ backend/shared/contracts/tokenstore/types.go | 109 ++ docs/backend/学习计划论坛与Token商店PRD.md | 203 ---- .../计划广场与Token商店后端实施方案.md | 816 +++++++++++++ docs/frontend/计划广场与Token商店对接说明.md | 1062 +++++++++++++++++ 25 files changed, 5344 insertions(+), 203 deletions(-) create mode 100644 backend/cmd/taskclassforum/main.go create mode 100644 backend/cmd/tokenstore/main.go create mode 100644 backend/services/taskclassforum/dao/connect.go create mode 100644 backend/services/taskclassforum/model/forum.go create mode 100644 backend/services/taskclassforum/rpc/errors.go create mode 100644 backend/services/taskclassforum/rpc/handler.go create mode 100644 backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go create mode 100644 backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go create mode 100644 backend/services/taskclassforum/rpc/server.go create mode 100644 backend/services/taskclassforum/rpc/taskclassforum.proto create mode 100644 backend/services/taskclassforum/sv/service.go create mode 100644 backend/services/tokenstore/dao/connect.go create mode 100644 backend/services/tokenstore/model/token.go create mode 100644 backend/services/tokenstore/rpc/errors.go create mode 100644 backend/services/tokenstore/rpc/handler.go create mode 100644 backend/services/tokenstore/rpc/pb/tokenstore.pb.go create mode 100644 backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go create mode 100644 backend/services/tokenstore/rpc/server.go create mode 100644 backend/services/tokenstore/rpc/tokenstore.proto create mode 100644 backend/services/tokenstore/sv/service.go create mode 100644 backend/shared/contracts/taskclassforum/types.go create mode 100644 backend/shared/contracts/tokenstore/types.go delete mode 100644 docs/backend/学习计划论坛与Token商店PRD.md create mode 100644 docs/backend/计划广场与Token商店后端实施方案.md create mode 100644 docs/frontend/计划广场与Token商店对接说明.md diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go new file mode 100644 index 0000000..ddf8f48 --- /dev/null +++ b/backend/cmd/taskclassforum/main.go @@ -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, + }) +} diff --git a/backend/cmd/tokenstore/main.go b/backend/cmd/tokenstore/main.go new file mode 100644 index 0000000..70eb44b --- /dev/null +++ b/backend/cmd/tokenstore/main.go @@ -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, + }) +} diff --git a/backend/services/taskclassforum/dao/connect.go b/backend/services/taskclassforum/dao/connect.go new file mode 100644 index 0000000..37d9c4d --- /dev/null +++ b/backend/services/taskclassforum/dao/connect.go @@ -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 +} diff --git a/backend/services/taskclassforum/model/forum.go b/backend/services/taskclassforum/model/forum.go new file mode 100644 index 0000000..f179a29 --- /dev/null +++ b/backend/services/taskclassforum/model/forum.go @@ -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" +} diff --git a/backend/services/taskclassforum/rpc/errors.go b/backend/services/taskclassforum/rpc/errors.go new file mode 100644 index 0000000..2305fe1 --- /dev/null +++ b/backend/services/taskclassforum/rpc/errors.go @@ -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 +} diff --git a/backend/services/taskclassforum/rpc/handler.go b/backend/services/taskclassforum/rpc/handler.go new file mode 100644 index 0000000..0b0d641 --- /dev/null +++ b/backend/services/taskclassforum/rpc/handler.go @@ -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 +} diff --git a/backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go b/backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go new file mode 100644 index 0000000..a61606c --- /dev/null +++ b/backend/services/taskclassforum/rpc/pb/taskclassforum.pb.go @@ -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() {} diff --git a/backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go b/backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go new file mode 100644 index 0000000..1d112c2 --- /dev/null +++ b/backend/services/taskclassforum/rpc/pb/taskclassforum_grpc.pb.go @@ -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", +} diff --git a/backend/services/taskclassforum/rpc/server.go b/backend/services/taskclassforum/rpc/server.go new file mode 100644 index 0000000..5d190ca --- /dev/null +++ b/backend/services/taskclassforum/rpc/server.go @@ -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 +} diff --git a/backend/services/taskclassforum/rpc/taskclassforum.proto b/backend/services/taskclassforum/rpc/taskclassforum.proto new file mode 100644 index 0000000..c143de8 --- /dev/null +++ b/backend/services/taskclassforum/rpc/taskclassforum.proto @@ -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; +} diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go new file mode 100644 index 0000000..0b0ca8c --- /dev/null +++ b/backend/services/taskclassforum/sv/service.go @@ -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 +} diff --git a/backend/services/tokenstore/dao/connect.go b/backend/services/tokenstore/dao/connect.go new file mode 100644 index 0000000..e5af2ba --- /dev/null +++ b/backend/services/tokenstore/dao/connect.go @@ -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, + }, + } +} diff --git a/backend/services/tokenstore/model/token.go b/backend/services/tokenstore/model/token.go new file mode 100644 index 0000000..c33f52b --- /dev/null +++ b/backend/services/tokenstore/model/token.go @@ -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" +} diff --git a/backend/services/tokenstore/rpc/errors.go b/backend/services/tokenstore/rpc/errors.go new file mode 100644 index 0000000..8fb504f --- /dev/null +++ b/backend/services/tokenstore/rpc/errors.go @@ -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 +} diff --git a/backend/services/tokenstore/rpc/handler.go b/backend/services/tokenstore/rpc/handler.go new file mode 100644 index 0000000..7763d4b --- /dev/null +++ b/backend/services/tokenstore/rpc/handler.go @@ -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 +} diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go new file mode 100644 index 0000000..249f9e7 --- /dev/null +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -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() {} diff --git a/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go new file mode 100644 index 0000000..1c5fd54 --- /dev/null +++ b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go @@ -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", +} diff --git a/backend/services/tokenstore/rpc/server.go b/backend/services/tokenstore/rpc/server.go new file mode 100644 index 0000000..e02628c --- /dev/null +++ b/backend/services/tokenstore/rpc/server.go @@ -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 +} diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto new file mode 100644 index 0000000..bc3eb33 --- /dev/null +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -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; +} diff --git a/backend/services/tokenstore/sv/service.go b/backend/services/tokenstore/sv/service.go new file mode 100644 index 0000000..b2a360b --- /dev/null +++ b/backend/services/tokenstore/sv/service.go @@ -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 +} diff --git a/backend/shared/contracts/taskclassforum/types.go b/backend/shared/contracts/taskclassforum/types.go new file mode 100644 index 0000000..f7007d9 --- /dev/null +++ b/backend/shared/contracts/taskclassforum/types.go @@ -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"` +} diff --git a/backend/shared/contracts/tokenstore/types.go b/backend/shared/contracts/tokenstore/types.go new file mode 100644 index 0000000..ff98025 --- /dev/null +++ b/backend/shared/contracts/tokenstore/types.go @@ -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"` +} diff --git a/docs/backend/学习计划论坛与Token商店PRD.md b/docs/backend/学习计划论坛与Token商店PRD.md deleted file mode 100644 index a8ecc1e..0000000 --- a/docs/backend/学习计划论坛与Token商店PRD.md +++ /dev/null @@ -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 是否需要前端先隐藏评论,只保留后端能力。 diff --git a/docs/backend/计划广场与Token商店后端实施方案.md b/docs/backend/计划广场与Token商店后端实施方案.md new file mode 100644 index 0000000..2d1bd8e --- /dev/null +++ b/docs/backend/计划广场与Token商店后端实施方案.md @@ -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//main.go` 只负责读取配置、初始化资源、启动 go-zero zrpc 服务,不承载业务规则。 +2. `gateway/api/` 只负责 HTTP 参数绑定、鉴权后用户 ID 注入、调用对应 client、复用 `respond` 返回前端,不直接访问服务数据库。 +3. `gateway/client/` 只放 zrpc client 和错误反解;不要把 client 放进 `cmd`,也不要让 gateway 直接 import 服务端 DAO / model。 +4. `services//sv` 是服务主业务编排层,负责事务顺序、幂等判断、状态流转和跨端口调用。 +5. `services//dao` 只负责本服务数据库访问;`model` 只放本服务私有表模型,不放跨服务 DTO。 +6. `services//internal` 只放服务私有子能力,外部服务禁止直接 import。 +7. `services//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,需要额外验证事件写入、投递、消费失败重试和幂等不重复发放。 diff --git a/docs/frontend/计划广场与Token商店对接说明.md b/docs/frontend/计划广场与Token商店对接说明.md new file mode 100644 index 0000000..0fef1d0 --- /dev/null +++ b/docs/frontend/计划广场与Token商店对接说明.md @@ -0,0 +1,1062 @@ +# 计划广场与 Token 商店前端对接说明 + +## 1. 文档目标 + +本文面向前端页面设计和接口联调,先把 P0 页面所需接口、字段、状态和交互流程定成初版。 + +当前确认口径: + +1. 论坛展示名使用“计划广场”。 +2. 前端正常展示评论,评论支持多层回复。 +3. 用户可以删除自己的评论;P0 暂不引入管理员删评、举报和审核流。 +4. 点赞奖励只给帖子作者。 +5. 同一用户对同一计划只允许导入一次,奖励随该次导入记录一次。 +6. Token 发放本轮不改 `user/auth`,先在 `token-store` 内封装 Token 获取途径和后续发放出口。 + +## 2. 通用约定 + +### 2.1 鉴权 + +P0 所有接口都需要登录态。 + +```http +Authorization: Bearer +``` + +### 2.2 统一响应壳 + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": {} +} +``` + +失败响应: + +```json +{ + "status": "40004", + "info": "计划不存在或已下架", + "data": null +} +``` + +前端处理建议: + +1. `status === "10000"` 视为成功。 +2. 非成功状态优先展示 `info`。 +3. 表单类错误可按接口返回的 `info` 直接提示,不需要前端硬编码太多文案。 + +### 2.3 幂等请求头 + +以下写操作建议前端带 `X-Idempotency-Key`: + +1. 发布计划。 +2. 发表评论。 +3. 一键导入。 +4. 创建订单。 +5. mock paid。 + +```http +X-Idempotency-Key: plan_square_publish_1710000000000_abcd +``` + +### 2.4 时间格式 + +所有时间字段使用 ISO 8601 字符串,带时区。 + +```json +"2026-05-04T20:30:00+08:00" +``` + +### 2.5 分页结构 + +分页接口统一返回: + +```json +{ + "items": [], + "page": 1, + "page_size": 20, + "total": 125, + "has_more": true +} +``` + +## 3. 页面总览 + +### 3.1 计划广场 + +前端建议页面: + +1. 计划广场列表页:展示公开计划卡片,支持排序、标签筛选和搜索。 +2. 计划详情页 / 详情抽屉:展示计划说明、任务条目预览、点赞、评论和导入按钮。 +3. 发布计划弹窗 / 页面:选择自己的 TaskClass,填写标题、简介、标签后发布。 +4. 评论区:展示多层评论树,支持回复和删除自己的评论。 + +### 3.2 Token 商店 + +前端建议页面: + +1. Token 商品页:展示 Token 包商品卡片。 +2. 订单确认 / mock paid 页:P0 可直接展示“确认支付”按钮。 +3. Token 获取记录页:展示购买、点赞奖励、导入奖励等账本记录。 +4. Token 概览组件:展示本服务记录的累计获取 Token。P0 不承诺已经同步到 `user/auth` 可用额度。 + +## 4. 路由总览 + +### 4.1 计划广场接口 + +| 功能 | 方法 | 路径 | +| --- | --- | --- | +| 计划列表 | `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` | + +发布计划时,TaskClass 下拉列表复用现有接口: + +```http +GET /api/v1/task-class/list +``` + +### 4.2 Token 商店接口 + +| 功能 | 方法 | 路径 | +| --- | --- | --- | +| 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` | + +## 5. 计划广场接口详情 + +### 5.1 计划列表 + +```http +GET /api/v1/plan-square/posts?page=1&page_size=20&sort=latest&keyword=数学&tag=考研 +``` + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | number | 否 | 默认 `1` | +| `page_size` | number | 否 | 默认 `20`,最大建议 `50` | +| `sort` | string | 否 | `latest` / `likes` / `imports` | +| `keyword` | string | 否 | 搜索标题和简介 | +| `tag` | string | 否 | 标签筛选 | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { + "post_id": 10001, + "title": "30 天高数强化复习计划", + "summary": "适合期末前一个月快速过完重点题型。", + "tags": ["高数", "期末", "30天"], + "author": { + "user_id": 88, + "nickname": "小鹿同学", + "avatar_url": "" + }, + "template_summary": { + "task_count": 24, + "mode": "date_range", + "start_date": "2026-05-05", + "end_date": "2026-06-04", + "strategy_labels": ["每日推进", "错题复盘"] + }, + "counters": { + "like_count": 128, + "comment_count": 32, + "import_count": 45 + }, + "viewer_state": { + "liked": false, + "imported_once": true + }, + "status": "published", + "created_at": "2026-05-04T20:30:00+08:00" + } + ], + "page": 1, + "page_size": 20, + "total": 125, + "has_more": true + } +} +``` + +前端展示建议: + +1. 卡片主标题用 `title`。 +2. 副文案用 `summary`,最多展示两到三行。 +3. 标签 chips 用 `tags`。 +4. 统计区展示点赞、评论、导入次数。 +5. `viewer_state.liked` 控制点赞按钮初始态。 + +### 5.2 热门标签 + +```http +GET /api/v1/plan-square/tags?limit=20 +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { "tag": "考研", "post_count": 30 }, + { "tag": "高数", "post_count": 18 } + ] + } +} +``` + +### 5.3 发布计划 + +```http +POST /api/v1/plan-square/posts +Content-Type: application/json +X-Idempotency-Key: plan_square_publish_1710000000000_abcd +``` + +请求体: + +```json +{ + "task_class_id": 42, + "title": "30 天高数强化复习计划", + "summary": "适合期末前一个月快速过完重点题型。", + "tags": ["高数", "期末", "30天"] +} +``` + +字段限制: + +| 字段 | 规则 | +| --- | --- | +| `task_class_id` | 必填,只能选择当前用户自己的 TaskClass | +| `title` | 必填,建议 4 到 40 字 | +| `summary` | 可选,建议 0 到 300 字 | +| `tags` | 可选,最多 5 个,每个最多 12 字 | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "post_id": 10001, + "title": "30 天高数强化复习计划", + "status": "published", + "created_at": "2026-05-04T20:30:00+08:00" + } +} +``` + +前端流程建议: + +1. 先调用 `GET /api/v1/task-class/list` 获取用户自己的计划。 +2. 用户选择一个 TaskClass 后填写发布信息。 +3. 发布成功后跳转到详情页或刷新计划广场列表。 + +### 5.4 计划详情 + +```http +GET /api/v1/plan-square/posts/{post_id} +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "post": { + "post_id": 10001, + "title": "30 天高数强化复习计划", + "summary": "适合期末前一个月快速过完重点题型。", + "tags": ["高数", "期末", "30天"], + "author": { + "user_id": 88, + "nickname": "小鹿同学", + "avatar_url": "" + }, + "counters": { + "like_count": 128, + "comment_count": 32, + "import_count": 45 + }, + "viewer_state": { + "liked": false, + "imported_once": true + }, + "created_at": "2026-05-04T20:30:00+08:00" + }, + "template": { + "mode": "date_range", + "start_date": "2026-05-05", + "end_date": "2026-06-04", + "strategy_labels": ["每日推进", "错题复盘"], + "task_count": 24, + "items_preview": [ + { + "item_id": 90001, + "order": 1, + "content": "复习极限与连续,完成基础例题。" + }, + { + "item_id": 90002, + "order": 2, + "content": "整理导数公式,完成 10 道综合题。" + } + ] + } + } +} +``` + +说明: + +1. `items_preview` 是模板快照,不是原作者当前 TaskClass。 +2. 不展示 `embedded_time`、schedule 绑定和用户私有排程状态。 +3. 评论区单独调用评论树接口,避免详情首屏过重。 + +### 5.5 点赞 + +```http +POST /api/v1/plan-square/posts/{post_id}/like +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "post_id": 10001, + "liked": true, + "like_count": 129, + "reward_hint": { + "receiver": "author", + "status": "recorded", + "amount": 1 + } + } +} +``` + +说明: + +1. 同一用户对同一帖子只能点赞一次。 +2. 点赞奖励只给作者。 +3. 取消点赞不回滚已经记录的作者奖励,避免账本反复冲正。 + +### 5.6 取消点赞 + +```http +DELETE /api/v1/plan-square/posts/{post_id}/like +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "post_id": 10001, + "liked": false, + "like_count": 128 + } +} +``` + +### 5.7 评论树 + +```http +GET /api/v1/plan-square/posts/{post_id}/comments?page=1&page_size=20&sort=oldest +``` + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | number | 否 | 根评论分页,默认 `1` | +| `page_size` | number | 否 | 根评论分页大小,默认 `20` | +| `sort` | string | 否 | `oldest` / `latest`,默认 `oldest` | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { + "comment_id": 50001, + "post_id": 10001, + "parent_comment_id": null, + "content": "这个计划很适合期末冲刺。", + "status": "visible", + "author": { + "user_id": 91, + "nickname": "西瓜同学", + "avatar_url": "" + }, + "can_delete": true, + "created_at": "2026-05-04T20:40:00+08:00", + "deleted_at": null, + "children": [ + { + "comment_id": 50002, + "post_id": 10001, + "parent_comment_id": 50001, + "content": "我也准备照这个导入一份。", + "status": "visible", + "author": { + "user_id": 92, + "nickname": "青柠同学", + "avatar_url": "" + }, + "can_delete": false, + "created_at": "2026-05-04T20:42:00+08:00", + "deleted_at": null, + "children": [] + } + ] + } + ], + "page": 1, + "page_size": 20, + "total": 32, + "has_more": true + } +} +``` + +评论状态: + +| `status` | 前端展示 | +| --- | --- | +| `visible` | 正常展示 `content` | +| `deleted` | 展示“该评论已删除”,保留子回复 | + +### 5.8 发表评论 / 回复 + +```http +POST /api/v1/plan-square/posts/{post_id}/comments +Content-Type: application/json +X-Idempotency-Key: plan_square_comment_1710000000000_abcd +``` + +请求体: + +```json +{ + "content": "这个计划很适合期末冲刺。", + "parent_comment_id": null +} +``` + +回复某条评论时: + +```json +{ + "content": "我也准备照这个导入一份。", + "parent_comment_id": 50001 +} +``` + +字段限制: + +| 字段 | 规则 | +| --- | --- | +| `content` | 必填,建议 1 到 500 字 | +| `parent_comment_id` | 可选,传 `null` 表示根评论 | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "comment_id": 50003, + "post_id": 10001, + "parent_comment_id": 50001, + "content": "我也准备照这个导入一份。", + "status": "visible", + "can_delete": true, + "created_at": "2026-05-04T20:45:00+08:00" + } +} +``` + +### 5.9 删除自己的评论 + +```http +DELETE /api/v1/plan-square/comments/{comment_id} +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "comment_id": 50003, + "status": "deleted", + "content": "", + "deleted_at": "2026-05-04T20:50:00+08:00" + } +} +``` + +说明: + +1. 只能删除自己的评论。 +2. 删除是软删除,不删除子回复。 +3. 非本人删除返回失败,前端展示 `info` 即可。 + +### 5.10 一键导入 + +```http +POST /api/v1/plan-square/posts/{post_id}/import +Content-Type: application/json +X-Idempotency-Key: plan_square_import_1710000000000_abcd +``` + +请求体: + +```json +{ + "target_title": "我的 30 天高数强化计划" +} +``` + +字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `target_title` | 否 | 导入后的 TaskClass 名称;不传则后端生成默认名称 | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "import_id": 70001, + "post_id": 10001, + "new_task_class_id": 430, + "task_class_title": "我的 30 天高数强化计划", + "import_count": 46, + "reward_hint": { + "receiver": "author", + "status": "recorded", + "amount": 2 + }, + "next_action": { + "type": "open_task_class", + "task_class_id": 430 + }, + "created_at": "2026-05-04T20:55:00+08:00" + } +} +``` + +说明: + +1. 一键导入只创建当前用户自己的 TaskClass 副本。 +2. 不直接写 schedule。 +3. 同一用户同一帖子只允许导入一次;导入成功后前端根据返回结果刷新 `imported_once` 和按钮状态。 + +## 6. Token 商店接口详情 + +### 6.1 Token 概览 + +```http +GET /api/v1/token-store/summary +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "recorded_token_total": 120, + "applied_token_total": 0, + "pending_apply_token_total": 120, + "quota_sync_status": "not_connected", + "tip": "当前为 Token 获取记录,后续会切换到 user/auth 权威额度。" + } +} +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `recorded_token_total` | `token-store` 内已经记录的累计获取 Token | +| `applied_token_total` | 已同步到权威额度的 Token,P0 通常为 `0` | +| `pending_apply_token_total` | 已记录但尚未同步到权威额度的 Token | +| `quota_sync_status` | `not_connected` / `partial` / `synced` | + +前端展示建议: + +1. P0 可以展示“累计获取 Token”,不要写成“当前可用 Token”。 +2. `quota_sync_status === "not_connected"` 时,展示轻提示,不阻塞购买流程。 + +### 6.2 商品列表 + +```http +GET /api/v1/token-store/products +``` + +说明: + +1. 商品从 `token_products` 表读取。 +2. P0 商品由后端 seed 初始化,不做商品管理后台。 + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { + "product_id": 1, + "name": "基础 Token 包", + "description": "适合轻量使用 Agent。", + "token_amount": 100, + "price_cent": 990, + "price_text": "¥9.90", + "currency": "CNY", + "badge": "入门", + "status": "active", + "sort_order": 10 + }, + { + "product_id": 2, + "name": "进阶 Token 包", + "description": "适合高频规划和复盘。", + "token_amount": 300, + "price_cent": 1990, + "price_text": "¥19.90", + "currency": "CNY", + "badge": "推荐", + "status": "active", + "sort_order": 20 + } + ] + } +} +``` + +前端展示建议: + +1. 只展示 `status === "active"` 的商品。 +2. `badge` 可作为角标,不保证一定有值。 + +### 6.3 创建订单 + +```http +POST /api/v1/token-store/orders +Content-Type: application/json +X-Idempotency-Key: token_order_1710000000000_abcd +``` + +请求体: + +```json +{ + "product_id": 1, + "quantity": 1 +} +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "order_id": 80001, + "order_no": "TS202605042055000001", + "status": "pending", + "product_snapshot": { + "product_id": 1, + "name": "基础 Token 包", + "token_amount": 100 + }, + "quantity": 1, + "token_amount": 100, + "amount_cent": 990, + "price_text": "¥9.90", + "currency": "CNY", + "payment_mode": "mock", + "payment_action": { + "type": "mock_paid", + "label": "确认支付" + }, + "created_at": "2026-05-04T20:55:00+08:00" + } +} +``` + +### 6.4 订单列表 + +```http +GET /api/v1/token-store/orders?page=1&page_size=20&status=pending +``` + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | number | 否 | 默认 `1` | +| `page_size` | number | 否 | 默认 `20` | +| `status` | string | 否 | `pending` / `paid` / `granted` / `closed` | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { + "order_id": 80001, + "order_no": "TS202605042055000001", + "status": "pending", + "product_name": "基础 Token 包", + "token_amount": 100, + "price_text": "¥9.90", + "created_at": "2026-05-04T20:55:00+08:00", + "paid_at": null, + "granted_at": null + } + ], + "page": 1, + "page_size": 20, + "total": 1, + "has_more": false + } +} +``` + +订单状态: + +| `status` | 前端含义 | +| --- | --- | +| `pending` | 待支付 | +| `paid` | 已支付,等待写入获取记录 | +| `granted` | 已写入 Token 获取记录 | +| `closed` | 已关闭 | + +### 6.5 订单详情 + +```http +GET /api/v1/token-store/orders/{order_id} +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "order_id": 80001, + "order_no": "TS202605042055000001", + "status": "pending", + "product_snapshot": { + "product_id": 1, + "name": "基础 Token 包", + "token_amount": 100 + }, + "quantity": 1, + "token_amount": 100, + "amount_cent": 990, + "price_text": "¥9.90", + "currency": "CNY", + "payment_mode": "mock", + "grant": null, + "created_at": "2026-05-04T20:55:00+08:00", + "paid_at": null, + "granted_at": null + } +} +``` + +### 6.6 mock paid + +```http +POST /api/v1/token-store/orders/{order_id}/mock-paid +Content-Type: application/json +X-Idempotency-Key: token_mock_paid_1710000000000_abcd +``` + +请求体: + +```json +{ + "mock_channel": "dev" +} +``` + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "order_id": 80001, + "order_no": "TS202605042055000001", + "status": "granted", + "paid_at": "2026-05-04T21:00:00+08:00", + "granted_at": "2026-05-04T21:00:01+08:00", + "grant": { + "grant_id": 90001, + "event_id": "order:80001:paid", + "source": "purchase", + "amount": 100, + "status": "recorded", + "quota_applied": false + } + } +} +``` + +说明: + +1. P0 不接真实支付网关。 +2. `status === "granted"` 表示已经写入 `token-store` 获取记录。 +3. `quota_applied === false` 表示本轮未同步到 `user/auth` 权威额度。 + +### 6.7 Token 获取记录 + +```http +GET /api/v1/token-store/grants?page=1&page_size=20&source=purchase +``` + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `page` | number | 否 | 默认 `1` | +| `page_size` | number | 否 | 默认 `20` | +| `source` | string | 否 | `purchase` / `forum_like` / `forum_import` / `manual` | + +成功响应: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "items": [ + { + "grant_id": 90001, + "event_id": "order:80001:paid", + "source": "purchase", + "source_label": "购买 Token 包", + "amount": 100, + "status": "recorded", + "quota_applied": false, + "description": "购买基础 Token 包", + "created_at": "2026-05-04T21:00:01+08:00" + }, + { + "grant_id": 90002, + "event_id": "forum.post.liked:10001:91", + "source": "forum_like", + "source_label": "计划被点赞", + "amount": 1, + "status": "recorded", + "quota_applied": false, + "description": "你的计划《30 天高数强化复习计划》获得点赞", + "created_at": "2026-05-04T21:05:00+08:00" + } + ], + "page": 1, + "page_size": 20, + "total": 2, + "has_more": false + } +} +``` + +获取记录状态: + +| `status` | 前端含义 | +| --- | --- | +| `recorded` | 已记录获取事实,P0 默认状态 | +| `applied` | 已同步到权威额度,后续切 `user/auth` 后使用 | +| `skipped` | 幂等重复或规则不发放 | +| `failed` | 写入或后续发放失败 | + +## 7. 页面交互建议 + +### 7.1 计划广场列表页 + +1. 首屏调用计划列表接口,默认 `sort=latest`。 +2. 顶部筛选可放搜索框、排序切换和热门标签。 +3. 卡片 CTA 建议包含“查看详情”和“一键导入”。 +4. 点赞按钮可在卡片上直接操作,成功后用接口返回的 `like_count` 覆盖本地计数。 + +### 7.2 计划详情页 + +1. 进入详情页先拉计划详情。 +2. 评论区单独拉评论树。 +3. 发表评论或删除评论成功后,建议重新拉评论树,避免多层结构局部更新出错。 +4. 一键导入成功后,可弹出成功反馈,并提供“查看我的 TaskClass”入口。 + +### 7.3 发布计划 + +1. 打开发布弹窗时调用现有 `GET /api/v1/task-class/list`。 +2. 用户选择 TaskClass 后填写标题、简介、标签。 +3. 发布按钮需要防重复点击,并发送 `X-Idempotency-Key`。 +4. 发布成功后跳转详情页或刷新列表。 + +### 7.4 Token 商店 + +1. 首屏并行调用 Token 概览和商品列表。 +2. 商品卡片点击“购买”后创建订单。 +3. P0 下单后展示 mock paid 确认按钮。 +4. mock paid 成功后刷新 Token 概览和获取记录。 +5. P0 文案建议使用“累计获取 Token”,暂不写“可用 Token 已到账”。 + +## 8. 前端类型参考 + +```ts +type ApiResponse = { + status: string + info: string + data: T +} + +type PageData = { + items: T[] + page: number + page_size: number + total: number + has_more: boolean +} + +type UserBrief = { + user_id: number + nickname: string + avatar_url: string +} + +type PlanSquarePostCard = { + post_id: number + title: string + summary: string + tags: string[] + author: UserBrief + template_summary: { + task_count: number + mode: string + start_date: string + end_date: string + strategy_labels: string[] + } + counters: { + like_count: number + comment_count: number + import_count: number + } + viewer_state: { + liked: boolean + imported_once: boolean + } + status: 'published' + created_at: string +} + +type CommentNode = { + comment_id: number + post_id: number + parent_comment_id: number | null + content: string + status: 'visible' | 'deleted' + author: UserBrief + can_delete: boolean + created_at: string + deleted_at: string | null + children: CommentNode[] +} + +type TokenProduct = { + product_id: number + name: string + description: string + token_amount: number + price_cent: number + price_text: string + currency: 'CNY' + badge: string + status: 'active' | 'inactive' + sort_order: number +} + +type TokenGrant = { + grant_id: number + event_id: string + source: 'purchase' | 'forum_like' | 'forum_import' | 'manual' + source_label: string + amount: number + status: 'recorded' | 'applied' | 'skipped' | 'failed' + quota_applied: boolean + description: string + created_at: string +} +``` + +## 9. P0 不需要前端处理的内容 + +1. 不做计划推荐算法,只做排序和筛选。 +2. 不做关注、私信、用户主页。 +3. 不做富文本,简介和评论都是纯文本。 +4. 不做管理员删评、举报和审核后台。 +5. 不接真实支付网关。 +6. 不直接展示 `user/auth` 权威可用额度,除非后续接口已经切通。 From 46874f0806e8494d08eceac8af65884bd91ba37c Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 4 May 2026 20:38:49 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=B9=BF=E5=9C=BA=E5=90=8E=E7=AB=AF=E4=B8=BB=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/start.go | 13 +- backend/cmd/taskclassforum/main.go | 14 +- backend/config.example.yaml | 8 + backend/gateway/forumapi/handler.go | 429 ++++++++++++++++ backend/gateway/forumapi/routes.go | 87 ++++ backend/gateway/router/router.go | 5 +- backend/gateway/taskclassforum/client.go | 470 ++++++++++++++++++ backend/gateway/taskclassforum/errors.go | 94 ++++ backend/middleware/idempotency.go | 15 +- .../adapter/legacy_taskclass.go | 448 +++++++++++++++++ .../taskclassforum/commenttree/tree.go | 204 ++++++++ backend/services/taskclassforum/dao/forum.go | 453 +++++++++++++++++ .../services/taskclassforum/model/forum.go | 12 +- backend/services/taskclassforum/rpc/errors.go | 4 - backend/services/taskclassforum/sv/comment.go | 202 ++++++++ backend/services/taskclassforum/sv/errors.go | 8 + backend/services/taskclassforum/sv/helpers.go | 294 +++++++++++ backend/services/taskclassforum/sv/import.go | 236 +++++++++ backend/services/taskclassforum/sv/like.go | 111 +++++ backend/services/taskclassforum/sv/post.go | 339 +++++++++++++ backend/services/taskclassforum/sv/service.go | 91 +--- .../shared/contracts/taskclassforum/types.go | 6 +- 22 files changed, 3439 insertions(+), 104 deletions(-) create mode 100644 backend/gateway/forumapi/handler.go create mode 100644 backend/gateway/forumapi/routes.go create mode 100644 backend/gateway/taskclassforum/client.go create mode 100644 backend/gateway/taskclassforum/errors.go create mode 100644 backend/services/taskclassforum/adapter/legacy_taskclass.go create mode 100644 backend/services/taskclassforum/commenttree/tree.go create mode 100644 backend/services/taskclassforum/dao/forum.go create mode 100644 backend/services/taskclassforum/sv/comment.go create mode 100644 backend/services/taskclassforum/sv/errors.go create mode 100644 backend/services/taskclassforum/sv/helpers.go create mode 100644 backend/services/taskclassforum/sv/import.go create mode 100644 backend/services/taskclassforum/sv/like.go create mode 100644 backend/services/taskclassforum/sv/post.go diff --git a/backend/cmd/start.go b/backend/cmd/start.go index ae98449..9aafda7 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -24,6 +24,7 @@ import ( "github.com/LoveLosita/smartflow/backend/bootstrap" "github.com/LoveLosita/smartflow/backend/dao" gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router" + gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -73,6 +74,7 @@ type appRuntime struct { limiter *pkg.RateLimiter handlers *api.ApiHandlers userAuthClient *gatewayuserauth.Client + taskClassForumClient *gatewaytaskclassforum.Client } // loadConfig 锻炼? @@ -215,6 +217,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if err != nil { return nil, fmt.Errorf("failed to initialize userauth zrpc client: %w", err) } + taskClassForumClient, err := gatewaytaskclassforum.NewClient(gatewaytaskclassforum.ClientConfig{ + Endpoints: viper.GetStringSlice("taskclassforum.rpc.endpoints"), + Target: viper.GetString("taskclassforum.rpc.target"), + Timeout: viper.GetDuration("taskclassforum.rpc.timeout"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err) + } taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(llmService, courseRepo, scheduleRepo) @@ -324,6 +334,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { limiter: limiter, handlers: handlers, userAuthClient: userAuthClient, + taskClassForumClient: taskClassForumClient, } if runtime.eventBus != nil { if err := runtime.registerEventHandlers(); err != nil { @@ -904,7 +915,7 @@ func (r *appRuntime) registerEventHandlers() error { } func (r *appRuntime) startHTTP(ctx context.Context) { - router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.cacheRepo, r.limiter) + router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.cacheRepo, r.limiter) gatewayrouter.StartEngine(ctx, router) } diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go index ddf8f48..6230532 100644 --- a/backend/cmd/taskclassforum/main.go +++ b/backend/cmd/taskclassforum/main.go @@ -4,6 +4,8 @@ import ( "log" "github.com/LoveLosita/smartflow/backend/bootstrap" + legacydao "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv" @@ -20,10 +22,14 @@ func main() { 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}) + // 1. 复用同一个 DB 句柄装配 legacy TaskClass DAO,避免本轮抢改 task-class 模块。 + // 2. 计划广场只通过快照端口读取和创建 TaskClass,不直接写 schedule。 + // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 + taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) + svc := forumsv.New(forumsv.Options{ + DB: db, + TaskClassPort: taskClassPort, + }) forumrpc.Start(forumrpc.ServerOptions{ ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), Timeout: viper.GetDuration("taskclassforum.rpc.timeout"), diff --git a/backend/config.example.yaml b/backend/config.example.yaml index e6ea27a..db3ab4d 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -37,6 +37,14 @@ userauth: - "127.0.0.1:9081" timeout: 2s +# 计划广场 zrpc 独立服务与网关客户端配置。 +taskclassforum: + rpc: + listenOn: "0.0.0.0:9082" + endpoints: + - "127.0.0.1:9082" + timeout: 2s + # Kafka outbox 事件总线配置。 kafka: enabled: true diff --git a/backend/gateway/forumapi/handler.go b/backend/gateway/forumapi/handler.go new file mode 100644 index 0000000..7564ec4 --- /dev/null +++ b/backend/gateway/forumapi/handler.go @@ -0,0 +1,429 @@ +package forumapi + +import ( + "context" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "github.com/gin-gonic/gin" +) + +const requestTimeout = 2 * time.Second + +type ForumClient interface { + ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) + ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error) + CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error) + GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error) + LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) + UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) + ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error) + CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error) + DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error) + ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error) +} + +type Handler struct { + client ForumClient +} + +func NewHandler(client ForumClient) *Handler { + return &Handler{client: client} +} + +type pageEnvelope[T any] struct { + Items []T `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + HasMore bool `json:"has_more"` +} + +type interactionEnvelope struct { + PostID uint64 `json:"post_id"` + Liked bool `json:"liked"` + LikeCount int64 `json:"like_count"` + RewardHint *rewardHint `json:"reward_hint,omitempty"` +} + +type rewardHint struct { + Receiver string `json:"receiver"` + Status string `json:"status"` + Amount int64 `json:"amount"` +} + +type nextAction struct { + Type string `json:"type"` + TaskClassID uint64 `json:"task_class_id"` +} + +type importEnvelope struct { + ImportID uint64 `json:"import_id"` + PostID uint64 `json:"post_id"` + NewTaskClassID uint64 `json:"new_task_class_id"` + TaskClassTitle string `json:"task_class_title"` + ImportCount int64 `json:"import_count"` + RewardHint rewardHint `json:"reward_hint"` + NextAction nextAction `json:"next_action"` + CreatedAt string `json:"created_at"` +} + +type deleteCommentEnvelope struct { + CommentID uint64 `json:"comment_id"` + Status string `json:"status"` + Content string `json:"content"` + DeletedAt *string `json:"deleted_at"` +} + +type createPostBody struct { + TaskClassID uint64 `json:"task_class_id"` + Title string `json:"title"` + Summary string `json:"summary"` + Tags []string `json:"tags"` +} + +type createCommentBody struct { + Content string `json:"content"` + ParentCommentID *uint64 `json:"parent_comment_id"` +} + +type importPostBody struct { + TargetTitle string `json:"target_title"` +} + +func (h *Handler) ListPosts(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + pageValue, ok := intQuery(c, "page") + if !ok { + return + } + pageSize, ok := intQuery(c, "page_size") + if !ok { + return + } + items, page, err := client.ListPosts( + ctx, + currentUserID(c), + pageValue, + pageSize, + c.Query("sort"), + c.Query("keyword"), + c.Query("tag"), + ) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page))) +} + +func (h *Handler) ListTags(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + limit, ok := intQuery(c, "limit") + if !ok { + return + } + items, err := client.ListTags(ctx, currentUserID(c), limit) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items})) +} + +func (h *Handler) CreatePost(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + var body createPostBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + post, err := client.CreatePost(ctx, contracts.CreateForumPostRequest{ + ActorUserID: currentUserID(c), + TaskClassID: body.TaskClassID, + Title: body.Title, + Summary: body.Summary, + Tags: append([]string(nil), body.Tags...), + IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, post)) +} + +func (h *Handler) GetPost(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + detail, err := client.GetPost(ctx, currentUserID(c), postID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail)) +} + +func (h *Handler) LikePost(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + counters, state, err := client.LikePost(ctx, currentUserID(c), postID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{ + PostID: postID, + Liked: state.Liked, + LikeCount: counters.LikeCount, + RewardHint: &rewardHint{ + Receiver: "author", + Status: "recorded", + Amount: 1, + }, + })) +} + +func (h *Handler) UnlikePost(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + counters, state, err := client.UnlikePost(ctx, currentUserID(c), postID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, interactionEnvelope{ + PostID: postID, + Liked: state.Liked, + LikeCount: counters.LikeCount, + })) +} + +func (h *Handler) ListComments(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + pageValue, ok := intQuery(c, "page") + if !ok { + return + } + pageSize, ok := intQuery(c, "page_size") + if !ok { + return + } + items, page, err := client.ListComments(ctx, currentUserID(c), postID, pageValue, pageSize, c.Query("sort")) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page))) +} + +func (h *Handler) CreateComment(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + var body createCommentBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + comment, err := client.CreateComment(ctx, contracts.CreateForumCommentRequest{ + ActorUserID: currentUserID(c), + PostID: postID, + Content: body.Content, + ParentCommentID: body.ParentCommentID, + IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, comment)) +} + +func (h *Handler) DeleteComment(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + commentID, ok := uint64Param(c, "comment_id") + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + result, err := client.DeleteComment(ctx, currentUserID(c), commentID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, deleteCommentEnvelope{ + CommentID: result.CommentID, + Status: result.Status, + Content: result.Content, + DeletedAt: result.DeletedAt, + })) +} + +func (h *Handler) ImportPost(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + postID, ok := uint64Param(c, "post_id") + if !ok { + return + } + var body importPostBody + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + result, err := client.ImportPost(ctx, contracts.ImportForumPostRequest{ + ActorUserID: currentUserID(c), + PostID: postID, + TargetTitle: body.TargetTitle, + IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, importEnvelope{ + ImportID: result.ImportID, + PostID: result.PostID, + NewTaskClassID: result.NewTaskClassID, + TaskClassTitle: result.TaskClassTitle, + ImportCount: result.ImportCount, + RewardHint: rewardHint{ + Receiver: "author", + Status: "recorded", + Amount: 2, + }, + NextAction: nextAction{ + Type: "open_task_class", + TaskClassID: result.NewTaskClassID, + }, + CreatedAt: result.CreatedAt, + })) +} + +func (h *Handler) ready(c *gin.Context) (ForumClient, bool) { + if h == nil || h.client == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场 gateway client 未初始化"))) + return nil, false + } + return h.client, true +} + +func currentUserID(c *gin.Context) uint64 { + userID := c.GetInt("user_id") + if userID <= 0 { + return 0 + } + return uint64(userID) +} + +func newPageEnvelope[T any](items []T, page contracts.PageResult) pageEnvelope[T] { + return pageEnvelope[T]{ + Items: items, + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + HasMore: page.HasMore, + } +} + +func intQuery(c *gin.Context, key string) (int, bool) { + raw := strings.TrimSpace(c.Query(key)) + if raw == "" { + return 0, true + } + value, err := strconv.Atoi(raw) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return 0, false + } + return value, true +} + +func uint64Param(c *gin.Context, key string) (uint64, bool) { + value, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64) + if err != nil || value == 0 { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return 0, false + } + return value, true +} diff --git a/backend/gateway/forumapi/routes.go b/backend/gateway/forumapi/routes.go new file mode 100644 index 0000000..c33902e --- /dev/null +++ b/backend/gateway/forumapi/routes.go @@ -0,0 +1,87 @@ +package forumapi + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/LoveLosita/smartflow/backend/dao" + gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" + rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" + "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/LoveLosita/smartflow/backend/shared/ports" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 把计划广场 HTTP 入口挂到 gateway 路由组。 +// +// 职责边界: +// 1. 只注册 /plan-square 下的边缘路由,不承载论坛业务规则; +// 2. 公开读接口允许匿名访问,若携带 token 则补齐 viewer_state; +// 3. 写接口必须登录,并按既有 Redis 幂等中间件保护重复提交。 +func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *pkg.RateLimiter) { + if apiGroup == nil || handler == nil { + return + } + + planSquare := apiGroup.Group("/plan-square") + { + publicGroup := planSquare.Group("") + publicGroup.Use(optionalJWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 40, 1)) + publicGroup.GET("/posts", handler.ListPosts) + publicGroup.GET("/tags", handler.ListTags) + publicGroup.GET("/posts/:post_id", handler.GetPost) + publicGroup.GET("/posts/:post_id/comments", handler.ListComments) + + writeGroup := planSquare.Group("") + writeGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1)) + writeGroup.POST("/posts", rootmiddleware.IdempotencyMiddleware(cache), handler.CreatePost) + writeGroup.POST("/posts/:post_id/like", handler.LikePost) + writeGroup.DELETE("/posts/:post_id/like", handler.UnlikePost) + writeGroup.POST("/posts/:post_id/comments", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateComment) + writeGroup.DELETE("/comments/:comment_id", handler.DeleteComment) + writeGroup.POST("/posts/:post_id/import", rootmiddleware.IdempotencyMiddleware(cache), handler.ImportPost) + } +} + +// optionalJWTTokenAuth 为计划广场公开读接口提供“可登录增强”。 +// +// 步骤说明: +// 1. 没有 Authorization 时直接放行,让匿名用户也能浏览计划广场; +// 2. 有 Authorization 时复用 user/auth 校验,并把 user_id 写入上下文; +// 3. token 非法时按正常鉴权失败返回,避免前端误以为已登录状态仍可用。 +func optionalJWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc { + return func(c *gin.Context) { + tokenString := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization")) + if tokenString == "" { + c.Next() + return + } + if validator == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("计划广场可选鉴权依赖未初始化"))) + c.Abort() + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + resp, err := validator.ValidateAccessToken(ctx, tokenString) + if err != nil { + respond.DealWithError(c, err) + c.Abort() + return + } + if resp == nil || !resp.Valid || resp.UserID <= 0 { + c.JSON(http.StatusUnauthorized, respond.InvalidClaims) + c.Abort() + return + } + + c.Set("user_id", resp.UserID) + c.Set("claims", resp) + c.Next() + } +} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 0185881..8136c1c 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -9,7 +9,9 @@ import ( "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/gateway/forumapi" gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" + gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" "github.com/LoveLosita/smartflow/backend/gateway/userapi" rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" "github.com/LoveLosita/smartflow/backend/pkg" @@ -55,7 +57,7 @@ func StartEngine(ctx context.Context, r *gin.Engine) { } } -func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { +func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { r := gin.Default() apiGroup := r.Group("/api/v1") { @@ -67,6 +69,7 @@ func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, }) userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter) + forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter) taskGroup := apiGroup.Group("/task") { diff --git a/backend/gateway/taskclassforum/client.go b/backend/gateway/taskclassforum/client.go new file mode 100644 index 0000000..45c9504 --- /dev/null +++ b/backend/gateway/taskclassforum/client.go @@ -0,0 +1,470 @@ +package taskclassforum + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc/pb" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "github.com/zeromicro/go-zero/zrpc" +) + +const ( + defaultEndpoint = "127.0.0.1:9082" + defaultTimeout = 2 * time.Second +) + +type ClientConfig struct { + Endpoints []string + Target string + Timeout time.Duration +} + +// Client 是 gateway 侧访问计划广场 zrpc 的适配层。 +// +// 职责边界: +// 1. 只负责 HTTP gateway 与 taskclassforum zrpc 之间的协议转译; +// 2. 不直连 forum_* 表,也不读取旧 TaskClass 表,所有业务规则交给 taskclassforum 服务; +// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。 +type Client struct { + rpc pb.TaskClassForumServiceClient +} + +func NewClient(cfg ClientConfig) (*Client, error) { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + endpoints := normalizeEndpoints(cfg.Endpoints) + target := strings.TrimSpace(cfg.Target) + if len(endpoints) == 0 && target == "" { + endpoints = []string{defaultEndpoint} + } + + zclient, err := zrpc.NewClient(zrpc.RpcClientConf{ + Endpoints: endpoints, + Target: target, + NonBlock: true, + Timeout: int64(timeout / time.Millisecond), + }) + if err != nil { + return nil, err + } + return &Client{rpc: pb.NewTaskClassForumServiceClient(zclient.Conn())}, nil +} + +func (c *Client) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) { + if err := c.ensureReady(); err != nil { + return nil, contracts.PageResult{}, err + } + resp, err := c.rpc.ListPosts(ctx, &pb.ListForumPostsRequest{ + ActorUserId: actorUserID, + Page: int32(page), + PageSize: int32(pageSize), + Sort: sort, + Keyword: keyword, + Tag: tag, + }) + if err != nil { + return nil, contracts.PageResult{}, responseFromRPCError(err) + } + if resp == nil { + return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list posts response") + } + return forumPostBriefsFromPB(resp.Items), pageFromPB(resp.Page), nil +} + +func (c *Client) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]contracts.ForumTagItem, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.ListTags(ctx, &pb.ListForumTagsRequest{ + ActorUserId: actorUserID, + Limit: int32(limit), + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty list tags response") + } + return forumTagItemsFromPB(resp.Items), nil +} + +func (c *Client) CreatePost(ctx context.Context, req contracts.CreateForumPostRequest) (*contracts.ForumPostBrief, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.CreatePost(ctx, &pb.CreateForumPostRequest{ + ActorUserId: req.ActorUserID, + TaskClassId: req.TaskClassID, + Title: req.Title, + Summary: req.Summary, + Tags: append([]string(nil), req.Tags...), + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty create post response") + } + post := forumPostBriefFromPB(resp.Post) + return &post, nil +} + +func (c *Client) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*contracts.ForumPostDetail, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.GetPost(ctx, &pb.GetForumPostRequest{ + ActorUserId: actorUserID, + PostId: postID, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty get post response") + } + data := forumPostDetailFromPB(resp.Data) + return &data, nil +} + +func (c *Client) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) { + if err := c.ensureReady(); err != nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err + } + resp, err := c.rpc.LikePost(ctx, &pb.LikeForumPostRequest{ + ActorUserId: actorUserID, + PostId: postID, + }) + if err != nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err) + } + if resp == nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty like response") + } + return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil +} + +func (c *Client) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (contracts.ForumPostCounters, contracts.ForumPostViewerState, error) { + if err := c.ensureReady(); err != nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, err + } + resp, err := c.rpc.UnlikePost(ctx, &pb.UnlikeForumPostRequest{ + ActorUserId: actorUserID, + PostId: postID, + }) + if err != nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, responseFromRPCError(err) + } + if resp == nil { + return contracts.ForumPostCounters{}, contracts.ForumPostViewerState{}, errors.New("taskclassforum zrpc service returned empty unlike response") + } + return forumPostCountersFromPB(resp.Counters), forumPostViewerStateFromPB(resp.ViewerState), nil +} + +func (c *Client) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sort string) ([]contracts.ForumCommentNode, contracts.PageResult, error) { + if err := c.ensureReady(); err != nil { + return nil, contracts.PageResult{}, err + } + resp, err := c.rpc.ListComments(ctx, &pb.ListForumCommentsRequest{ + ActorUserId: actorUserID, + PostId: postID, + Page: int32(page), + PageSize: int32(pageSize), + Sort: sort, + }) + if err != nil { + return nil, contracts.PageResult{}, responseFromRPCError(err) + } + if resp == nil { + return nil, contracts.PageResult{}, errors.New("taskclassforum zrpc service returned empty list comments response") + } + return forumCommentNodesFromPB(resp.Items), pageFromPB(resp.Page), nil +} + +func (c *Client) CreateComment(ctx context.Context, req contracts.CreateForumCommentRequest) (*contracts.ForumCommentNode, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.CreateComment(ctx, &pb.CreateForumCommentRequest{ + ActorUserId: req.ActorUserID, + PostId: req.PostID, + Content: req.Content, + ParentCommentId: uint64FromPtr(req.ParentCommentID), + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty create comment response") + } + comment := forumCommentNodeFromPB(resp.Comment) + return &comment, nil +} + +func (c *Client) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*contracts.DeleteForumCommentResult, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.DeleteComment(ctx, &pb.DeleteForumCommentRequest{ + ActorUserId: actorUserID, + CommentId: commentID, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty delete comment response") + } + deletedAt := time.Now().Format(time.RFC3339) + return &contracts.DeleteForumCommentResult{ + CommentID: resp.CommentId, + Status: resp.Status, + Content: "", + DeletedAt: &deletedAt, + }, nil +} + +func (c *Client) ImportPost(ctx context.Context, req contracts.ImportForumPostRequest) (*contracts.ImportForumPostResult, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.ImportPost(ctx, &pb.ImportForumPostRequest{ + ActorUserId: req.ActorUserID, + PostId: req.PostID, + TargetTitle: req.TargetTitle, + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("taskclassforum zrpc service returned empty import post response") + } + return &contracts.ImportForumPostResult{ + ImportID: resp.ImportId, + PostID: resp.PostId, + NewTaskClassID: resp.NewTaskClassId, + TaskClassTitle: resp.TaskClassTitle, + ImportCount: resp.ImportCount, + CreatedAt: resp.CreatedAt, + }, nil +} + +func (c *Client) ensureReady() error { + if c == nil || c.rpc == nil { + return errors.New("taskclassforum zrpc client is not initialized") + } + return nil +} + +func normalizeEndpoints(values []string) []string { + endpoints := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + endpoints = append(endpoints, trimmed) + } + } + return endpoints +} + +func pageFromPB(page *pb.PageResponse) contracts.PageResult { + if page == nil { + return contracts.PageResult{} + } + return contracts.PageResult{ + Page: int(page.Page), + PageSize: int(page.PageSize), + Total: int(page.Total), + HasMore: page.HasMore, + } +} + +func forumUserFromPB(user *pb.UserBrief) contracts.UserBrief { + if user == nil { + return contracts.UserBrief{} + } + return contracts.UserBrief{ + UserID: user.UserId, + Nickname: user.Nickname, + AvatarURL: user.AvatarUrl, + } +} + +func forumTemplateSummaryFromPB(summary *pb.TemplateSummary) contracts.TemplateSummary { + if summary == nil { + return contracts.TemplateSummary{} + } + return contracts.TemplateSummary{ + TaskCount: int(summary.TaskCount), + Mode: summary.Mode, + StartDate: summary.StartDate, + EndDate: summary.EndDate, + StrategyLabels: append([]string(nil), summary.StrategyLabels...), + } +} + +func forumPostCountersFromPB(counters *pb.ForumPostCounters) contracts.ForumPostCounters { + if counters == nil { + return contracts.ForumPostCounters{} + } + return contracts.ForumPostCounters{ + LikeCount: counters.LikeCount, + CommentCount: counters.CommentCount, + ImportCount: counters.ImportCount, + } +} + +func forumPostViewerStateFromPB(state *pb.ForumPostViewerState) contracts.ForumPostViewerState { + if state == nil { + return contracts.ForumPostViewerState{} + } + return contracts.ForumPostViewerState{ + Liked: state.Liked, + ImportedOnce: state.ImportedOnce, + } +} + +func forumPostBriefFromPB(post *pb.ForumPostBrief) contracts.ForumPostBrief { + if post == nil { + return contracts.ForumPostBrief{} + } + return contracts.ForumPostBrief{ + PostID: post.PostId, + Title: post.Title, + Summary: post.Summary, + Tags: append([]string(nil), post.Tags...), + Author: forumUserFromPB(post.Author), + TemplateSummary: forumTemplateSummaryFromPB(post.TemplateSummary), + Counters: forumPostCountersFromPB(post.Counters), + ViewerState: forumPostViewerStateFromPB(post.ViewerState), + Status: post.Status, + CreatedAt: post.CreatedAt, + } +} + +func forumPostBriefsFromPB(items []*pb.ForumPostBrief) []contracts.ForumPostBrief { + if len(items) == 0 { + return []contracts.ForumPostBrief{} + } + result := make([]contracts.ForumPostBrief, 0, len(items)) + for _, item := range items { + result = append(result, forumPostBriefFromPB(item)) + } + return result +} + +func forumTemplateDetailFromPB(detail *pb.TemplateDetail) contracts.TemplateDetail { + if detail == nil { + return contracts.TemplateDetail{} + } + items := make([]contracts.TemplateItemPreview, 0, len(detail.ItemsPreview)) + for _, item := range detail.ItemsPreview { + if item == nil { + continue + } + items = append(items, contracts.TemplateItemPreview{ + ItemID: item.ItemId, + Order: int(item.Order), + Content: item.Content, + }) + } + return contracts.TemplateDetail{ + Mode: detail.Mode, + StartDate: detail.StartDate, + EndDate: detail.EndDate, + StrategyLabels: append([]string(nil), detail.StrategyLabels...), + TaskCount: int(detail.TaskCount), + ItemsPreview: items, + } +} + +func forumPostDetailFromPB(detail *pb.ForumPostDetail) contracts.ForumPostDetail { + if detail == nil { + return contracts.ForumPostDetail{} + } + return contracts.ForumPostDetail{ + Post: forumPostBriefFromPB(detail.Post), + Template: forumTemplateDetailFromPB(detail.Template), + } +} + +func forumTagItemsFromPB(items []*pb.ForumTagItem) []contracts.ForumTagItem { + if len(items) == 0 { + return []contracts.ForumTagItem{} + } + result := make([]contracts.ForumTagItem, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + result = append(result, contracts.ForumTagItem{ + Tag: item.Tag, + PostCount: int(item.PostCount), + }) + } + return result +} + +func forumCommentNodeFromPB(node *pb.ForumCommentNode) contracts.ForumCommentNode { + if node == nil { + return contracts.ForumCommentNode{} + } + children := make([]contracts.ForumCommentNode, 0, len(node.Children)) + for _, child := range node.Children { + children = append(children, forumCommentNodeFromPB(child)) + } + return contracts.ForumCommentNode{ + CommentID: node.CommentId, + PostID: node.PostId, + ParentCommentID: uint64PtrFromPositive(node.ParentCommentId), + Content: node.Content, + Status: node.Status, + Author: forumUserFromPB(node.Author), + CanDelete: node.CanDelete, + CreatedAt: node.CreatedAt, + DeletedAt: stringPtrFromNonEmpty(node.DeletedAt), + Children: children, + } +} + +func forumCommentNodesFromPB(items []*pb.ForumCommentNode) []contracts.ForumCommentNode { + if len(items) == 0 { + return []contracts.ForumCommentNode{} + } + result := make([]contracts.ForumCommentNode, 0, len(items)) + for _, item := range items { + result = append(result, forumCommentNodeFromPB(item)) + } + return result +} + +func uint64FromPtr(value *uint64) uint64 { + if value == nil { + return 0 + } + return *value +} + +func uint64PtrFromPositive(value uint64) *uint64 { + if value == 0 { + return nil + } + result := value + return &result +} + +func stringPtrFromNonEmpty(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/backend/gateway/taskclassforum/errors.go b/backend/gateway/taskclassforum/errors.go new file mode 100644 index 0000000..fd975bc --- /dev/null +++ b/backend/gateway/taskclassforum/errors.go @@ -0,0 +1,94 @@ +package taskclassforum + +import ( + "errors" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// responseFromRPCError 把计划广场 zrpc 错误恢复成 HTTP 层可处理的业务错误。 +// +// 职责边界: +// 1. 优先读取 taskclassforum RPC 写入的 ErrorInfo,恢复 respond.Response; +// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error,让 HTTP 层按 500 处理; +// 3. 暂不复用 userauth/errors.go,因为 user/auth 还承担历史 legacy code 兼容,计划广场只消费新 ErrorInfo 协议。 +func responseFromRPCError(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return wrapRPCError(err) + } + if resp, ok := responseFromStatusDetails(st); ok { + return resp + } + + switch st.Code() { + case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented: + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "taskclassforum zrpc service internal error" + } + return wrapRPCError(errors.New(msg)) + case codes.NotFound: + return responseWithFallback(st, respond.UserTaskClassNotFound) + case codes.PermissionDenied, codes.Unauthenticated: + return responseWithFallback(st, respond.ErrUnauthorized) + case codes.InvalidArgument: + return responseWithFallback(st, respond.MissingParam) + } + + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "taskclassforum zrpc service rejected request" + } + return respond.Response{Status: "400", Info: msg} +} + +func responseFromStatusDetails(st *status.Status) (respond.Response, bool) { + if st == nil { + return respond.Response{}, false + } + for _, detail := range st.Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + + statusValue := strings.TrimSpace(info.Reason) + if statusValue == "" { + return respond.Response{}, false + } + message := strings.TrimSpace(st.Message()) + if message == "" && info.Metadata != nil { + message = strings.TrimSpace(info.Metadata["info"]) + } + if message == "" { + message = statusValue + } + return respond.Response{Status: statusValue, Info: message}, true + } + return respond.Response{}, false +} + +func responseWithFallback(st *status.Status, fallback respond.Response) respond.Response { + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = fallback.Info + } + return respond.Response{Status: fallback.Status, Info: msg} +} + +func wrapRPCError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("调用 taskclassforum zrpc 服务失败: %w", err) +} diff --git a/backend/middleware/idempotency.go b/backend/middleware/idempotency.go index e8ec515..c376b56 100644 --- a/backend/middleware/idempotency.go +++ b/backend/middleware/idempotency.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "strings" "time" "github.com/LoveLosita/smartflow/backend/dao" @@ -39,7 +40,8 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } userID := c.GetInt("user_id") // 假设 JWT 已存入 - redisKey := fmt.Sprintf("idempotency:%d:%s", userID, ikey) + routeKey := idempotencyRouteKey(c) + redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey) // 2. 查 Redis 缓存 cachedData, err := cache.GetRecord(c, redisKey) @@ -94,3 +96,14 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } } } + +func idempotencyRouteKey(c *gin.Context) string { + // 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。 + // 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。 + // 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。 + route := strings.TrimSpace(c.FullPath()) + if route == "" && c.Request != nil && c.Request.URL != nil { + route = strings.TrimSpace(c.Request.URL.Path) + } + return strings.ReplaceAll(route, ":", "_") +} diff --git a/backend/services/taskclassforum/adapter/legacy_taskclass.go b/backend/services/taskclassforum/adapter/legacy_taskclass.go new file mode 100644 index 0000000..39cbd0a --- /dev/null +++ b/backend/services/taskclassforum/adapter/legacy_taskclass.go @@ -0,0 +1,448 @@ +package adapter + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + "time" + + legacydao "github.com/LoveLosita/smartflow/backend/dao" + legacymodel "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" + forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv" + "gorm.io/gorm" +) + +const legacyTaskClassDateLayout = "2006-01-02" + +var errLegacyTaskClassAdapterNotReady = errors.New("taskclassforum legacy taskclass adapter is not initialized") + +// LegacyTaskClassAdapter 负责把旧 task-class DAO 适配成计划广场需要的快照端口。 +// +// 职责边界: +// 1. 只复用旧 TaskClassDAO 读写 task_classes / task_items; +// 2. 只产出/消费 TaskClass 白名单快照,不透传 embedded_time 和任何 schedule 绑定; +// 3. 不承载论坛帖子、模板、导入记录事务,这些仍由 taskclassforum service 编排。 +type LegacyTaskClassAdapter struct { + taskClassDAO *legacydao.TaskClassDAO +} + +var _ forumsv.TaskClassSnapshotPort = (*LegacyTaskClassAdapter)(nil) + +// NewLegacyTaskClassAdapter 创建 legacy TaskClass 适配器。 +// +// 职责边界: +// 1. 只做依赖注入,不主动探活数据库; +// 2. 不创建 DAO 以外的额外资源; +// 3. 若传入 nil,真正报错延后到方法调用时返回,便于上层统一做依赖检查。 +func NewLegacyTaskClassAdapter(taskClassDAO *legacydao.TaskClassDAO) *LegacyTaskClassAdapter { + return &LegacyTaskClassAdapter{ + taskClassDAO: taskClassDAO, + } +} + +// GetOwnedTaskClassSnapshot 读取当前用户自己的旧 TaskClass,并投影为论坛可分享快照。 +// +// 职责边界: +// 1. 只读取 user_id 归属下的单个 TaskClass; +// 2. 只返回白名单字段与条目 source id/order/content; +// 3. 不返回 embedded_time、schedule 绑定和其他用户私有排程状态。 +func (a *LegacyTaskClassAdapter) GetOwnedTaskClassSnapshot(ctx context.Context, userID uint64, taskClassID uint64) (*forumsv.TaskClassSnapshot, error) { + if err := a.ensureReady(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + userIDInt, err := toUserID(userID) + if err != nil { + return nil, err + } + taskClassIDInt, err := toTaskClassID(taskClassID) + if err != nil { + return nil, err + } + + legacyTaskClass, err := a.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassIDInt, userIDInt) + if err != nil { + return nil, normalizeLegacyTaskClassLookupError(err) + } + if legacyTaskClass == nil { + return nil, respond.UserTaskClassNotFound + } + + snapshot, err := snapshotFromLegacyTaskClass(*legacyTaskClass) + if err != nil { + return nil, err + } + return &snapshot, nil +} + +// CreateTaskClassFromSnapshot 基于论坛模板快照为当前用户创建旧 TaskClass 副本。 +// +// 职责边界: +// 1. 只创建 task_classes / task_items 副本,不写 forum_imports; +// 2. 只写白名单字段,所有新建 item 都强制重置为未安排状态; +// 3. 不保留原始 item ID,避免误触旧 DAO 的“更新已有记录”分支。 +func (a *LegacyTaskClassAdapter) CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot forumsv.TaskClassSnapshot, targetTitle string) (*forumsv.CreatedTaskClass, error) { + if err := a.ensureReady(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + userIDInt, err := toUserID(userID) + if err != nil { + return nil, err + } + + title := strings.TrimSpace(targetTitle) + if title == "" { + title = strings.TrimSpace(snapshot.Title) + } + if title == "" || strings.TrimSpace(snapshot.Mode) == "" { + return nil, respond.MissingParam + } + + startDate, endDate, err := parseSnapshotDateRange(snapshot.Mode, snapshot.StartDate, snapshot.EndDate) + if err != nil { + return nil, err + } + + createTaskClass := buildLegacyTaskClassModel(title, snapshot, userIDInt, startDate, endDate) + createItems := buildLegacyTaskClassItems(snapshot.Items) + if len(createItems) == 0 { + return nil, respond.MissingParam + } + + created := &forumsv.CreatedTaskClass{ + Title: title, + } + + // 1. 先在旧 DAO 事务里创建 task_class 主记录,拿到新主键。 + // 2. 再把所有快照条目改写成“当前用户的新副本条目”,统一挂到新主键下。 + // 3. 任一步失败都回滚,避免出现“有主表、没子项”的半写状态。 + if err := a.taskClassDAO.Transaction(func(txDAO *legacydao.TaskClassDAO) error { + taskClassID, txErr := txDAO.AddOrUpdateTaskClass(userIDInt, createTaskClass) + if txErr != nil { + return txErr + } + + for i := range createItems { + createItems[i].CategoryID = intPtr(taskClassID) + } + if txErr := txDAO.AddOrUpdateTaskClassItems(userIDInt, createItems); txErr != nil { + return txErr + } + + created.TaskClassID = uint64(taskClassID) + return nil + }); err != nil { + return nil, err + } + + return created, nil +} + +// snapshotFromLegacyTaskClass 把旧 TaskClass 模型转换成论坛白名单快照。 +// +// 职责边界: +// 1. 负责字段投影与默认值归一化; +// 2. 负责过滤 embedded_time,只保留条目 source id/order/content; +// 3. 负责生成与论坛模板同口径的 ConfigSnapshotJSON。 +func snapshotFromLegacyTaskClass(taskClass legacymodel.TaskClass) (forumsv.TaskClassSnapshot, error) { + items := snapshotItemsFromLegacyItems(taskClass.Items) + snapshot := forumsv.TaskClassSnapshot{ + TaskClassID: uint64(taskClass.ID), + Title: stringValue(taskClass.Name), + Mode: stringValue(taskClass.Mode), + StartDate: formatDate(taskClass.StartDate), + EndDate: formatDate(taskClass.EndDate), + SubjectType: stringValue(taskClass.SubjectType), + DifficultyLevel: stringValue(taskClass.DifficultyLevel), + CognitiveIntensity: stringValue(taskClass.CognitiveIntensity), + TotalSlots: intValue(taskClass.TotalSlots), + AllowFillerCourse: boolValue(taskClass.AllowFillerCourse), + Strategy: stringValue(taskClass.Strategy), + ExcludedSlots: cloneIntSlice([]int(taskClass.ExcludedSlots)), + ExcludedDaysOfWeek: cloneIntSlice([]int(taskClass.ExcludedDaysOfWeek)), + StrategyLabels: legacyStrategyLabels(stringValue(taskClass.Strategy)), + Items: items, + } + + configJSON, err := buildConfigSnapshotJSON(snapshot) + if err != nil { + return forumsv.TaskClassSnapshot{}, err + } + snapshot.ConfigSnapshotJSON = configJSON + return snapshot, nil +} + +// snapshotItemsFromLegacyItems 过滤旧 task_items 的可分享字段。 +// +// 职责边界: +// 1. 只保留 source id、order、content; +// 2. 不复制 embedded_time、status 等用户私有排程状态; +// 3. 输出前按 order、source id 做稳定排序,保证论坛快照可重复。 +func snapshotItemsFromLegacyItems(items []legacymodel.TaskClassItem) []forumsv.TaskClassSnapshotItem { + if len(items) == 0 { + return []forumsv.TaskClassSnapshotItem{} + } + + sorted := append([]legacymodel.TaskClassItem(nil), items...) + sort.SliceStable(sorted, func(i, j int) bool { + leftOrder := intValue(sorted[i].Order) + rightOrder := intValue(sorted[j].Order) + if leftOrder != rightOrder { + return leftOrder < rightOrder + } + return sorted[i].ID < sorted[j].ID + }) + + result := make([]forumsv.TaskClassSnapshotItem, 0, len(sorted)) + for _, item := range sorted { + result = append(result, forumsv.TaskClassSnapshotItem{ + TaskItemID: uint64(item.ID), + Order: intValue(item.Order), + Content: stringValue(item.Content), + }) + } + return result +} + +// buildLegacyTaskClassModel 把论坛快照转换成旧 task_classes 主表模型。 +// +// 职责边界: +// 1. 只负责主表字段映射; +// 2. 不负责 items 生成; +// 3. 不负责事务提交,调用方必须交给 DAO.Transaction 执行。 +func buildLegacyTaskClassModel(title string, snapshot forumsv.TaskClassSnapshot, userID int, startDate *time.Time, endDate *time.Time) *legacymodel.TaskClass { + totalSlots := snapshot.TotalSlots + allowFillerCourse := snapshot.AllowFillerCourse + mode := strings.TrimSpace(snapshot.Mode) + strategy := strings.TrimSpace(snapshot.Strategy) + + return &legacymodel.TaskClass{ + UserID: intPtr(userID), + Name: stringPtr(strings.TrimSpace(title)), + Mode: stringPtr(mode), + StartDate: startDate, + EndDate: endDate, + SubjectType: optionalStringPtr(snapshot.SubjectType), + DifficultyLevel: optionalStringPtr(snapshot.DifficultyLevel), + CognitiveIntensity: optionalStringPtr(snapshot.CognitiveIntensity), + TotalSlots: &totalSlots, + AllowFillerCourse: &allowFillerCourse, + Strategy: optionalStringPtr(strategy), + ExcludedSlots: legacymodel.IntSlice(cloneIntSlice(snapshot.ExcludedSlots)), + ExcludedDaysOfWeek: legacymodel.IntSlice(cloneIntSlice(snapshot.ExcludedDaysOfWeek)), + } +} + +// buildLegacyTaskClassItems 把论坛快照条目改写成旧 task_items 待创建模型。 +// +// 职责边界: +// 1. 只构造“新建 item”模型,因此 ID 固定为 0; +// 2. 强制清空 EmbeddedTime,并把状态写成未安排; +// 3. 跳过纯空白内容,避免把无意义条目写回旧表。 +func buildLegacyTaskClassItems(snapshotItems []forumsv.TaskClassSnapshotItem) []legacymodel.TaskClassItem { + if len(snapshotItems) == 0 { + return []legacymodel.TaskClassItem{} + } + + sorted := append([]forumsv.TaskClassSnapshotItem(nil), snapshotItems...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Order != sorted[j].Order { + return sorted[i].Order < sorted[j].Order + } + return sorted[i].TaskItemID < sorted[j].TaskItemID + }) + + result := make([]legacymodel.TaskClassItem, 0, len(sorted)) + for _, item := range sorted { + if strings.TrimSpace(item.Content) == "" { + continue + } + + order := item.Order + content := item.Content + status := legacymodel.TaskItemStatusUnscheduled + + result = append(result, legacymodel.TaskClassItem{ + Order: &order, + Content: &content, + EmbeddedTime: nil, + Status: &status, + }) + } + return result +} + +// parseSnapshotDateRange 解析论坛快照中的日期范围。 +// +// 职责边界: +// 1. 只负责 2006-01-02 格式解析; +// 2. 只在 mode=auto 时执行起止日期必填和先后顺序校验; +// 3. 不负责校验节次、星期等其他业务规则。 +func parseSnapshotDateRange(mode string, startDate string, endDate string) (*time.Time, *time.Time, error) { + parsedStart, err := parseDatePtr(startDate) + if err != nil { + return nil, nil, respond.WrongParamType + } + parsedEnd, err := parseDatePtr(endDate) + if err != nil { + return nil, nil, respond.WrongParamType + } + + if strings.TrimSpace(mode) != "auto" { + return parsedStart, parsedEnd, nil + } + if parsedStart == nil || parsedEnd == nil { + return nil, nil, respond.MissingParamForAutoScheduling + } + if parsedStart.After(*parsedEnd) { + return nil, nil, respond.InvalidDateRange + } + return parsedStart, parsedEnd, nil +} + +// buildConfigSnapshotJSON 生成论坛模板沿用的配置白名单 JSON。 +// +// 职责边界: +// 1. 只序列化配置白名单字段; +// 2. 不写 items、embedded_time、schedule 相关数据; +// 3. 输出键名保持和 taskclassforum 发布链路一致,避免模板口径漂移。 +func buildConfigSnapshotJSON(snapshot forumsv.TaskClassSnapshot) (string, error) { + raw, err := json.Marshal(map[string]any{ + "mode": snapshot.Mode, + "start_date": snapshot.StartDate, + "end_date": snapshot.EndDate, + "subject_type": snapshot.SubjectType, + "difficulty_level": snapshot.DifficultyLevel, + "cognitive_intensity": snapshot.CognitiveIntensity, + "total_slots": snapshot.TotalSlots, + "allow_filler_course": snapshot.AllowFillerCourse, + "strategy": snapshot.Strategy, + "excluded_slots": cloneIntSlice(snapshot.ExcludedSlots), + "excluded_days_of_week": cloneIntSlice(snapshot.ExcludedDaysOfWeek), + "strategy_labels": cloneStringSlice(snapshot.StrategyLabels), + }) + if err != nil { + return "", err + } + return string(raw), nil +} + +func (a *LegacyTaskClassAdapter) ensureReady() error { + if a == nil || a.taskClassDAO == nil { + return errLegacyTaskClassAdapterNotReady + } + return nil +} + +func normalizeLegacyTaskClassLookupError(err error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return respond.UserTaskClassNotFound + } + return err +} + +func parseDatePtr(value string) (*time.Time, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + parsed, err := time.ParseInLocation(legacyTaskClassDateLayout, trimmed, time.Local) + if err != nil { + return nil, err + } + return &parsed, nil +} + +func formatDate(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return value.Format(legacyTaskClassDateLayout) +} + +func toUserID(value uint64) (int, error) { + if value == 0 || value > uint64(maxIntValue()) { + return 0, respond.WrongUserID + } + return int(value), nil +} + +func toTaskClassID(value uint64) (int, error) { + if value == 0 || value > uint64(maxIntValue()) { + return 0, respond.WrongTaskClassID + } + return int(value), nil +} + +func maxIntValue() int { + return int(^uint(0) >> 1) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func boolValue(value *bool) bool { + if value == nil { + return true + } + return *value +} + +func legacyStrategyLabels(strategy string) []string { + trimmed := strings.TrimSpace(strategy) + if trimmed == "" { + return []string{} + } + return []string{trimmed} +} + +func stringPtr(value string) *string { + return &value +} + +func intPtr(value int) *int { + return &value +} + +func optionalStringPtr(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func cloneIntSlice(values []int) []int { + if len(values) == 0 { + return []int{} + } + return append([]int(nil), values...) +} + +func cloneStringSlice(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string(nil), values...) +} diff --git a/backend/services/taskclassforum/commenttree/tree.go b/backend/services/taskclassforum/commenttree/tree.go new file mode 100644 index 0000000..873f98e --- /dev/null +++ b/backend/services/taskclassforum/commenttree/tree.go @@ -0,0 +1,204 @@ +package commenttree + +import ( + "fmt" + "sort" + "time" + + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" +) + +const deletedCommentPlaceholder = "该评论已删除" + +type commentTreeNode struct { + comment forummodel.ForumComment + parent *commentTreeNode + children []*commentTreeNode +} + +// BuildForumCommentTree 将扁平评论组装为多层评论树。 +// +// 职责边界: +// 1. 负责根据 parent_comment_id 组装无限层树结构,并按 CreatedAt 升序稳定排序; +// 2. 负责把软删除评论转换成前端展示文案,同时保留 deleted 状态与 deleted_at; +// 3. 不负责查询数据库、补充真实昵称头像,也不负责帖子级权限校验。 +func BuildForumCommentTree(comments []forummodel.ForumComment, actorUserID uint64) []forumcontracts.ForumCommentNode { + if len(comments) == 0 { + return nil + } + + nodesByID := make(map[uint64]*commentTreeNode, len(comments)) + orderedNodes := make([]*commentTreeNode, 0, len(comments)) + for i := range comments { + node := &commentTreeNode{comment: comments[i]} + nodesByID[comments[i].ID] = node + orderedNodes = append(orderedNodes, node) + } + + roots := attachCommentTreeNodes(orderedNodes, nodesByID) + sortCommentTreeChildren(roots) + + result := make([]forumcontracts.ForumCommentNode, 0, len(roots)) + for i := range roots { + result = append(result, buildForumCommentNode(roots[i], actorUserID)) + } + return result +} + +// attachCommentTreeNodes 按原始 parent_comment_id 把评论挂成树。 +// +// 职责边界: +// 1. 只处理节点挂载关系,不做字段格式化; +// 2. 缺失父节点、自指向、环引用都回退到根层,避免整棵树丢失; +// 3. 根层顺序先保留输入顺序,后续统一由排序函数做稳定排序。 +func attachCommentTreeNodes( + orderedNodes []*commentTreeNode, + nodesByID map[uint64]*commentTreeNode, +) []*commentTreeNode { + roots := make([]*commentTreeNode, 0, len(orderedNodes)) + for i := range orderedNodes { + node := orderedNodes[i] + parentID := node.comment.ParentCommentID + if parentID == nil { + roots = append(roots, node) + continue + } + + parentNode, ok := nodesByID[*parentID] + if !ok || parentNode == nil || parentNode.comment.ID == node.comment.ID { + roots = append(roots, node) + continue + } + + if wouldCreateCommentCycle(nodesByID, node.comment.ID, parentNode) { + roots = append(roots, node) + continue + } + + node.parent = parentNode + parentNode.children = append(parentNode.children, node) + } + return roots +} + +// wouldCreateCommentCycle 判断把 child 挂到 parent 下时是否会形成环。 +// +// 职责边界: +// 1. 只依赖原始 parent_comment_id 链路判断,不依赖当前挂载顺序; +// 2. 一旦发现 child 会回到自己,或父链本身已成环,就返回 true; +// 3. 父链中途断开时按“无环”处理,让节点继续挂到可用分支上。 +func wouldCreateCommentCycle( + nodesByID map[uint64]*commentTreeNode, + childCommentID uint64, + parentNode *commentTreeNode, +) bool { + visited := make(map[uint64]struct{}) + current := parentNode + for current != nil { + if current.comment.ID == childCommentID { + return true + } + if _, seen := visited[current.comment.ID]; seen { + return true + } + visited[current.comment.ID] = struct{}{} + + if current.comment.ParentCommentID == nil { + return false + } + + nextNode, ok := nodesByID[*current.comment.ParentCommentID] + if !ok { + return false + } + current = nextNode + } + return false +} + +// sortCommentTreeChildren 对根层以下的兄弟节点做 CreatedAt 升序稳定排序。 +// +// 职责边界: +// 1. 根层顺序来自服务层根评论分页,必须保留 latest/oldest 的查询语义; +// 2. 子回复统一按 CreatedAt 升序展示,符合常见对话阅读顺序; +// 3. 相同 CreatedAt 依赖稳定排序保留原始输入顺序,避免同秒回复来回跳动。 +func sortCommentTreeChildren(nodes []*commentTreeNode) { + if len(nodes) == 0 { + return + } + + for i := range nodes { + sort.SliceStable(nodes[i].children, func(left, right int) bool { + return nodes[i].children[left].comment.CreatedAt.Before(nodes[i].children[right].comment.CreatedAt) + }) + sortCommentTreeChildren(nodes[i].children) + } +} + +// buildForumCommentNode 把内部树节点转换成对外契约节点。 +// +// 职责边界: +// 1. 负责软删除展示文案、CanDelete、时间格式等输出字段整理; +// 2. 根节点统一输出 nil parent_comment_id;孤儿兜底到根层后也遵循该规则; +// 3. 这里沿用当前服务里的“用户{ID}”占位昵称语义。 +func buildForumCommentNode(node *commentTreeNode, actorUserID uint64) forumcontracts.ForumCommentNode { + children := make([]forumcontracts.ForumCommentNode, 0, len(node.children)) + for i := range node.children { + children = append(children, buildForumCommentNode(node.children[i], actorUserID)) + } + + // 1. 先基于最终挂载结果回填 parent_comment_id,保证孤儿回退到根层后对外语义一致。 + // 2. 再处理软删除评论文案:内容固定替换,但 status 仍保留 deleted,便于前端区分。 + // 3. 最后按“当前用户且评论可见”计算 CanDelete,避免已删除评论被重复展示可删除按钮。 + parentCommentID := actualParentCommentID(node.parent) + content := node.comment.Content + if node.comment.Status == forummodel.ForumCommentStatusDeleted { + content = deletedCommentPlaceholder + } + + return forumcontracts.ForumCommentNode{ + CommentID: node.comment.ID, + PostID: node.comment.PostID, + ParentCommentID: parentCommentID, + Content: content, + Status: node.comment.Status, + Author: buildCommentAuthor(node.comment.UserID), + CanDelete: node.comment.Status == forummodel.ForumCommentStatusVisible && node.comment.UserID == actorUserID, + CreatedAt: formatCommentTime(node.comment.CreatedAt), + DeletedAt: formatCommentTimePtr(node.comment.DeletedAt), + Children: children, + } +} + +func actualParentCommentID(parent *commentTreeNode) *uint64 { + if parent == nil { + return nil + } + parentID := parent.comment.ID + return &parentID +} + +func formatCommentTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339) +} + +func formatCommentTimePtr(value *time.Time) *string { + if value == nil || value.IsZero() { + return nil + } + formatted := value.Format(time.RFC3339) + return &formatted +} + +func buildCommentAuthor(userID uint64) forumcontracts.UserBrief { + // 由于本轮写入范围被限制在 commenttree/tree.go,暂时无法把 UserBrief 生成逻辑下沉成公共能力; + // 这里先与现有服务层保持同一占位昵称语义,避免为了抽公共层去改动 sv/contract 等非授权文件。 + return forumcontracts.UserBrief{ + UserID: userID, + Nickname: fmt.Sprintf("用户%d", userID), + } +} diff --git a/backend/services/taskclassforum/dao/forum.go b/backend/services/taskclassforum/dao/forum.go new file mode 100644 index 0000000..faeabed --- /dev/null +++ b/backend/services/taskclassforum/dao/forum.go @@ -0,0 +1,453 @@ +package dao + +import ( + "context" + "errors" + "strings" + "time" + + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ForumDAO 承载计划广场私有表的持久化访问。 +// +// 职责边界: +// 1. 只访问 forum_* 表,不直接读写旧 task_classes / task_items; +// 2. 只做查询、事务和基础状态更新,不组装前端 DTO; +// 3. 业务规则由 sv 层控制,DAO 仅提供必要的数据原子操作。 +type ForumDAO struct { + db *gorm.DB +} + +func NewForumDAO(db *gorm.DB) *ForumDAO { + return &ForumDAO{db: db} +} + +func (dao *ForumDAO) WithTx(tx *gorm.DB) *ForumDAO { + return &ForumDAO{db: tx} +} + +// Transaction 在一个数据库事务内执行计划广场写操作。 +func (dao *ForumDAO) Transaction(ctx context.Context, fn func(txDAO *ForumDAO) error) error { + return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dao.WithTx(tx)) + }) +} + +type ListPostsQuery struct { + Page int + PageSize int + Sort string + Keyword string + Tag string +} + +// CreatePostSnapshot 在同一事务中写帖子、模板和模板条目。 +func (dao *ForumDAO) CreatePostSnapshot(ctx context.Context, post *forummodel.ForumPost, template *forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) error { + return dao.Transaction(ctx, func(txDAO *ForumDAO) error { + if err := txDAO.db.Create(post).Error; err != nil { + return err + } + template.PostID = post.ID + if err := txDAO.db.Create(template).Error; err != nil { + return err + } + for i := range items { + items[i].PostID = post.ID + items[i].TemplateID = template.ID + } + if len(items) > 0 { + if err := txDAO.db.Create(&items).Error; err != nil { + return err + } + } + return nil + }) +} + +func (dao *ForumDAO) FindPostByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumPost, error) { + var post forummodel.ForumPost + err := dao.db.WithContext(ctx). + Where("author_user_id = ? AND idempotency_key = ?", userID, key). + First(&post).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &post, nil +} + +func (dao *ForumDAO) ListPosts(ctx context.Context, query ListPostsQuery) ([]forummodel.ForumPost, int64, error) { + db := dao.db.WithContext(ctx). + Model(&forummodel.ForumPost{}). + Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished) + if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("title LIKE ? OR summary LIKE ?", like, like) + } + if tag := strings.TrimSpace(query.Tag); tag != "" { + db = db.Where("JSON_CONTAINS(tags_json, JSON_QUOTE(?))", tag) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + orderBy := "created_at DESC" + switch strings.TrimSpace(query.Sort) { + case "likes": + orderBy = "like_count DESC, created_at DESC" + case "imports": + orderBy = "import_count DESC, created_at DESC" + } + + var posts []forummodel.ForumPost + err := db.Order(orderBy). + Offset((query.Page - 1) * query.PageSize). + Limit(query.PageSize). + Find(&posts).Error + if err != nil { + return nil, 0, err + } + return posts, total, nil +} + +func (dao *ForumDAO) ListPublishedTagJSONs(ctx context.Context) ([]string, error) { + var rows []struct { + TagsJSON string + } + err := dao.db.WithContext(ctx). + Model(&forummodel.ForumPost{}). + Select("tags_json"). + Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished). + Find(&rows).Error + if err != nil { + return nil, err + } + result := make([]string, 0, len(rows)) + for _, row := range rows { + result = append(result, row.TagsJSON) + } + return result, nil +} + +func (dao *ForumDAO) FindPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) { + var post forummodel.ForumPost + err := dao.db.WithContext(ctx). + Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished). + First(&post).Error + if err != nil { + return nil, err + } + return &post, nil +} + +func (dao *ForumDAO) LockPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) { + var post forummodel.ForumPost + err := dao.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished). + First(&post).Error + if err != nil { + return nil, err + } + return &post, nil +} + +func (dao *ForumDAO) FindTemplateByPostID(ctx context.Context, postID uint64) (*forummodel.ForumPostTemplate, error) { + var template forummodel.ForumPostTemplate + err := dao.db.WithContext(ctx).Where("post_id = ?", postID).First(&template).Error + if err != nil { + return nil, err + } + return &template, nil +} + +func (dao *ForumDAO) ListTemplateItemsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumPostTemplateItem, error) { + var items []forummodel.ForumPostTemplateItem + err := dao.db.WithContext(ctx). + Where("post_id = ?", postID). + Order("item_order ASC"). + Find(&items).Error + return items, err +} + +func (dao *ForumDAO) FindTemplatesByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]forummodel.ForumPostTemplate, error) { + var templates []forummodel.ForumPostTemplate + err := dao.db.WithContext(ctx).Where("post_id IN ?", postIDs).Find(&templates).Error + if err != nil { + return nil, err + } + result := make(map[uint64]forummodel.ForumPostTemplate, len(templates)) + for _, template := range templates { + result[template.PostID] = template + } + return result, nil +} + +func (dao *ForumDAO) CountTemplateItemsByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]int, error) { + var rows []struct { + PostID uint64 + Count int + } + err := dao.db.WithContext(ctx). + Model(&forummodel.ForumPostTemplateItem{}). + Select("post_id, COUNT(*) AS count"). + Where("post_id IN ?", postIDs). + Group("post_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + result := make(map[uint64]int, len(rows)) + for _, row := range rows { + result[row.PostID] = row.Count + } + return result, nil +} + +func (dao *ForumDAO) LikedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) { + var likes []forummodel.ForumLike + err := dao.db.WithContext(ctx). + Select("post_id"). + Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumLikeStatusActive). + Find(&likes).Error + if err != nil { + return nil, err + } + result := make(map[uint64]bool, len(likes)) + for _, like := range likes { + result[like.PostID] = true + } + return result, nil +} + +func (dao *ForumDAO) ImportedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) { + var imports []forummodel.ForumImport + err := dao.db.WithContext(ctx). + Select("post_id"). + Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumImportStatusImported). + Find(&imports).Error + if err != nil { + return nil, err + } + result := make(map[uint64]bool, len(imports)) + for _, item := range imports { + result[item.PostID] = true + } + return result, nil +} + +func (dao *ForumDAO) FindLike(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumLike, error) { + var like forummodel.ForumLike + err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &like, nil +} + +func (dao *ForumDAO) CreateLike(ctx context.Context, like *forummodel.ForumLike) error { + return dao.db.WithContext(ctx).Create(like).Error +} + +func (dao *ForumDAO) ActivateLike(ctx context.Context, likeID uint64) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumLike{}). + Where("id = ?", likeID). + Updates(map[string]any{ + "status": forummodel.ForumLikeStatusActive, + "canceled_at": nil, + "updated_at": time.Now(), + }).Error +} + +func (dao *ForumDAO) CancelLike(ctx context.Context, likeID uint64, now time.Time) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumLike{}). + Where("id = ?", likeID). + Updates(map[string]any{ + "status": forummodel.ForumLikeStatusCanceled, + "canceled_at": &now, + "updated_at": now, + }).Error +} + +func (dao *ForumDAO) AddPostCounter(ctx context.Context, postID uint64, column string, delta int64) error { + expr := "CASE WHEN " + column + " + ? < 0 THEN 0 ELSE " + column + " + ? END" + return dao.db.WithContext(ctx). + Model(&forummodel.ForumPost{}). + Where("id = ?", postID). + UpdateColumn(column, gorm.Expr(expr, delta, delta)).Error +} + +func (dao *ForumDAO) FindCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) { + var comment forummodel.ForumComment + err := dao.db.WithContext(ctx).Where("id = ?", commentID).First(&comment).Error + if err != nil { + return nil, err + } + return &comment, nil +} + +func (dao *ForumDAO) LockCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) { + var comment forummodel.ForumComment + err := dao.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", commentID). + First(&comment).Error + if err != nil { + return nil, err + } + return &comment, nil +} + +func (dao *ForumDAO) FindCommentByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumComment, error) { + var comment forummodel.ForumComment + err := dao.db.WithContext(ctx). + Where("user_id = ? AND idempotency_key = ?", userID, key). + First(&comment).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &comment, nil +} + +func (dao *ForumDAO) CreateComment(ctx context.Context, comment *forummodel.ForumComment) error { + return dao.db.WithContext(ctx).Create(comment).Error +} + +func (dao *ForumDAO) CountRootComments(ctx context.Context, postID uint64) (int64, error) { + var total int64 + err := dao.db.WithContext(ctx). + Model(&forummodel.ForumComment{}). + Where("post_id = ? AND parent_comment_id IS NULL", postID). + Count(&total).Error + return total, err +} + +func (dao *ForumDAO) ListRootComments(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forummodel.ForumComment, error) { + orderBy := "created_at ASC" + if strings.TrimSpace(sort) == "latest" { + orderBy = "created_at DESC" + } + var comments []forummodel.ForumComment + err := dao.db.WithContext(ctx). + Where("post_id = ? AND parent_comment_id IS NULL", postID). + Order(orderBy). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&comments).Error + return comments, err +} + +func (dao *ForumDAO) ListCommentsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumComment, error) { + var comments []forummodel.ForumComment + err := dao.db.WithContext(ctx). + Where("post_id = ?", postID). + Order("created_at ASC"). + Find(&comments).Error + return comments, err +} + +func (dao *ForumDAO) SoftDeleteComment(ctx context.Context, commentID uint64, now time.Time) error { + tx := dao.db.WithContext(ctx). + Model(&forummodel.ForumComment{}). + Where("id = ? AND status = ?", commentID, forummodel.ForumCommentStatusVisible). + Updates(map[string]any{ + "status": forummodel.ForumCommentStatusDeleted, + "deleted_at": &now, + "updated_at": now, + }) + return tx.Error +} + +func (dao *ForumDAO) FindImport(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumImport, error) { + var item forummodel.ForumImport + err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (dao *ForumDAO) FindImportByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumImport, error) { + var item forummodel.ForumImport + err := dao.db.WithContext(ctx). + Where("user_id = ? AND idempotency_key = ?", userID, key). + First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (dao *ForumDAO) CreateImport(ctx context.Context, item *forummodel.ForumImport) error { + return dao.db.WithContext(ctx).Create(item).Error +} + +func (dao *ForumDAO) UpdateImportProcessing(ctx context.Context, importID uint64, title string, now time.Time) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumImport{}). + Where("id = ?", importID). + Updates(map[string]any{ + "target_title": title, + "status": forummodel.ForumImportStatusPending, + "last_error": nil, + "updated_at": now, + }).Error +} + +func (dao *ForumDAO) FinalizeImport(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, now time.Time) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumImport{}). + Where("id = ?", importID). + Updates(map[string]any{ + "new_task_class_id": &newTaskClassID, + "target_title": targetTitle, + "status": forummodel.ForumImportStatusImported, + "last_error": nil, + "updated_at": now, + }).Error +} + +func (dao *ForumDAO) MarkImportFailed(ctx context.Context, importID uint64, message string, now time.Time) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumImport{}). + Where("id = ?", importID). + Updates(map[string]any{ + "status": forummodel.ForumImportStatusFailed, + "last_error": &message, + "updated_at": now, + }).Error +} + +func (dao *ForumDAO) MarkImportFailedAfterTaskClassCreated(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, message string, now time.Time) error { + return dao.db.WithContext(ctx). + Model(&forummodel.ForumImport{}). + Where("id = ?", importID). + Updates(map[string]any{ + "new_task_class_id": &newTaskClassID, + "target_title": targetTitle, + "status": forummodel.ForumImportStatusFailed, + "last_error": &message, + "updated_at": now, + }).Error +} diff --git a/backend/services/taskclassforum/model/forum.go b/backend/services/taskclassforum/model/forum.go index f179a29..3c357bd 100644 --- a/backend/services/taskclassforum/model/forum.go +++ b/backend/services/taskclassforum/model/forum.go @@ -28,8 +28,12 @@ const ( ) const ( + // ForumImportStatusPending 表示导入记录已占位,正在创建 TaskClass 副本。 + ForumImportStatusPending = "pending" // ForumImportStatusImported 表示导入已成功创建当前用户自己的 TaskClass 副本。 ForumImportStatusImported = "imported" + // ForumImportStatusFailed 表示导入副本创建或最终确认失败,可由后续重试覆盖。 + ForumImportStatusFailed = "failed" ) // ForumPost 是计划广场帖子主体表。 @@ -40,11 +44,12 @@ const ( // 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"` + AuthorUserID uint64 `gorm:"column:author_user_id;not null;index:idx_forum_posts_author_status,priority:1;uniqueIndex:uk_forum_posts_author_idem,priority:1;comment:作者用户ID"` SourceTaskClassID uint64 `gorm:"column:source_task_class_id;not null;index:idx_forum_posts_source_task_class;comment:发布时选择的原始TaskClass ID,仅用于审计"` Title string `gorm:"column:title;type:varchar(80);not null;comment:帖子标题"` Summary string `gorm:"column:summary;type:text;comment:帖子简介"` TagsJSON string `gorm:"column:tags_json;type:json;not null;comment:标签JSON数组"` + IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_posts_author_idem,priority:2;comment:发布请求幂等键"` Status string `gorm:"column:status;type:varchar(32);not null;default:'published';index:idx_forum_posts_status_created,priority:1;index:idx_forum_posts_author_status,priority:2;comment:published/hidden/deleted/pending_review"` LikeCount int64 `gorm:"column:like_count;not null;default:0;index:idx_forum_posts_like_count;comment:点赞数冗余计数"` CommentCount int64 `gorm:"column:comment_count;not null;default:0;comment:评论数冗余计数"` @@ -166,11 +171,12 @@ type ForumImport struct { 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"` + NewTaskClassID *uint64 `gorm:"column:new_task_class_id;comment:导入后创建的当前用户TaskClass ID,pending/failed 时为空"` TargetTitle string `gorm:"column:target_title;type:varchar(80);comment:导入后的TaskClass标题"` - Status string `gorm:"column:status;type:varchar(32);not null;default:'imported';comment:imported"` + Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';comment:pending/imported/failed"` EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_forum_imports_event;comment:导入事件ID"` IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_forum_imports_user_idem,priority:2;comment:导入请求幂等键"` + LastError *string `gorm:"column:last_error;type:text;comment:最近一次导入失败原因"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"` } diff --git a/backend/services/taskclassforum/rpc/errors.go b/backend/services/taskclassforum/rpc/errors.go index 2305fe1..175f8d4 100644 --- a/backend/services/taskclassforum/rpc/errors.go +++ b/backend/services/taskclassforum/rpc/errors.go @@ -6,7 +6,6 @@ import ( "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" @@ -29,9 +28,6 @@ func grpcErrorFromServiceError(err error) error { 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") } diff --git a/backend/services/taskclassforum/sv/comment.go b/backend/services/taskclassforum/sv/comment.go new file mode 100644 index 0000000..6f1333b --- /dev/null +++ b/backend/services/taskclassforum/sv/comment.go @@ -0,0 +1,202 @@ +package sv + +import ( + "context" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/LoveLosita/smartflow/backend/services/taskclassforum/commenttree" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" +) + +// ListComments 查询评论树。 +// +// 职责边界: +// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端; +// 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装; +// 3. 不做评论缓存,新增、回复、删除后直接读库保持语义简单。 +func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) { + if err := s.Ready(); err != nil { + return nil, forumcontracts.PageResult{}, err + } + if postID == 0 { + return nil, forumcontracts.PageResult{}, respond.MissingParam + } + page, pageSize = normalizePage(page, pageSize) + if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil { + return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + + total, err := s.forumDAO.CountRootComments(ctx, postID) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + roots, err := s.forumDAO.ListRootComments(ctx, postID, page, pageSize, sortBy) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + if len(roots) == 0 { + return []forumcontracts.ForumCommentNode{}, pageResult(page, pageSize, total), nil + } + allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID) + return nodes, pageResult(page, pageSize, total), nil +} + +// CreateComment 创建帖子评论或多层回复。 +func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateForumCommentRequest) (*forumcontracts.ForumCommentNode, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if req.ActorUserID == 0 || req.PostID == 0 || strings.TrimSpace(req.Content) == "" { + return nil, respond.MissingParam + } + if err := validateRuneMax(req.Content, maxCommentLen); err != nil { + return nil, err + } + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey != "" { + existing, err := s.forumDAO.FindCommentByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey) + if err != nil { + return nil, err + } + if existing != nil { + return commentModelToNode(*existing, req.ActorUserID), nil + } + } + + var created forummodel.ForumComment + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + if req.ParentCommentID != nil { + parent, err := txDAO.FindCommentByID(ctx, *req.ParentCommentID) + if err != nil { + return normalizeRecordNotFound(err, respond.MissingParam) + } + if parent.PostID != req.PostID { + return respond.MissingParam + } + } + created = forummodel.ForumComment{ + PostID: req.PostID, + ParentCommentID: req.ParentCommentID, + UserID: req.ActorUserID, + Content: strings.TrimSpace(req.Content), + Status: forummodel.ForumCommentStatusVisible, + IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey), + } + if err := txDAO.CreateComment(ctx, &created); err != nil { + return err + } + return txDAO.AddPostCounter(ctx, req.PostID, "comment_count", 1) + }); err != nil { + return nil, err + } + return commentModelToNode(created, req.ActorUserID), nil +} + +// DeleteComment 软删除当前用户自己的评论。 +func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, commentID uint64) (*forumcontracts.DeleteForumCommentResult, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if actorUserID == 0 || commentID == 0 { + return nil, respond.MissingParam + } + + var deletedAt *string + status := forummodel.ForumCommentStatusDeleted + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + comment, err := txDAO.LockCommentByID(ctx, commentID) + if err != nil { + return normalizeRecordNotFound(err, respond.MissingParam) + } + if comment.UserID != actorUserID { + return respond.ErrUnauthorized + } + if comment.Status == forummodel.ForumCommentStatusDeleted { + deletedAt = formatTimePtr(comment.DeletedAt) + return nil + } + now := time.Now() + if err := txDAO.SoftDeleteComment(ctx, commentID, now); err != nil { + return err + } + if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil { + return err + } + deletedAt = formatTimePtr(&now) + return nil + }); err != nil { + return nil, err + } + return &forumcontracts.DeleteForumCommentResult{ + CommentID: commentID, + Status: status, + Content: "", + DeletedAt: deletedAt, + }, nil +} + +func commentModelToNode(comment forummodel.ForumComment, actorUserID uint64) *forumcontracts.ForumCommentNode { + content := comment.Content + if comment.Status == forummodel.ForumCommentStatusDeleted { + content = "该评论已删除" + } + return &forumcontracts.ForumCommentNode{ + CommentID: comment.ID, + PostID: comment.PostID, + ParentCommentID: comment.ParentCommentID, + Content: content, + Status: comment.Status, + Author: userBrief(comment.UserID), + CanDelete: comment.Status == forummodel.ForumCommentStatusVisible && comment.UserID == actorUserID, + CreatedAt: formatTime(comment.CreatedAt), + DeletedAt: formatTimePtr(comment.DeletedAt), + Children: []forumcontracts.ForumCommentNode{}, + } +} + +func filterCommentsForRoots(allComments []forummodel.ForumComment, roots []forummodel.ForumComment) []forummodel.ForumComment { + filtered := make([]forummodel.ForumComment, 0, len(allComments)) + included := make(map[uint64]struct{}, len(allComments)) + for _, root := range roots { + filtered = append(filtered, root) + included[root.ID] = struct{}{} + } + candidateSet := make(map[uint64]struct{}, len(allComments)) + for _, root := range roots { + collectDescendantCommentIDs(root.ID, allComments, candidateSet) + } + for _, comment := range allComments { + if _, ok := included[comment.ID]; ok { + continue + } + if _, ok := candidateSet[comment.ID]; ok { + filtered = append(filtered, comment) + included[comment.ID] = struct{}{} + } + } + return filtered +} + +func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumComment, result map[uint64]struct{}) { + for _, comment := range comments { + if comment.ParentCommentID == nil || *comment.ParentCommentID != parentID { + continue + } + if _, exists := result[comment.ID]; exists { + continue + } + result[comment.ID] = struct{}{} + collectDescendantCommentIDs(comment.ID, comments, result) + } +} diff --git a/backend/services/taskclassforum/sv/errors.go b/backend/services/taskclassforum/sv/errors.go new file mode 100644 index 0000000..c5e4ec9 --- /dev/null +++ b/backend/services/taskclassforum/sv/errors.go @@ -0,0 +1,8 @@ +package sv + +import "errors" + +var ( + // ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass,但 adapter 尚未注入。 + ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil") +) diff --git a/backend/services/taskclassforum/sv/helpers.go b/backend/services/taskclassforum/sv/helpers.go new file mode 100644 index 0000000..f08a4e7 --- /dev/null +++ b/backend/services/taskclassforum/sv/helpers.go @@ -0,0 +1,294 @@ +package sv + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" +) + +const ( + defaultPage = 1 + defaultPageSize = 20 + maxPageSize = 50 + maxPostTitleLen = 40 + maxSummaryLen = 300 + maxTagCount = 5 + maxTagLength = 12 + maxCommentLen = 500 + maxImportTitle = 80 +) + +func normalizePage(page int, pageSize int) (int, int) { + if page <= 0 { + page = defaultPage + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func pageResult(page int, pageSize int, total int64) forumcontracts.PageResult { + return forumcontracts.PageResult{ + Page: page, + PageSize: pageSize, + Total: int(total), + HasMore: int64(page*pageSize) < total, + } +} + +func normalizeTags(tags []string) ([]string, error) { + result := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, raw := range tags { + tag := strings.TrimSpace(raw) + if tag == "" { + continue + } + if len([]rune(tag)) > maxTagLength { + return nil, respond.ParamTooLong + } + if _, exists := seen[tag]; exists { + continue + } + seen[tag] = struct{}{} + result = append(result, tag) + if len(result) > maxTagCount { + return nil, respond.ParamTooLong + } + } + return result, nil +} + +func validateRuneMax(value string, maxLen int) error { + if len([]rune(strings.TrimSpace(value))) > maxLen { + return respond.ParamTooLong + } + return nil +} + +func tagsToJSON(tags []string) (string, error) { + if tags == nil { + tags = []string{} + } + raw, err := json.Marshal(tags) + if err != nil { + return "", err + } + return string(raw), nil +} + +func tagsFromJSON(raw string) []string { + var tags []string + if err := json.Unmarshal([]byte(raw), &tags); err != nil { + return []string{} + } + return tags +} + +func intSliceToJSONPtr(values []int) (*string, error) { + if values == nil { + values = []int{} + } + raw, err := json.Marshal(values) + if err != nil { + return nil, err + } + result := string(raw) + return &result, nil +} + +func stringSliceToJSONPtr(values []string) (*string, error) { + if values == nil { + values = []string{} + } + raw, err := json.Marshal(values) + if err != nil { + return nil, err + } + result := string(raw) + return &result, nil +} + +func intSliceFromJSONPtr(raw *string) []int { + if raw == nil || strings.TrimSpace(*raw) == "" { + return []int{} + } + var values []int + if err := json.Unmarshal([]byte(*raw), &values); err != nil { + return []int{} + } + return values +} + +func stringSliceFromJSONPtr(raw *string) []string { + if raw == nil || strings.TrimSpace(*raw) == "" { + return []string{} + } + var values []string + if err := json.Unmarshal([]byte(*raw), &values); err != nil { + return []string{} + } + return values +} + +func parseSnapshotDate(value string) *time.Time { + if strings.TrimSpace(value) == "" { + return nil + } + parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local) + if err != nil { + return nil + } + return &parsed +} + +func formatDate(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return value.Format("2006-01-02") +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339) +} + +func formatTimePtr(value *time.Time) *string { + if value == nil || value.IsZero() { + return nil + } + formatted := value.Format(time.RFC3339) + return &formatted +} + +func userBrief(userID uint64) forumcontracts.UserBrief { + return forumcontracts.UserBrief{ + UserID: userID, + Nickname: fmt.Sprintf("用户%d", userID), + } +} + +func countersFromPost(post forummodel.ForumPost) forumcontracts.ForumPostCounters { + return forumcontracts.ForumPostCounters{ + LikeCount: post.LikeCount, + CommentCount: post.CommentCount, + ImportCount: post.ImportCount, + } +} + +func viewerState(postID uint64, liked map[uint64]bool, imported map[uint64]bool) forumcontracts.ForumPostViewerState { + return forumcontracts.ForumPostViewerState{ + Liked: liked[postID], + ImportedOnce: imported[postID], + } +} + +func templateSummaryFromTemplate(template *forummodel.ForumPostTemplate, itemCount int) forumcontracts.TemplateSummary { + if template == nil { + return forumcontracts.TemplateSummary{} + } + return forumcontracts.TemplateSummary{ + TaskCount: itemCount, + Mode: template.Mode, + StartDate: formatDate(template.StartDate), + EndDate: formatDate(template.EndDate), + StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON), + } +} + +func postBriefFromModel(post forummodel.ForumPost, template *forummodel.ForumPostTemplate, itemCount int, state forumcontracts.ForumPostViewerState) forumcontracts.ForumPostBrief { + return forumcontracts.ForumPostBrief{ + PostID: post.ID, + Title: post.Title, + Summary: post.Summary, + Tags: tagsFromJSON(post.TagsJSON), + Author: userBrief(post.AuthorUserID), + TemplateSummary: templateSummaryFromTemplate(template, itemCount), + Counters: countersFromPost(post), + ViewerState: state, + Status: post.Status, + CreatedAt: formatTime(post.CreatedAt), + } +} + +func templateDetailFromModel(template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) forumcontracts.TemplateDetail { + sort.SliceStable(items, func(i, j int) bool { + return items[i].Order < items[j].Order + }) + preview := make([]forumcontracts.TemplateItemPreview, 0, len(items)) + for _, item := range items { + preview = append(preview, forumcontracts.TemplateItemPreview{ + ItemID: item.ID, + Order: item.Order, + Content: item.Content, + }) + } + return forumcontracts.TemplateDetail{ + Mode: template.Mode, + StartDate: formatDate(template.StartDate), + EndDate: formatDate(template.EndDate), + StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON), + TaskCount: len(items), + ItemsPreview: preview, + } +} + +func snapshotFromTemplate(post forummodel.ForumPost, template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) TaskClassSnapshot { + sort.SliceStable(items, func(i, j int) bool { + return items[i].Order < items[j].Order + }) + snapshotItems := make([]TaskClassSnapshotItem, 0, len(items)) + for _, item := range items { + snapshotItems = append(snapshotItems, TaskClassSnapshotItem{ + TaskItemID: item.SourceTaskItemID, + Order: item.Order, + Content: item.Content, + }) + } + return TaskClassSnapshot{ + TaskClassID: template.SourceTaskClassID, + Title: post.Title, + Mode: template.Mode, + StartDate: formatDate(template.StartDate), + EndDate: formatDate(template.EndDate), + SubjectType: template.SubjectType, + DifficultyLevel: template.DifficultyLevel, + CognitiveIntensity: template.CognitiveIntensity, + TotalSlots: template.TotalSlots, + AllowFillerCourse: template.AllowFillerCourse, + Strategy: template.Strategy, + ExcludedSlots: intSliceFromJSONPtr(template.ExcludedSlotsJSON), + ExcludedDaysOfWeek: intSliceFromJSONPtr(template.ExcludedDaysOfWeekJSON), + StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON), + Items: snapshotItems, + ConfigSnapshotJSON: stringFromPtr(template.ConfigSnapshotJSON), + } +} + +func stringFromPtr(value *string) string { + if value == nil { + return "" + } + return *value +} + +func stringPtrFromNonEmpty(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/backend/services/taskclassforum/sv/import.go b/backend/services/taskclassforum/sv/import.go new file mode 100644 index 0000000..6172a9e --- /dev/null +++ b/backend/services/taskclassforum/sv/import.go @@ -0,0 +1,236 @@ +package sv + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" +) + +// ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。 +// +// 职责边界: +// 1. 同一用户同一帖子只允许导入一次,由 forum_imports 唯一约束兜底; +// 2. 只通过 TaskClassSnapshotPort 创建 TaskClass,不写 schedule; +// 3. 只写 forum_imports 和 import_count,Token 奖励后续基于 event_id 消费。 +func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForumPostRequest) (*forumcontracts.ImportForumPostResult, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if req.ActorUserID == 0 || req.PostID == 0 { + return nil, respond.MissingParam + } + if strings.TrimSpace(req.TargetTitle) != "" { + if err := validateRuneMax(req.TargetTitle, maxImportTitle); err != nil { + return nil, err + } + } + if s.taskClassPort == nil { + return nil, ErrTaskClassPortMissing + } + + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey != "" { + existing, err := s.forumDAO.FindImportByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey) + if err != nil { + return nil, err + } + if existing != nil && existing.Status == forummodel.ForumImportStatusImported { + return importResultFromModel(*existing), nil + } + } + existing, err := s.forumDAO.FindImport(ctx, req.PostID, req.ActorUserID) + if err != nil { + return nil, err + } + if existing != nil && existing.Status == forummodel.ForumImportStatusImported { + return importResultFromModel(*existing), nil + } + if existing != nil && existing.Status == forummodel.ForumImportStatusFailed && existing.NewTaskClassID != nil { + return s.recoverCreatedImport(ctx, req, *existing) + } + if existing != nil && existing.Status == forummodel.ForumImportStatusPending { + return nil, respond.RequestIsProcessing + } + + post, template, items, err := s.loadPostTemplate(ctx, req.PostID) + if err != nil { + return nil, err + } + snapshot := snapshotFromTemplate(*post, *template, items) + targetTitle := strings.TrimSpace(req.TargetTitle) + if targetTitle == "" { + targetTitle = post.Title + } + + pending, err := s.reserveImport(ctx, req, post.AuthorUserID, targetTitle, idempotencyKey) + if err != nil { + return nil, err + } + if pending.Status == forummodel.ForumImportStatusImported { + result := importResultFromModel(*pending) + result.ImportCount = post.ImportCount + return result, nil + } + + created, err := s.taskClassPort.CreateTaskClassFromSnapshot(ctx, req.ActorUserID, snapshot, targetTitle) + if err != nil { + _ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Error(), time.Now()) + return nil, err + } + if created == nil { + err := respond.InternalError(fmt.Errorf("taskclass adapter returned nil created taskclass")) + _ = s.forumDAO.MarkImportFailed(ctx, pending.ID, err.Info, time.Now()) + return nil, err + } + + var imported forummodel.ForumImport + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID) + if err != nil { + return err + } + if again == nil || again.ID != pending.ID { + return respond.RequestIsProcessing + } + if again.Status == forummodel.ForumImportStatusImported { + imported = *again + return nil + } + if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, time.Now()); err != nil { + return err + } + imported = *again + imported.NewTaskClassID = &created.TaskClassID + imported.TargetTitle = created.Title + imported.Status = forummodel.ForumImportStatusImported + if again.Status != forummodel.ForumImportStatusImported { + return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1) + } + return nil + }); err != nil { + _ = s.forumDAO.MarkImportFailedAfterTaskClassCreated(ctx, pending.ID, created.TaskClassID, created.Title, err.Error(), time.Now()) + return nil, err + } + result := importResultFromModel(imported) + if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil { + result.ImportCount = postAfter.ImportCount + } + return result, nil +} + +func (s *Service) reserveImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, authorUserID uint64, targetTitle string, idempotencyKey string) (*forummodel.ForumImport, error) { + var reserved *forummodel.ForumImport + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + existing, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID) + if err != nil { + return err + } + if existing != nil { + switch existing.Status { + case forummodel.ForumImportStatusImported: + reserved = existing + return nil + case forummodel.ForumImportStatusPending: + return respond.RequestIsProcessing + case forummodel.ForumImportStatusFailed: + if existing.NewTaskClassID != nil { + reserved = existing + return nil + } + if err := txDAO.UpdateImportProcessing(ctx, existing.ID, targetTitle, time.Now()); err != nil { + return err + } + existing.Status = forummodel.ForumImportStatusPending + existing.TargetTitle = targetTitle + reserved = existing + return nil + } + } + item := &forummodel.ForumImport{ + PostID: req.PostID, + UserID: req.ActorUserID, + AuthorUserID: authorUserID, + TargetTitle: targetTitle, + Status: forummodel.ForumImportStatusPending, + EventID: forumImportEventID(req.PostID, req.ActorUserID), + IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey), + } + if err := txDAO.CreateImport(ctx, item); err != nil { + return err + } + reserved = item + return nil + }); err != nil { + return nil, err + } + return reserved, nil +} + +func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.ImportForumPostRequest, existing forummodel.ForumImport) (*forumcontracts.ImportForumPostResult, error) { + if existing.NewTaskClassID == nil { + return nil, respond.RequestIsProcessing + } + imported := existing + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + again, err := txDAO.FindImport(ctx, req.PostID, req.ActorUserID) + if err != nil { + return err + } + if again == nil || again.ID != existing.ID { + return respond.RequestIsProcessing + } + if again.Status == forummodel.ForumImportStatusImported { + imported = *again + return nil + } + if again.Status != forummodel.ForumImportStatusFailed || again.NewTaskClassID == nil { + return respond.RequestIsProcessing + } + if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, time.Now()); err != nil { + return err + } + imported = *again + imported.Status = forummodel.ForumImportStatusImported + return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1) + }); err != nil { + return nil, err + } + result := importResultFromModel(imported) + if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil { + result.ImportCount = postAfter.ImportCount + } + return result, nil +} + +func importResultFromModel(item forummodel.ForumImport) *forumcontracts.ImportForumPostResult { + var newTaskClassID uint64 + if item.NewTaskClassID != nil { + newTaskClassID = *item.NewTaskClassID + } + return &forumcontracts.ImportForumPostResult{ + ImportID: item.ID, + PostID: item.PostID, + NewTaskClassID: newTaskClassID, + TaskClassTitle: item.TargetTitle, + CreatedAt: formatTime(item.CreatedAt), + } +} + +func forumImportEventID(postID uint64, userID uint64) string { + return fmt.Sprintf("forum.post.imported:%d:%d", postID, userID) +} diff --git a/backend/services/taskclassforum/sv/like.go b/backend/services/taskclassforum/sv/like.go new file mode 100644 index 0000000..26b1212 --- /dev/null +++ b/backend/services/taskclassforum/sv/like.go @@ -0,0 +1,111 @@ +package sv + +import ( + "context" + "fmt" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" +) + +// LikePost 点赞计划帖子。 +// +// 职责边界: +// 1. 负责保证同一用户同一帖子只有一个 active 点赞状态; +// 2. 负责维护帖子 like_count 计数字段; +// 3. 不直接发放 Token,只写稳定 event_id,后续奖励链路可基于该 ID 幂等消费。 +func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { + if err := s.Ready(); err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err + } + if actorUserID == 0 || postID == 0 { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam + } + + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + post, err := txDAO.LockPublishedPost(ctx, postID) + if err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + like, err := txDAO.FindLike(ctx, postID, actorUserID) + if err != nil { + return err + } + if like == nil { + return createActiveLike(ctx, txDAO, post, actorUserID) + } + if like.Status == forummodel.ForumLikeStatusActive { + return nil + } + if err := txDAO.ActivateLike(ctx, like.ID); err != nil { + return err + } + return txDAO.AddPostCounter(ctx, postID, "like_count", 1) + }); err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err + } + return s.postInteractionState(ctx, actorUserID, postID) +} + +// UnlikePost 取消计划帖子点赞。 +func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { + if err := s.Ready(); err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err + } + if actorUserID == 0 || postID == 0 { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam + } + + if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { + if _, err := txDAO.LockPublishedPost(ctx, postID); err != nil { + return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + like, err := txDAO.FindLike(ctx, postID, actorUserID) + if err != nil { + return err + } + if like == nil || like.Status != forummodel.ForumLikeStatusActive { + return nil + } + if err := txDAO.CancelLike(ctx, like.ID, time.Now()); err != nil { + return err + } + return txDAO.AddPostCounter(ctx, postID, "like_count", -1) + }); err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err + } + return s.postInteractionState(ctx, actorUserID, postID) +} + +func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) error { + like := &forummodel.ForumLike{ + PostID: post.ID, + UserID: actorUserID, + AuthorUserID: post.AuthorUserID, + Status: forummodel.ForumLikeStatusActive, + EventID: forumLikeEventID(post.ID, actorUserID), + } + if err := txDAO.CreateLike(ctx, like); err != nil { + return err + } + return txDAO.AddPostCounter(ctx, post.ID, "like_count", 1) +} + +func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { + post, err := s.forumDAO.FindPublishedPost(ctx, postID) + if err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID}) + if err != nil { + return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err + } + return countersFromPost(*post), viewerState(postID, liked, imported), nil +} + +func forumLikeEventID(postID uint64, userID uint64) string { + return fmt.Sprintf("forum.post.liked:%d:%d", postID, userID) +} diff --git a/backend/services/taskclassforum/sv/post.go b/backend/services/taskclassforum/sv/post.go new file mode 100644 index 0000000..3a22fe0 --- /dev/null +++ b/backend/services/taskclassforum/sv/post.go @@ -0,0 +1,339 @@ +package sv + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "gorm.io/gorm" +) + +// ListPosts 查询计划广场帖子列表。 +// +// 职责边界: +// 1. 负责分页、排序、关键词和标签筛选的业务口径; +// 2. 负责补齐模板摘要、当前用户点赞/导入状态; +// 3. 不读取原作者当前 TaskClass,列表只基于论坛快照表。 +func (s *Service) ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sortBy string, keyword string, tag string) ([]forumcontracts.ForumPostBrief, forumcontracts.PageResult, error) { + if err := s.Ready(); err != nil { + return nil, forumcontracts.PageResult{}, err + } + page, pageSize = normalizePage(page, pageSize) + + posts, total, err := s.forumDAO.ListPosts(ctx, forumdao.ListPostsQuery{ + Page: page, + PageSize: pageSize, + Sort: sortBy, + Keyword: keyword, + Tag: tag, + }) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + if len(posts) == 0 { + return []forumcontracts.ForumPostBrief{}, pageResult(page, pageSize, total), nil + } + + postIDs := collectPostIDs(posts) + templates, err := s.forumDAO.FindTemplatesByPostIDs(ctx, postIDs) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + itemCounts, err := s.forumDAO.CountTemplateItemsByPostIDs(ctx, postIDs) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + liked, imported, err := s.viewerStateSets(ctx, actorUserID, postIDs) + if err != nil { + return nil, forumcontracts.PageResult{}, err + } + + result := make([]forumcontracts.ForumPostBrief, 0, len(posts)) + for _, post := range posts { + template, ok := templates[post.ID] + var templatePtr *forummodel.ForumPostTemplate + if ok { + templateCopy := template + templatePtr = &templateCopy + } + result = append(result, postBriefFromModel(post, templatePtr, itemCounts[post.ID], viewerState(post.ID, liked, imported))) + } + return result, pageResult(page, pageSize, total), nil +} + +// ListTags 聚合计划广场已发布帖子的标签。 +func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ([]forumcontracts.ForumTagItem, error) { + _ = actorUserID + if err := s.Ready(); err != nil { + return nil, err + } + if limit <= 0 || limit > 50 { + limit = 20 + } + + rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx) + if err != nil { + return nil, err + } + counter := make(map[string]int) + for _, raw := range rawTags { + for _, tag := range tagsFromJSON(raw) { + if strings.TrimSpace(tag) == "" { + continue + } + counter[tag]++ + } + } + + items := make([]forumcontracts.ForumTagItem, 0, len(counter)) + for tag, count := range counter { + items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count}) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].PostCount == items[j].PostCount { + return items[i].Tag < items[j].Tag + } + return items[i].PostCount > items[j].PostCount + }) + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +// CreatePost 发布计划,并把旧 TaskClass 复制为论坛快照。 +// +// 职责边界: +// 1. 通过 TaskClassSnapshotPort 获取当前用户自己的 TaskClass 快照; +// 2. 在论坛私有表写帖子、模板和模板条目; +// 3. 不修改旧 TaskClass,也不写 schedule。 +func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForumPostRequest) (*forumcontracts.ForumPostBrief, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if req.ActorUserID == 0 || req.TaskClassID == 0 || strings.TrimSpace(req.Title) == "" { + return nil, respond.MissingParam + } + if err := validateRuneMax(req.Title, maxPostTitleLen); err != nil { + return nil, err + } + if err := validateRuneMax(req.Summary, maxSummaryLen); err != nil { + return nil, err + } + if s.taskClassPort == nil { + return nil, ErrTaskClassPortMissing + } + + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey != "" { + existing, err := s.forumDAO.FindPostByIdempotencyKey(ctx, req.ActorUserID, idempotencyKey) + if err != nil { + return nil, err + } + if existing != nil { + return s.postBriefByID(ctx, req.ActorUserID, existing.ID) + } + } + + tags, err := normalizeTags(req.Tags) + if err != nil { + return nil, err + } + tagsJSON, err := tagsToJSON(tags) + if err != nil { + return nil, err + } + snapshot, err := s.taskClassPort.GetOwnedTaskClassSnapshot(ctx, req.ActorUserID, req.TaskClassID) + if err != nil { + return nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + if snapshot == nil { + return nil, respond.UserTaskClassNotFound + } + + post, template, items, err := buildPostSnapshotModels(req, idempotencyKey, tagsJSON, *snapshot) + if err != nil { + return nil, err + } + if err := s.forumDAO.CreatePostSnapshot(ctx, &post, &template, items); err != nil { + return nil, err + } + return s.postBriefByID(ctx, req.ActorUserID, post.ID) +} + +// GetPost 查询帖子详情和模板快照。 +func (s *Service) GetPost(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostDetail, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if postID == 0 { + return nil, respond.MissingParam + } + + post, template, items, err := s.loadPostTemplate(ctx, postID) + if err != nil { + return nil, err + } + liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID}) + if err != nil { + return nil, err + } + state := viewerState(postID, liked, imported) + return &forumcontracts.ForumPostDetail{ + Post: postBriefFromModel(*post, template, len(items), state), + Template: templateDetailFromModel(*template, items), + }, nil +} + +func (s *Service) postBriefByID(ctx context.Context, actorUserID uint64, postID uint64) (*forumcontracts.ForumPostBrief, error) { + post, template, items, err := s.loadPostTemplate(ctx, postID) + if err != nil { + return nil, err + } + liked, imported, err := s.viewerStateSets(ctx, actorUserID, []uint64{postID}) + if err != nil { + return nil, err + } + brief := postBriefFromModel(*post, template, len(items), viewerState(postID, liked, imported)) + return &brief, nil +} + +func (s *Service) loadPostTemplate(ctx context.Context, postID uint64) (*forummodel.ForumPost, *forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) { + post, err := s.forumDAO.FindPublishedPost(ctx, postID) + if err != nil { + return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + template, err := s.forumDAO.FindTemplateByPostID(ctx, postID) + if err != nil { + return nil, nil, nil, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) + } + items, err := s.forumDAO.ListTemplateItemsByPostID(ctx, postID) + if err != nil { + return nil, nil, nil, err + } + return post, template, items, nil +} + +func (s *Service) viewerStateSets(ctx context.Context, actorUserID uint64, postIDs []uint64) (map[uint64]bool, map[uint64]bool, error) { + if actorUserID == 0 || len(postIDs) == 0 { + return map[uint64]bool{}, map[uint64]bool{}, nil + } + liked, err := s.forumDAO.LikedPostIDSet(ctx, actorUserID, postIDs) + if err != nil { + return nil, nil, err + } + imported, err := s.forumDAO.ImportedPostIDSet(ctx, actorUserID, postIDs) + if err != nil { + return nil, nil, err + } + return liked, imported, nil +} + +func collectPostIDs(posts []forummodel.ForumPost) []uint64 { + result := make([]uint64, 0, len(posts)) + for _, post := range posts { + result = append(result, post.ID) + } + return result +} + +func buildPostSnapshotModels(req forumcontracts.CreateForumPostRequest, idempotencyKey string, tagsJSON string, snapshot TaskClassSnapshot) (forummodel.ForumPost, forummodel.ForumPostTemplate, []forummodel.ForumPostTemplateItem, error) { + configJSON, err := configSnapshotJSON(snapshot) + if err != nil { + return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err + } + excludedSlotsJSON, err := intSliceToJSONPtr(snapshot.ExcludedSlots) + if err != nil { + return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err + } + excludedDaysJSON, err := intSliceToJSONPtr(snapshot.ExcludedDaysOfWeek) + if err != nil { + return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err + } + labelsJSON, err := stringSliceToJSONPtr(snapshot.StrategyLabels) + if err != nil { + return forummodel.ForumPost{}, forummodel.ForumPostTemplate{}, nil, err + } + + post := forummodel.ForumPost{ + AuthorUserID: req.ActorUserID, + SourceTaskClassID: req.TaskClassID, + Title: strings.TrimSpace(req.Title), + Summary: strings.TrimSpace(req.Summary), + TagsJSON: tagsJSON, + IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey), + Status: forummodel.ForumPostStatusPublished, + } + template := forummodel.ForumPostTemplate{ + SourceTaskClassID: snapshot.TaskClassID, + Mode: snapshot.Mode, + StartDate: parseSnapshotDate(snapshot.StartDate), + EndDate: parseSnapshotDate(snapshot.EndDate), + SubjectType: snapshot.SubjectType, + DifficultyLevel: snapshot.DifficultyLevel, + CognitiveIntensity: snapshot.CognitiveIntensity, + TotalSlots: snapshot.TotalSlots, + AllowFillerCourse: snapshot.AllowFillerCourse, + Strategy: snapshot.Strategy, + ExcludedSlotsJSON: excludedSlotsJSON, + ExcludedDaysOfWeekJSON: excludedDaysJSON, + StrategyLabelsJSON: labelsJSON, + ConfigSnapshotJSON: &configJSON, + } + snapshotItems := append([]TaskClassSnapshotItem(nil), snapshot.Items...) + sort.SliceStable(snapshotItems, func(i, j int) bool { + if snapshotItems[i].Order != snapshotItems[j].Order { + return snapshotItems[i].Order < snapshotItems[j].Order + } + return snapshotItems[i].TaskItemID < snapshotItems[j].TaskItemID + }) + items := make([]forummodel.ForumPostTemplateItem, 0, len(snapshotItems)) + for _, item := range snapshotItems { + if strings.TrimSpace(item.Content) == "" { + continue + } + items = append(items, forummodel.ForumPostTemplateItem{ + SourceTaskItemID: item.TaskItemID, + Order: len(items) + 1, + Content: item.Content, + }) + } + return post, template, items, nil +} + +func configSnapshotJSON(snapshot TaskClassSnapshot) (string, error) { + if strings.TrimSpace(snapshot.ConfigSnapshotJSON) != "" { + return snapshot.ConfigSnapshotJSON, nil + } + raw, err := json.Marshal(map[string]any{ + "mode": snapshot.Mode, + "start_date": snapshot.StartDate, + "end_date": snapshot.EndDate, + "subject_type": snapshot.SubjectType, + "difficulty_level": snapshot.DifficultyLevel, + "cognitive_intensity": snapshot.CognitiveIntensity, + "total_slots": snapshot.TotalSlots, + "allow_filler_course": snapshot.AllowFillerCourse, + "strategy": snapshot.Strategy, + "excluded_slots": snapshot.ExcludedSlots, + "excluded_days_of_week": snapshot.ExcludedDaysOfWeek, + "strategy_labels": snapshot.StrategyLabels, + }) + if err != nil { + return "", err + } + return string(raw), nil +} + +func normalizeRecordNotFound(err error, fallback error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fallback + } + return err +} diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go index 0b0ca8c..bceaffe 100644 --- a/backend/services/taskclassforum/sv/service.go +++ b/backend/services/taskclassforum/sv/service.go @@ -4,13 +4,10 @@ import ( "context" "errors" - forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" "gorm.io/gorm" ) -// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。 -var ErrNotImplemented = errors.New("taskclassforum service method not implemented") - // TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。 // // 职责边界: @@ -71,12 +68,14 @@ type Options struct { // 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 type Service struct { db *gorm.DB + forumDAO *forumdao.ForumDAO taskClassPort TaskClassSnapshotPort } func New(opts Options) *Service { return &Service{ db: opts.DB, + forumDAO: forumdao.NewForumDAO(opts.DB), taskClassPort: opts.TaskClassPort, } } @@ -93,87 +92,3 @@ func (s *Service) Ready() error { } 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 -} diff --git a/backend/shared/contracts/taskclassforum/types.go b/backend/shared/contracts/taskclassforum/types.go index f7007d9..9cde528 100644 --- a/backend/shared/contracts/taskclassforum/types.go +++ b/backend/shared/contracts/taskclassforum/types.go @@ -128,8 +128,10 @@ type ImportForumPostRequest struct { // DeleteForumCommentResult 是删除评论后的状态回执。 type DeleteForumCommentResult struct { - CommentID uint64 `json:"comment_id"` - Status string `json:"status"` + CommentID uint64 `json:"comment_id"` + Status string `json:"status"` + Content string `json:"content"` + DeletedAt *string `json:"deleted_at"` } // ImportForumPostResult 是一键导入后的回执。 From 4fc6c0cac3015f0c86d046b6b44a8dd8d1b43ac3 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 4 May 2026 21:49:29 +0800 Subject: [PATCH 3/6] feat: add token store p0 backend flow --- backend/cmd/start.go | 16 +- backend/config.example.yaml | 8 + backend/gateway/router/router.go | 5 +- backend/gateway/tokenstore/client.go | 388 +++++++++++++++++ backend/gateway/tokenstore/errors.go | 92 +++++ backend/gateway/tokenstoreapi/handler.go | 390 ++++++++++++++++++ backend/gateway/tokenstoreapi/routes.go | 34 ++ backend/middleware/idempotency.go | 20 +- backend/services/tokenstore/dao/tokenstore.go | 260 ++++++++++++ backend/services/tokenstore/rpc/handler.go | 27 +- .../tokenstore/rpc/pb/tokenstore.pb.go | 27 +- .../services/tokenstore/rpc/tokenstore.proto | 3 + backend/services/tokenstore/sv/grant.go | 84 ++++ backend/services/tokenstore/sv/helpers.go | 238 +++++++++++ backend/services/tokenstore/sv/order.go | 312 ++++++++++++++ backend/services/tokenstore/sv/product.go | 34 ++ backend/services/tokenstore/sv/service.go | 53 +-- backend/shared/contracts/tokenstore/types.go | 27 +- 18 files changed, 1921 insertions(+), 97 deletions(-) create mode 100644 backend/gateway/tokenstore/client.go create mode 100644 backend/gateway/tokenstore/errors.go create mode 100644 backend/gateway/tokenstoreapi/handler.go create mode 100644 backend/gateway/tokenstoreapi/routes.go create mode 100644 backend/services/tokenstore/dao/tokenstore.go create mode 100644 backend/services/tokenstore/sv/grant.go create mode 100644 backend/services/tokenstore/sv/helpers.go create mode 100644 backend/services/tokenstore/sv/order.go create mode 100644 backend/services/tokenstore/sv/product.go diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 9aafda7..4a7e9ee 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -25,6 +25,7 @@ import ( "github.com/LoveLosita/smartflow/backend/dao" gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router" gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" + gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore" gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" @@ -75,9 +76,11 @@ type appRuntime struct { handlers *api.ApiHandlers userAuthClient *gatewayuserauth.Client taskClassForumClient *gatewaytaskclassforum.Client + tokenStoreClient *gatewaytokenstore.Client } -// loadConfig 锻炼? +// loadConfig 负责装载全局配置。 +// 职责边界:只封装配置读取入口,不做服务装配和运行时初始化。 func loadConfig() error { return bootstrap.LoadConfig() } @@ -225,6 +228,14 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if err != nil { return nil, fmt.Errorf("failed to initialize taskclassforum zrpc client: %w", err) } + tokenStoreClient, err := gatewaytokenstore.NewClient(gatewaytokenstore.ClientConfig{ + Endpoints: viper.GetStringSlice("tokenstore.rpc.endpoints"), + Target: viper.GetString("tokenstore.rpc.target"), + Timeout: viper.GetDuration("tokenstore.rpc.timeout"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize tokenstore zrpc client: %w", err) + } taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(llmService, courseRepo, scheduleRepo) @@ -335,6 +346,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { handlers: handlers, userAuthClient: userAuthClient, taskClassForumClient: taskClassForumClient, + tokenStoreClient: tokenStoreClient, } if runtime.eventBus != nil { if err := runtime.registerEventHandlers(); err != nil { @@ -915,7 +927,7 @@ func (r *appRuntime) registerEventHandlers() error { } func (r *appRuntime) startHTTP(ctx context.Context) { - router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.cacheRepo, r.limiter) + router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.taskClassForumClient, r.tokenStoreClient, r.cacheRepo, r.limiter) gatewayrouter.StartEngine(ctx, router) } diff --git a/backend/config.example.yaml b/backend/config.example.yaml index db3ab4d..6819397 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -45,6 +45,14 @@ taskclassforum: - "127.0.0.1:9082" timeout: 2s +# Token 商店 zrpc 独立服务与网关客户端配置。 +tokenstore: + rpc: + listenOn: "0.0.0.0:9083" + endpoints: + - "127.0.0.1:9083" + timeout: 2s + # Kafka outbox 事件总线配置。 kafka: enabled: true diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 8136c1c..6fa2aa5 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -12,6 +12,8 @@ import ( "github.com/LoveLosita/smartflow/backend/gateway/forumapi" gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" gatewaytaskclassforum "github.com/LoveLosita/smartflow/backend/gateway/taskclassforum" + gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore" + "github.com/LoveLosita/smartflow/backend/gateway/tokenstoreapi" "github.com/LoveLosita/smartflow/backend/gateway/userapi" rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" "github.com/LoveLosita/smartflow/backend/pkg" @@ -57,7 +59,7 @@ func StartEngine(ctx context.Context, r *gin.Engine) { } } -func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { +func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, forumClient *gatewaytaskclassforum.Client, tokenStoreClient *gatewaytokenstore.Client, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine { r := gin.Default() apiGroup := r.Group("/api/v1") { @@ -70,6 +72,7 @@ func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter) forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter) + tokenstoreapi.RegisterRoutes(apiGroup, tokenstoreapi.NewHandler(tokenStoreClient), authClient, cache, limiter) taskGroup := apiGroup.Group("/task") { diff --git a/backend/gateway/tokenstore/client.go b/backend/gateway/tokenstore/client.go new file mode 100644 index 0000000..6706d59 --- /dev/null +++ b/backend/gateway/tokenstore/client.go @@ -0,0 +1,388 @@ +package tokenstore + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + "github.com/zeromicro/go-zero/zrpc" +) + +const ( + defaultEndpoint = "127.0.0.1:9083" + defaultTimeout = 2 * time.Second +) + +type ClientConfig struct { + Endpoints []string + Target string + Timeout time.Duration +} + +// ProductSnapshot 是订单详情里内嵌的商品快照。 +// +// 职责边界: +// 1. 只承载 HTTP gateway 当前需要透出的商品摘要; +// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体; +// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底。 +type ProductSnapshot struct { + ProductID uint64 `json:"product_id"` + Name string `json:"name"` + TokenAmount int64 `json:"token_amount"` +} + +// OrderView 是 gateway 侧订单展示结构。 +// +// 职责边界: +// 1. 复用 token-store contract 里已稳定的订单字段; +// 2. 为前端 P0 额外承载 product_snapshot / product_name / quantity 三个 HTTP 所需字段; +// 3. 不反向影响 shared/contracts,等并行 worker 合入正式字段后可再收敛。 +type OrderView struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductSnapshot *ProductSnapshot `json:"product_snapshot,omitempty"` + ProductName string `json:"product_name,omitempty"` + Quantity int `json:"quantity"` + TokenAmount int64 `json:"token_amount"` + AmountCent int64 `json:"amount_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + PaymentMode string `json:"payment_mode"` + Grant *tokencontracts.TokenGrantView `json:"grant"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + GrantedAt *string `json:"granted_at"` +} + +// Client 是 gateway 侧访问 token-store zrpc 的适配层。 +// +// 职责边界: +// 1. 只负责 HTTP gateway 与 token-store zrpc 之间的协议转译; +// 2. 不直连 token_* 表,也不承载订单/支付业务规则; +// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。 +type Client struct { + rpc pb.TokenStoreServiceClient +} + +func NewClient(cfg ClientConfig) (*Client, error) { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + endpoints := normalizeEndpoints(cfg.Endpoints) + target := strings.TrimSpace(cfg.Target) + if len(endpoints) == 0 && target == "" { + endpoints = []string{defaultEndpoint} + } + + zclient, err := zrpc.NewClient(zrpc.RpcClientConf{ + Endpoints: endpoints, + Target: target, + NonBlock: true, + Timeout: int64(timeout / time.Millisecond), + }) + if err != nil { + return nil, err + } + return &Client{rpc: pb.NewTokenStoreServiceClient(zclient.Conn())}, nil +} + +func (c *Client) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.GetSummary(ctx, &pb.GetTokenSummaryRequest{ActorUserId: actorUserID}) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty get summary response") + } + summary := tokenSummaryFromPB(resp.Summary) + return &summary, nil +} + +func (c *Client) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.ListProducts(ctx, &pb.ListTokenProductsRequest{ActorUserId: actorUserID}) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty list products response") + } + return tokenProductsFromPB(resp.Items), nil +} + +func (c *Client) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*OrderView, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.CreateOrder(ctx, &pb.CreateTokenOrderRequest{ + ActorUserId: req.ActorUserID, + ProductId: req.ProductID, + Quantity: int32(req.Quantity), + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty create order response") + } + order := tokenOrderFromPB(resp.Order) + return &order, nil +} + +func (c *Client) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]OrderView, tokencontracts.PageResult, error) { + if err := c.ensureReady(); err != nil { + return nil, tokencontracts.PageResult{}, err + } + resp, err := c.rpc.ListOrders(ctx, &pb.ListTokenOrdersRequest{ + ActorUserId: req.ActorUserID, + Page: int32(req.Page), + PageSize: int32(req.PageSize), + Status: req.Status, + }) + if err != nil { + return nil, tokencontracts.PageResult{}, responseFromRPCError(err) + } + if resp == nil { + return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list orders response") + } + return tokenOrdersFromPB(resp.Items), pageFromPB(resp.Page), nil +} + +func (c *Client) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*OrderView, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.GetOrder(ctx, &pb.GetTokenOrderRequest{ + ActorUserId: actorUserID, + OrderId: orderID, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty get order response") + } + order := tokenOrderFromPB(resp.Order) + return &order, nil +} + +func (c *Client) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*OrderView, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.MockPaidOrder(ctx, &pb.MockPaidOrderRequest{ + ActorUserId: req.ActorUserID, + OrderId: req.OrderID, + MockChannel: req.MockChannel, + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty mock paid response") + } + order := tokenOrderFromPB(resp.Order) + return &order, nil +} + +func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) { + if err := c.ensureReady(); err != nil { + return nil, tokencontracts.PageResult{}, err + } + resp, err := c.rpc.ListGrants(ctx, &pb.ListTokenGrantsRequest{ + ActorUserId: req.ActorUserID, + Page: int32(req.Page), + PageSize: int32(req.PageSize), + Source: req.Source, + }) + if err != nil { + return nil, tokencontracts.PageResult{}, responseFromRPCError(err) + } + if resp == nil { + return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list grants response") + } + return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil +} + +func (c *Client) ensureReady() error { + if c == nil || c.rpc == nil { + return errors.New("tokenstore zrpc client is not initialized") + } + return nil +} + +func normalizeEndpoints(values []string) []string { + endpoints := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + endpoints = append(endpoints, trimmed) + } + } + return endpoints +} + +func pageFromPB(page *pb.PageResponse) tokencontracts.PageResult { + if page == nil { + return tokencontracts.PageResult{} + } + return tokencontracts.PageResult{ + Page: int(page.Page), + PageSize: int(page.PageSize), + Total: int(page.Total), + HasMore: page.HasMore, + } +} + +func tokenSummaryFromPB(summary *pb.TokenSummary) tokencontracts.TokenSummary { + if summary == nil { + return tokencontracts.TokenSummary{} + } + return tokencontracts.TokenSummary{ + RecordedTokenTotal: summary.RecordedTokenTotal, + AppliedTokenTotal: summary.AppliedTokenTotal, + PendingApplyTokenTotal: summary.PendingApplyTokenTotal, + QuotaSyncStatus: summary.QuotaSyncStatus, + Tip: summary.Tip, + } +} + +func tokenProductFromPB(product *pb.TokenProductView) tokencontracts.TokenProductView { + if product == nil { + return tokencontracts.TokenProductView{} + } + return tokencontracts.TokenProductView{ + ProductID: product.ProductId, + Name: product.Name, + Description: product.Description, + TokenAmount: product.TokenAmount, + PriceCent: product.PriceCent, + PriceText: product.PriceText, + Currency: product.Currency, + Badge: product.Badge, + Status: product.Status, + SortOrder: int(product.SortOrder), + } +} + +func tokenProductsFromPB(items []*pb.TokenProductView) []tokencontracts.TokenProductView { + if len(items) == 0 { + return []tokencontracts.TokenProductView{} + } + result := make([]tokencontracts.TokenProductView, 0, len(items)) + for _, item := range items { + result = append(result, tokenProductFromPB(item)) + } + return result +} + +func tokenGrantFromPB(grant *pb.TokenGrantView) *tokencontracts.TokenGrantView { + if grant == nil { + return nil + } + return &tokencontracts.TokenGrantView{ + GrantID: grant.GrantId, + EventID: grant.EventId, + Source: grant.Source, + SourceLabel: grant.SourceLabel, + Amount: grant.Amount, + Status: grant.Status, + QuotaApplied: grant.QuotaApplied, + Description: grant.Description, + CreatedAt: grant.CreatedAt, + } +} + +func tokenGrantsFromPB(items []*pb.TokenGrantView) []tokencontracts.TokenGrantView { + if len(items) == 0 { + return []tokencontracts.TokenGrantView{} + } + result := make([]tokencontracts.TokenGrantView, 0, len(items)) + for _, item := range items { + if grant := tokenGrantFromPB(item); grant != nil { + result = append(result, *grant) + } + } + return result +} + +func tokenOrderFromPB(order *pb.TokenOrderView) OrderView { + if order == nil { + return OrderView{} + } + productSnapshot := tokenProductSnapshotFromJSON(order.ProductSnapshot) + productName := strings.TrimSpace(order.ProductName) + if productName == "" && productSnapshot != nil { + productName = productSnapshot.Name + } + return OrderView{ + OrderID: order.OrderId, + OrderNo: order.OrderNo, + Status: order.Status, + ProductSnapshot: productSnapshot, + ProductName: productName, + Quantity: int(order.Quantity), + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + Grant: tokenGrantFromPB(order.Grant), + CreatedAt: order.CreatedAt, + PaidAt: stringPtrFromNonEmpty(order.PaidAt), + GrantedAt: stringPtrFromNonEmpty(order.GrantedAt), + } +} + +func tokenOrdersFromPB(items []*pb.TokenOrderView) []OrderView { + if len(items) == 0 { + return []OrderView{} + } + result := make([]OrderView, 0, len(items)) + for _, item := range items { + result = append(result, tokenOrderFromPB(item)) + } + return result +} + +// tokenProductSnapshotFromJSON 负责把 RPC 内部快照字符串转成 HTTP 展示对象。 +// +// 职责边界: +// 1. 只解析 product_id / name / token_amount 三个前端需要的字段; +// 2. 不把解析失败暴露成接口错误,避免历史脏快照影响订单主流程展示; +// 3. 不反查商品表,订单详情必须以当时下单快照为准。 +func tokenProductSnapshotFromJSON(raw string) *ProductSnapshot { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var snapshot ProductSnapshot + if err := json.Unmarshal([]byte(trimmed), &snapshot); err != nil { + return nil + } + if snapshot.ProductID == 0 && snapshot.Name == "" && snapshot.TokenAmount == 0 { + return nil + } + return &snapshot +} + +func stringPtrFromNonEmpty(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/backend/gateway/tokenstore/errors.go b/backend/gateway/tokenstore/errors.go new file mode 100644 index 0000000..50e6e40 --- /dev/null +++ b/backend/gateway/tokenstore/errors.go @@ -0,0 +1,92 @@ +package tokenstore + +import ( + "errors" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// responseFromRPCError 把 token-store zrpc 错误恢复成 HTTP 层可处理的业务错误。 +// +// 职责边界: +// 1. 优先读取 token-store RPC 写入的 ErrorInfo,恢复 respond.Response; +// 2. 对网络、超时、服务不可用等非业务错误保留为普通 error,让 HTTP 层按 500 处理; +// 3. 不在这里拼装 HTTP 响应体,handler 仍然统一走 respond.DealWithError。 +func responseFromRPCError(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return wrapRPCError(err) + } + if resp, ok := responseFromStatusDetails(st); ok { + return resp + } + + switch st.Code() { + case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented: + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "tokenstore zrpc service internal error" + } + return wrapRPCError(errors.New(msg)) + case codes.PermissionDenied, codes.Unauthenticated: + return responseWithFallback(st, respond.ErrUnauthorized) + case codes.InvalidArgument: + return responseWithFallback(st, respond.MissingParam) + } + + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "tokenstore zrpc service rejected request" + } + return respond.Response{Status: "400", Info: msg} +} + +func responseFromStatusDetails(st *status.Status) (respond.Response, bool) { + if st == nil { + return respond.Response{}, false + } + for _, detail := range st.Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + + statusValue := strings.TrimSpace(info.Reason) + if statusValue == "" { + return respond.Response{}, false + } + message := strings.TrimSpace(st.Message()) + if message == "" && info.Metadata != nil { + message = strings.TrimSpace(info.Metadata["info"]) + } + if message == "" { + message = statusValue + } + return respond.Response{Status: statusValue, Info: message}, true + } + return respond.Response{}, false +} + +func responseWithFallback(st *status.Status, fallback respond.Response) respond.Response { + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = fallback.Info + } + return respond.Response{Status: fallback.Status, Info: msg} +} + +func wrapRPCError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("调用 tokenstore zrpc 服务失败: %w", err) +} diff --git a/backend/gateway/tokenstoreapi/handler.go b/backend/gateway/tokenstoreapi/handler.go new file mode 100644 index 0000000..a741e02 --- /dev/null +++ b/backend/gateway/tokenstoreapi/handler.go @@ -0,0 +1,390 @@ +package tokenstoreapi + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + gatewaytokenstore "github.com/LoveLosita/smartflow/backend/gateway/tokenstore" + "github.com/LoveLosita/smartflow/backend/respond" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + "github.com/gin-gonic/gin" +) + +const requestTimeout = 2 * time.Second + +type TokenStoreClient interface { + GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) + ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) + CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*gatewaytokenstore.OrderView, error) + ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]gatewaytokenstore.OrderView, tokencontracts.PageResult, error) + GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*gatewaytokenstore.OrderView, error) + MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*gatewaytokenstore.OrderView, error) + ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) +} + +type Handler struct { + client TokenStoreClient +} + +func NewHandler(client TokenStoreClient) *Handler { + return &Handler{client: client} +} + +type pageEnvelope[T any] struct { + Items []T `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + HasMore bool `json:"has_more"` +} + +type paymentAction struct { + Type string `json:"type"` + Label string `json:"label"` +} + +type orderCreateEnvelope struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"` + Quantity int `json:"quantity"` + TokenAmount int64 `json:"token_amount"` + AmountCent int64 `json:"amount_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + PaymentMode string `json:"payment_mode"` + PaymentAction paymentAction `json:"payment_action"` + CreatedAt string `json:"created_at"` +} + +type orderListItemEnvelope struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductName string `json:"product_name"` + TokenAmount int64 `json:"token_amount"` + PriceText string `json:"price_text"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + GrantedAt *string `json:"granted_at"` +} + +type orderDetailEnvelope struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"` + Quantity int `json:"quantity"` + TokenAmount int64 `json:"token_amount"` + AmountCent int64 `json:"amount_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + PaymentMode string `json:"payment_mode"` + Grant *tokencontracts.TokenGrantView `json:"grant"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + GrantedAt *string `json:"granted_at"` +} + +type createOrderBody struct { + ProductID uint64 `json:"product_id"` + Quantity int `json:"quantity"` +} + +type mockPaidBody struct { + MockChannel string `json:"mock_channel"` +} + +func (h *Handler) GetSummary(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + summary, err := client.GetSummary(ctx, currentUserID(c)) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, summary)) +} + +func (h *Handler) ListProducts(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + items, err := client.ListProducts(ctx, currentUserID(c)) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"items": items})) +} + +func (h *Handler) CreateOrder(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + var body createOrderBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + order, err := client.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{ + ActorUserID: currentUserID(c), + ProductID: body.ProductID, + Quantity: body.Quantity, + IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderCreateEnvelope(order))) +} + +func (h *Handler) ListOrders(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + pageValue, ok := intQuery(c, "page") + if !ok { + return + } + pageSize, ok := intQuery(c, "page_size") + if !ok { + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + items, page, err := client.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{ + ActorUserID: currentUserID(c), + Page: pageValue, + PageSize: pageSize, + Status: c.Query("status"), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(newOrderListItemEnvelopes(items), page))) +} + +func (h *Handler) GetOrder(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + orderID, ok := uint64Param(c, "order_id") + if !ok { + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + order, err := client.GetOrder(ctx, currentUserID(c), orderID) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order))) +} + +func (h *Handler) MockPaidOrder(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + orderID, ok := uint64Param(c, "order_id") + if !ok { + return + } + var body mockPaidBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + order, err := client.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{ + ActorUserID: currentUserID(c), + OrderID: orderID, + MockChannel: body.MockChannel, + IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order))) +} + +func (h *Handler) ListGrants(c *gin.Context) { + client, ok := h.ready(c) + if !ok { + return + } + pageValue, ok := intQuery(c, "page") + if !ok { + return + } + pageSize, ok := intQuery(c, "page_size") + if !ok { + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + items, page, err := client.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{ + ActorUserID: currentUserID(c), + Page: pageValue, + PageSize: pageSize, + Source: c.Query("source"), + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page))) +} + +func (h *Handler) ready(c *gin.Context) (TokenStoreClient, bool) { + if h == nil || h.client == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token-store gateway client 未初始化"))) + return nil, false + } + return h.client, true +} + +func currentUserID(c *gin.Context) uint64 { + userID := c.GetInt("user_id") + if userID <= 0 { + return 0 + } + return uint64(userID) +} + +func newOrderCreateEnvelope(order *gatewaytokenstore.OrderView) orderCreateEnvelope { + if order == nil { + return orderCreateEnvelope{ + PaymentAction: paymentAction{ + Type: "mock_paid", + Label: "确认支付", + }, + } + } + return orderCreateEnvelope{ + OrderID: order.OrderID, + OrderNo: order.OrderNo, + Status: order.Status, + ProductSnapshot: order.ProductSnapshot, + Quantity: order.Quantity, + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + PaymentAction: paymentAction{ + Type: "mock_paid", + Label: "确认支付", + }, + CreatedAt: order.CreatedAt, + } +} + +func newOrderListItemEnvelopes(items []gatewaytokenstore.OrderView) []orderListItemEnvelope { + if len(items) == 0 { + return []orderListItemEnvelope{} + } + result := make([]orderListItemEnvelope, 0, len(items)) + for _, item := range items { + productName := item.ProductName + if productName == "" && item.ProductSnapshot != nil { + productName = item.ProductSnapshot.Name + } + result = append(result, orderListItemEnvelope{ + OrderID: item.OrderID, + OrderNo: item.OrderNo, + Status: item.Status, + ProductName: productName, + TokenAmount: item.TokenAmount, + PriceText: item.PriceText, + CreatedAt: item.CreatedAt, + PaidAt: item.PaidAt, + GrantedAt: item.GrantedAt, + }) + } + return result +} + +func newOrderDetailEnvelope(order *gatewaytokenstore.OrderView) orderDetailEnvelope { + if order == nil { + return orderDetailEnvelope{} + } + return orderDetailEnvelope{ + OrderID: order.OrderID, + OrderNo: order.OrderNo, + Status: order.Status, + ProductSnapshot: order.ProductSnapshot, + Quantity: order.Quantity, + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + Grant: order.Grant, + CreatedAt: order.CreatedAt, + PaidAt: order.PaidAt, + GrantedAt: order.GrantedAt, + } +} + +func newPageEnvelope[T any](items []T, page tokencontracts.PageResult) pageEnvelope[T] { + return pageEnvelope[T]{ + Items: items, + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + HasMore: page.HasMore, + } +} + +func intQuery(c *gin.Context, key string) (int, bool) { + raw := strings.TrimSpace(c.Query(key)) + if raw == "" { + return 0, true + } + value, err := strconv.Atoi(raw) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return 0, false + } + return value, true +} + +func uint64Param(c *gin.Context, key string) (uint64, bool) { + value, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64) + if err != nil || value == 0 { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return 0, false + } + return value, true +} diff --git a/backend/gateway/tokenstoreapi/routes.go b/backend/gateway/tokenstoreapi/routes.go new file mode 100644 index 0000000..5821bdb --- /dev/null +++ b/backend/gateway/tokenstoreapi/routes.go @@ -0,0 +1,34 @@ +package tokenstoreapi + +import ( + "github.com/LoveLosita/smartflow/backend/dao" + gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" + rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware" + "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/LoveLosita/smartflow/backend/shared/ports" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 把 Token 商店 HTTP 入口挂到 gateway 路由组。 +// +// 职责边界: +// 1. 只注册 /token-store 下的边缘路由,不承载订单和 grant 业务规则; +// 2. P0 全部接口都要求登录,并统一走限流保护; +// 3. 只有创建订单与 mock paid 需要幂等键,避免重复下单或重复确认支付。 +func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *pkg.RateLimiter) { + if apiGroup == nil || handler == nil { + return + } + + tokenStoreGroup := apiGroup.Group("/token-store") + tokenStoreGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1)) + { + tokenStoreGroup.GET("/summary", handler.GetSummary) + tokenStoreGroup.GET("/products", handler.ListProducts) + tokenStoreGroup.POST("/orders", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateOrder) + tokenStoreGroup.GET("/orders", handler.ListOrders) + tokenStoreGroup.GET("/orders/:order_id", handler.GetOrder) + tokenStoreGroup.POST("/orders/:order_id/mock-paid", rootmiddleware.IdempotencyMiddleware(cache), handler.MockPaidOrder) + tokenStoreGroup.GET("/grants", handler.ListGrants) + } +} diff --git a/backend/middleware/idempotency.go b/backend/middleware/idempotency.go index c376b56..6c4ef40 100644 --- a/backend/middleware/idempotency.go +++ b/backend/middleware/idempotency.go @@ -40,7 +40,7 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } userID := c.GetInt("user_id") // 假设 JWT 已存入 - routeKey := idempotencyRouteKey(c) + routeKey := idempotencyScopeKey(c) redisKey := fmt.Sprintf("idempotency:%d:%s:%s:%s", userID, c.Request.Method, routeKey, ikey) // 2. 查 Redis 缓存 @@ -97,13 +97,17 @@ func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { } } -func idempotencyRouteKey(c *gin.Context) string { - // 1. 优先使用 Gin 匹配后的路由模板,避免 /posts/1 和 /posts/2 被当成两个幂等域。 - // 2. 若当前上下文还拿不到模板,则退回请求路径,保证异常情况下仍不会跨接口串响应。 - // 3. 路由 key 统一替换冒号,避免 Redis key 中混入过多分隔符影响人工排查。 +func idempotencyScopeKey(c *gin.Context) string { + // 1. 路由模板用于区分接口语义,避免同一路径被不同 handler 复用时串缓存。 + // 2. 实际路径用于区分资源实例,避免 /orders/1/mock-paid 和 /orders/2/mock-paid 复用同一个幂等响应。 + // 3. 若异常情况下拿不到模板,则退回实际路径,保证至少不会跨资源串响应。 route := strings.TrimSpace(c.FullPath()) - if route == "" && c.Request != nil && c.Request.URL != nil { - route = strings.TrimSpace(c.Request.URL.Path) + actualPath := "" + if c.Request != nil && c.Request.URL != nil { + actualPath = strings.TrimSpace(c.Request.URL.Path) } - return strings.ReplaceAll(route, ":", "_") + if route == "" { + route = actualPath + } + return strings.ReplaceAll(route+"|"+actualPath, ":", "_") } diff --git a/backend/services/tokenstore/dao/tokenstore.go b/backend/services/tokenstore/dao/tokenstore.go new file mode 100644 index 0000000..0323645 --- /dev/null +++ b/backend/services/tokenstore/dao/tokenstore.go @@ -0,0 +1,260 @@ +package dao + +import ( + "context" + "errors" + "strings" + "time" + + tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// TokenStoreDAO 承载 token-store 私有表的持久化访问。 +// +// 职责边界: +// 1. 只访问 token_products、token_orders、token_grants、token_reward_rules。 +// 2. 只提供查询、事务和原子状态更新,不组装 RPC/HTTP 视图。 +// 3. 业务状态机、幂等回退和提示文案由 sv 层负责。 +type TokenStoreDAO struct { + db *gorm.DB +} + +func NewTokenStoreDAO(db *gorm.DB) *TokenStoreDAO { + return &TokenStoreDAO{db: db} +} + +func (dao *TokenStoreDAO) WithTx(tx *gorm.DB) *TokenStoreDAO { + return &TokenStoreDAO{db: tx} +} + +// Transaction 在一个数据库事务内执行 token-store 写操作。 +func (dao *TokenStoreDAO) Transaction(ctx context.Context, fn func(txDAO *TokenStoreDAO) error) error { + return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(dao.WithTx(tx)) + }) +} + +type ListTokenOrdersQuery struct { + UserID uint64 + Page int + PageSize int + Status string +} + +type ListTokenGrantsQuery struct { + UserID uint64 + Page int + PageSize int + Source string +} + +type TokenGrantSummary struct { + RecordedTokenTotal int64 + AppliedTokenTotal int64 +} + +func (dao *TokenStoreDAO) ListActiveProducts(ctx context.Context) ([]tokenmodel.TokenProduct, error) { + var products []tokenmodel.TokenProduct + err := dao.db.WithContext(ctx). + Where("status = ?", tokenmodel.TokenProductStatusActive). + Order("sort_order ASC, id ASC"). + Find(&products).Error + return products, err +} + +func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) { + var product tokenmodel.TokenProduct + err := dao.db.WithContext(ctx). + Where("id = ? AND status = ?", productID, tokenmodel.TokenProductStatusActive). + First(&product).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &product, nil +} + +func (dao *TokenStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*tokenmodel.TokenOrder, error) { + var order tokenmodel.TokenOrder + err := dao.db.WithContext(ctx). + Where("user_id = ? AND idempotency_key = ?", userID, key). + First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &order, nil +} + +func (dao *TokenStoreDAO) CreateOrder(ctx context.Context, order *tokenmodel.TokenOrder) error { + return dao.db.WithContext(ctx).Create(order).Error +} + +func (dao *TokenStoreDAO) CountOrders(ctx context.Context, query ListTokenOrdersQuery) (int64, error) { + db := dao.db.WithContext(ctx). + Model(&tokenmodel.TokenOrder{}). + Where("user_id = ?", query.UserID) + if status := strings.TrimSpace(query.Status); status != "" { + db = db.Where("status = ?", status) + } + + var total int64 + err := db.Count(&total).Error + return total, err +} + +func (dao *TokenStoreDAO) ListOrders(ctx context.Context, query ListTokenOrdersQuery) ([]tokenmodel.TokenOrder, error) { + db := dao.db.WithContext(ctx). + Where("user_id = ?", query.UserID) + if status := strings.TrimSpace(query.Status); status != "" { + db = db.Where("status = ?", status) + } + + var orders []tokenmodel.TokenOrder + err := db.Order("created_at DESC, id DESC"). + Offset((query.Page - 1) * query.PageSize). + Limit(query.PageSize). + Find(&orders).Error + return orders, err +} + +func (dao *TokenStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) { + var order tokenmodel.TokenOrder + err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &order, nil +} + +func (dao *TokenStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) { + var order tokenmodel.TokenOrder + err := dao.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", orderID). + First(&order).Error + if err != nil { + return nil, err + } + return &order, nil +} + +// UpdateOrderState 只负责把订单持久化到最新状态。 +// +// 职责边界: +// 1. 调用方必须先完成状态机判断,并决定最终 status/paid_at/granted_at。 +// 2. 这里不做“是否允许从 A -> B”校验,避免 DAO 层承载业务规则。 +// 3. payment_mode 允许调用方显式回填,保证 mock paid 后订单快照完整。 +func (dao *TokenStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, grantedAt *time.Time, paymentMode string) error { + updates := map[string]any{ + "status": status, + "paid_at": paidAt, + "granted_at": grantedAt, + "payment_mode": paymentMode, + "updated_at": time.Now(), + } + return dao.db.WithContext(ctx). + Model(&tokenmodel.TokenOrder{}). + Where("id = ?", orderID). + Updates(updates).Error +} + +func (dao *TokenStoreDAO) FindGrantByEventID(ctx context.Context, eventID string) (*tokenmodel.TokenGrant, error) { + var grant tokenmodel.TokenGrant + err := dao.db.WithContext(ctx). + Where("event_id = ?", eventID). + First(&grant).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &grant, nil +} + +func (dao *TokenStoreDAO) FindGrantByOrderID(ctx context.Context, orderID uint64) (*tokenmodel.TokenGrant, error) { + var grant tokenmodel.TokenGrant + err := dao.db.WithContext(ctx). + Where("order_id = ?", orderID). + Order("created_at DESC, id DESC"). + First(&grant).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &grant, nil +} + +func (dao *TokenStoreDAO) ListGrantsByOrderIDs(ctx context.Context, orderIDs []uint64) ([]tokenmodel.TokenGrant, error) { + if len(orderIDs) == 0 { + return []tokenmodel.TokenGrant{}, nil + } + + var grants []tokenmodel.TokenGrant + err := dao.db.WithContext(ctx). + Where("order_id IN ?", orderIDs). + Order("created_at DESC, id DESC"). + Find(&grants).Error + return grants, err +} + +func (dao *TokenStoreDAO) CreateGrant(ctx context.Context, grant *tokenmodel.TokenGrant) error { + return dao.db.WithContext(ctx).Create(grant).Error +} + +func (dao *TokenStoreDAO) CountGrants(ctx context.Context, query ListTokenGrantsQuery) (int64, error) { + db := dao.db.WithContext(ctx). + Model(&tokenmodel.TokenGrant{}). + Where("user_id = ?", query.UserID) + if source := strings.TrimSpace(query.Source); source != "" { + db = db.Where("source = ?", source) + } + + var total int64 + err := db.Count(&total).Error + return total, err +} + +func (dao *TokenStoreDAO) ListGrants(ctx context.Context, query ListTokenGrantsQuery) ([]tokenmodel.TokenGrant, error) { + db := dao.db.WithContext(ctx). + Where("user_id = ?", query.UserID) + if source := strings.TrimSpace(query.Source); source != "" { + db = db.Where("source = ?", source) + } + + var grants []tokenmodel.TokenGrant + err := db.Order("created_at DESC, id DESC"). + Offset((query.Page - 1) * query.PageSize). + Limit(query.PageSize). + Find(&grants).Error + return grants, err +} + +func (dao *TokenStoreDAO) SummarizePositiveGrants(ctx context.Context, userID uint64) (TokenGrantSummary, error) { + var summary TokenGrantSummary + err := dao.db.WithContext(ctx). + Model(&tokenmodel.TokenGrant{}). + Select( + `COALESCE(SUM(CASE WHEN amount > 0 AND status IN (?, ?) THEN amount ELSE 0 END), 0) AS recorded_token_total, + COALESCE(SUM(CASE WHEN amount > 0 AND (quota_applied = ? OR status = ?) THEN amount ELSE 0 END), 0) AS applied_token_total`, + tokenmodel.TokenGrantStatusRecorded, + tokenmodel.TokenGrantStatusApplied, + true, + tokenmodel.TokenGrantStatusApplied, + ). + Where("user_id = ?", userID). + Scan(&summary).Error + return summary, err +} diff --git a/backend/services/tokenstore/rpc/handler.go b/backend/services/tokenstore/rpc/handler.go index 7763d4b..92835d5 100644 --- a/backend/services/tokenstore/rpc/handler.go +++ b/backend/services/tokenstore/rpc/handler.go @@ -253,18 +253,21 @@ func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView { return nil } return &pb.TokenOrderView{ - OrderId: order.OrderID, - OrderNo: order.OrderNo, - Status: order.Status, - TokenAmount: order.TokenAmount, - AmountCent: order.AmountCent, - PriceText: order.PriceText, - Currency: order.Currency, - PaymentMode: order.PaymentMode, - Grant: tokenGrantToPB(order.Grant), - CreatedAt: order.CreatedAt, - PaidAt: tokenStringFromPtr(order.PaidAt), - GrantedAt: tokenStringFromPtr(order.GrantedAt), + OrderId: order.OrderID, + OrderNo: order.OrderNo, + Status: order.Status, + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + Grant: tokenGrantToPB(order.Grant), + CreatedAt: order.CreatedAt, + PaidAt: tokenStringFromPtr(order.PaidAt), + GrantedAt: tokenStringFromPtr(order.GrantedAt), + ProductSnapshot: order.ProductSnapshot, + ProductName: order.ProductName, + Quantity: int32(order.Quantity), } } diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go index 249f9e7..659032d 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -63,18 +63,21 @@ func (m *TokenGrantView) String() string { return proto.CompactTextString(m) } func (*TokenGrantView) ProtoMessage() {} type TokenOrderView struct { - OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` - OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` - Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_amount,omitempty"` - AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"` - PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"` - Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"` - PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"` - Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,omitempty"` - CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - PaidAt string `protobuf:"bytes,11,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"` - GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"` + OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` + OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_amount,omitempty"` + AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"` + PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"` + Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"` + PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"` + Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,omitempty"` + CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + PaidAt string `protobuf:"bytes,11,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"` + GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"` + ProductSnapshot string `protobuf:"bytes,13,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"` + ProductName string `protobuf:"bytes,14,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"` + Quantity int32 `protobuf:"varint,15,opt,name=quantity,proto3" json:"quantity,omitempty"` } func (m *TokenOrderView) Reset() { *m = TokenOrderView{} } diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto index bc3eb33..30c3738 100644 --- a/backend/services/tokenstore/rpc/tokenstore.proto +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -67,6 +67,9 @@ message TokenOrderView { string created_at = 10; string paid_at = 11; string granted_at = 12; + string product_snapshot = 13; + string product_name = 14; + int32 quantity = 15; } message GetTokenSummaryRequest { diff --git a/backend/services/tokenstore/sv/grant.go b/backend/services/tokenstore/sv/grant.go new file mode 100644 index 0000000..5338f1f --- /dev/null +++ b/backend/services/tokenstore/sv/grant.go @@ -0,0 +1,84 @@ +package sv + +import ( + "context" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" +) + +// GetSummary 聚合当前用户在 token-store 账本中的获得记录。 +// +// 职责边界: +// 1. 只统计 token_grants,不读取 user/auth 的权威额度。 +// 2. 只汇总正向获取额度,避免把未来的冲正或补偿误算进 P0 展示口径。 +// 3. quota_sync_status 在 P0 固定为 not_connected,明确告知尚未打通权威额度。 +func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if actorUserID == 0 { + return nil, respond.MissingParam + } + + summary, err := s.tokenDAO.SummarizePositiveGrants(ctx, actorUserID) + if err != nil { + return nil, err + } + + pending := summary.RecordedTokenTotal - summary.AppliedTokenTotal + if pending < 0 { + pending = 0 + } + + return &tokencontracts.TokenSummary{ + RecordedTokenTotal: summary.RecordedTokenTotal, + AppliedTokenTotal: summary.AppliedTokenTotal, + PendingApplyTokenTotal: pending, + QuotaSyncStatus: tokenSummaryQuotaStatusNotConnected, + Tip: tokenSummaryTipP0, + }, nil +} + +// ListGrants 按用户分页查询 Token 获得记录。 +// +// 职责边界: +// 1. 只支持 user_id 维度分页和 source 过滤,不做跨用户检索。 +// 2. 负责把空 source 归一化为“不筛选”,避免 DAO 层重复处理入口噪音。 +// 3. 结果只来自账本事实,不推导 user/auth 可用额度。 +func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) { + if err := s.Ready(); err != nil { + return nil, tokencontracts.PageResult{}, err + } + if req.ActorUserID == 0 { + return nil, tokencontracts.PageResult{}, respond.MissingParam + } + + page, pageSize := normalizePage(req.Page, req.PageSize) + query := tokenstoredao.ListTokenGrantsQuery{ + UserID: req.ActorUserID, + Page: page, + PageSize: pageSize, + Source: strings.TrimSpace(req.Source), + } + + total, err := s.tokenDAO.CountGrants(ctx, query) + if err != nil { + return nil, tokencontracts.PageResult{}, err + } + grants, err := s.tokenDAO.ListGrants(ctx, query) + if err != nil { + return nil, tokencontracts.PageResult{}, err + } + if len(grants) == 0 { + return []tokencontracts.TokenGrantView{}, pageResult(page, pageSize, total), nil + } + + result := make([]tokencontracts.TokenGrantView, 0, len(grants)) + for _, grant := range grants { + result = append(result, grantViewFromModel(grant)) + } + return result, pageResult(page, pageSize, total), nil +} diff --git a/backend/services/tokenstore/sv/helpers.go b/backend/services/tokenstore/sv/helpers.go new file mode 100644 index 0000000..ada2491 --- /dev/null +++ b/backend/services/tokenstore/sv/helpers.go @@ -0,0 +1,238 @@ +package sv + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + defaultPage = 1 + defaultPageSize = 20 + maxPageSize = 50 + + tokenSummaryQuotaStatusNotConnected = "not_connected" + tokenSummaryTipP0 = "当前仅统计 Token 商店已记录的获得记录,尚未同步到 user/auth 可用额度。" +) + +type productSnapshot struct { + ProductID uint64 `json:"product_id"` + SKU string `json:"sku"` + Name string `json:"name"` + Description string `json:"description"` + TokenAmount int64 `json:"token_amount"` + PriceCent int64 `json:"price_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + Badge string `json:"badge"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` +} + +func normalizePage(page int, pageSize int) (int, int) { + if page <= 0 { + page = defaultPage + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func pageResult(page int, pageSize int, total int64) tokencontracts.PageResult { + return tokencontracts.PageResult{ + Page: page, + PageSize: pageSize, + Total: int(total), + HasMore: int64(page*pageSize) < total, + } +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339) +} + +func formatTimePtr(value *time.Time) *string { + if value == nil || value.IsZero() { + return nil + } + formatted := value.Format(time.RFC3339) + return &formatted +} + +func formatPriceText(currency string, amountCent int64) string { + if strings.EqualFold(strings.TrimSpace(currency), "CNY") { + return fmt.Sprintf("¥%.2f", float64(amountCent)/100) + } + return fmt.Sprintf("%s %.2f", strings.ToUpper(strings.TrimSpace(currency)), float64(amountCent)/100) +} + +func stringPtrFromNonEmpty(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func productViewFromModel(product tokenmodel.TokenProduct) tokencontracts.TokenProductView { + return tokencontracts.TokenProductView{ + ProductID: product.ID, + Name: product.Name, + Description: product.Description, + TokenAmount: product.TokenAmount, + PriceCent: product.PriceCent, + PriceText: formatPriceText(product.Currency, product.PriceCent), + Currency: product.Currency, + Badge: product.Badge, + Status: product.Status, + SortOrder: product.SortOrder, + } +} + +func grantViewFromModel(grant tokenmodel.TokenGrant) tokencontracts.TokenGrantView { + return tokencontracts.TokenGrantView{ + GrantID: grant.ID, + EventID: grant.EventID, + Source: grant.Source, + SourceLabel: grantSourceLabel(grant.Source, grant.SourceLabel), + Amount: grant.Amount, + Status: grant.Status, + QuotaApplied: grant.QuotaApplied, + Description: grant.Description, + CreatedAt: formatTime(grant.CreatedAt), + } +} + +func orderViewFromModel(order tokenmodel.TokenOrder, grant *tokenmodel.TokenGrant) tokencontracts.TokenOrderView { + var grantView *tokencontracts.TokenGrantView + if grant != nil { + view := grantViewFromModel(*grant) + grantView = &view + } + + return tokencontracts.TokenOrderView{ + OrderID: order.ID, + OrderNo: order.OrderNo, + Status: order.Status, + ProductSnapshot: order.ProductSnapshotJSON, + ProductName: order.ProductName, + Quantity: order.Quantity, + TokenAmount: order.TokenAmount, + AmountCent: order.AmountCent, + PriceText: formatPriceText(order.Currency, order.AmountCent), + Currency: order.Currency, + PaymentMode: order.PaymentMode, + Grant: grantView, + CreatedAt: formatTime(order.CreatedAt), + PaidAt: formatTimePtr(order.PaidAt), + GrantedAt: formatTimePtr(order.GrantedAt), + } +} + +func grantSourceLabel(source string, fallback string) string { + if strings.TrimSpace(fallback) != "" { + return fallback + } + switch strings.TrimSpace(source) { + case tokenmodel.TokenGrantSourcePurchase: + return "购买充值" + case tokenmodel.TokenGrantSourceForumLike: + return "计划被点赞" + case tokenmodel.TokenGrantSourceForumImport: + return "计划被导入" + case tokenmodel.TokenGrantSourceManual: + return "人工补发" + default: + return "Token 获得记录" + } +} + +func buildProductSnapshot(product tokenmodel.TokenProduct) (string, error) { + snapshot := productSnapshot{ + ProductID: product.ID, + SKU: product.SKU, + Name: product.Name, + Description: product.Description, + TokenAmount: product.TokenAmount, + PriceCent: product.PriceCent, + PriceText: formatPriceText(product.Currency, product.PriceCent), + Currency: product.Currency, + Badge: product.Badge, + Status: product.Status, + SortOrder: product.SortOrder, + } + raw, err := json.Marshal(snapshot) + if err != nil { + return "", err + } + return string(raw), nil +} + +func newOrderNo() string { + return fmt.Sprintf( + "TS%s%s", + time.Now().Format("20060102150405"), + strings.ReplaceAll(uuid.NewString(), "-", ""), + ) +} + +func purchaseGrantEventID(orderID uint64) string { + return fmt.Sprintf("order:%d:paid", orderID) +} + +func purchaseGrantDescription(productName string) string { + trimmed := strings.TrimSpace(productName) + if trimmed == "" { + return "购买 Token 商品" + } + return fmt.Sprintf("购买%s", trimmed) +} + +func isDuplicateKeyError(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, "duplicate entry") || + strings.Contains(lower, "duplicate key") || + strings.Contains(lower, "unique constraint") || + strings.Contains(lower, "unique violation") || + strings.Contains(lower, "error 1062") +} + +func normalizeRecordNotFound(err error, fallback error) error { + if errorsIsRecordNotFound(err) { + return fallback + } + return err +} + +func errorsIsRecordNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} + +// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。 +// 具体错误原因仍放在 Info,避免为每个商品/订单校验分支提前扩散大量细分码。 +const tokenStoreBadRequestStatus = "40067" + +func tokenStoreBadRequest(message string) respond.Response { + return respond.Response{ + Status: tokenStoreBadRequestStatus, + Info: strings.TrimSpace(message), + } +} diff --git a/backend/services/tokenstore/sv/order.go b/backend/services/tokenstore/sv/order.go new file mode 100644 index 0000000..030f21d --- /dev/null +++ b/backend/services/tokenstore/sv/order.go @@ -0,0 +1,312 @@ +package sv + +import ( + "context" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" + tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" +) + +// CreateOrder 创建 Token 商品订单。 +// +// 职责边界: +// 1. 校验 actor_user_id、product_id、quantity 与幂等键。 +// 2. 只生成 pending 订单和商品快照,不触发真实支付或 user/auth 同步。 +// 3. 并发冲突时优先按 user_id + idempotency_key 回查旧单,保证 P0 幂等语义。 +func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if req.ActorUserID == 0 || req.ProductID == 0 { + return nil, respond.MissingParam + } + if req.Quantity < 1 || req.Quantity > 99 { + return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99") + } + + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey != "" { + existing, err := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey) + if err != nil { + return nil, err + } + if existing != nil { + return s.orderViewByID(ctx, req.ActorUserID, existing.ID) + } + } + + product, err := s.tokenDAO.FindActiveProductByID(ctx, req.ProductID) + if err != nil { + return nil, err + } + if product == nil { + return nil, tokenStoreBadRequest("商品不存在或已下架") + } + + productSnapshot, err := buildProductSnapshot(*product) + if err != nil { + return nil, err + } + + order := tokenmodel.TokenOrder{ + OrderNo: newOrderNo(), + UserID: req.ActorUserID, + ProductID: product.ID, + ProductSKU: product.SKU, + ProductName: product.Name, + ProductSnapshotJSON: productSnapshot, + Quantity: req.Quantity, + TokenAmount: product.TokenAmount * int64(req.Quantity), + AmountCent: product.PriceCent * int64(req.Quantity), + Currency: product.Currency, + Status: tokenmodel.TokenOrderStatusPending, + PaymentMode: "mock", + IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey), + } + if err := s.tokenDAO.CreateOrder(ctx, &order); err != nil { + if idempotencyKey != "" && isDuplicateKeyError(err) { + existing, findErr := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey) + if findErr != nil { + return nil, findErr + } + if existing != nil { + return s.orderViewByID(ctx, req.ActorUserID, existing.ID) + } + } + return nil, err + } + + return s.orderViewByID(ctx, req.ActorUserID, order.ID) +} + +// ListOrders 按用户分页查询订单列表。 +// +// 职责边界: +// 1. 只支持当前用户维度分页,不做跨用户检索。 +// 2. status 为空时不过滤,非空时按精确值过滤。 +// 3. 负责把订单与 grant 账本拼装成统一视图。 +func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) { + if err := s.Ready(); err != nil { + return nil, tokencontracts.PageResult{}, err + } + if req.ActorUserID == 0 { + return nil, tokencontracts.PageResult{}, respond.MissingParam + } + + page, pageSize := normalizePage(req.Page, req.PageSize) + query := tokenstoredao.ListTokenOrdersQuery{ + UserID: req.ActorUserID, + Page: page, + PageSize: pageSize, + Status: strings.TrimSpace(req.Status), + } + + total, err := s.tokenDAO.CountOrders(ctx, query) + if err != nil { + return nil, tokencontracts.PageResult{}, err + } + orders, err := s.tokenDAO.ListOrders(ctx, query) + if err != nil { + return nil, tokencontracts.PageResult{}, err + } + if len(orders) == 0 { + return []tokencontracts.TokenOrderView{}, pageResult(page, pageSize, total), nil + } + + grantMap, err := s.orderGrantMap(ctx, collectOrderIDs(orders)) + if err != nil { + return nil, tokencontracts.PageResult{}, err + } + + result := make([]tokencontracts.TokenOrderView, 0, len(orders)) + for _, order := range orders { + result = append(result, orderViewFromModel(order, grantMap[order.ID])) + } + return result, pageResult(page, pageSize, total), nil +} + +// GetOrder 查询单个订单详情,并校验归属用户。 +func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if actorUserID == 0 || orderID == 0 { + return nil, respond.MissingParam + } + return s.orderViewByID(ctx, actorUserID, orderID) +} + +// MockPaidOrder 在同步事务里完成 mock paid 和 grant 入账。 +// +// 职责边界: +// 1. 只处理订单状态流转与 token_grants 幂等写入,不调用 user/auth。 +// 2. event_id 固定为 order:{order_id}:paid,作为最终 grant 幂等边界。 +// 3. 重复调用优先复用既有 grant,再把订单补齐到 granted,避免重复写账本。 +func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) { + if err := s.Ready(); err != nil { + return nil, err + } + if req.ActorUserID == 0 || req.OrderID == 0 { + return nil, respond.MissingParam + } + + var resultOrder tokenmodel.TokenOrder + var resultGrant *tokenmodel.TokenGrant + err := s.tokenDAO.Transaction(ctx, func(txDAO *tokenstoredao.TokenStoreDAO) error { + now := time.Now() + + // 1. 先锁订单并校验归属,避免并发 mock paid 重复写 grant。 + // 2. 订单不存在直接返回;订单不属于当前用户时明确拒绝。 + // 3. closed 状态不允许继续支付,避免把关闭单重新拉回可用态。 + order, err := txDAO.LockOrderByID(ctx, req.OrderID) + if err != nil { + return normalizeRecordNotFound(err, tokenStoreBadRequest("订单不存在")) + } + if order.UserID != req.ActorUserID { + return tokenStoreBadRequest("订单不属于当前用户") + } + switch order.Status { + case tokenmodel.TokenOrderStatusPending, tokenmodel.TokenOrderStatusPaid, tokenmodel.TokenOrderStatusGranted: + case tokenmodel.TokenOrderStatusClosed: + return tokenStoreBadRequest("订单已关闭,不能执行 mock paid") + default: + return tokenStoreBadRequest("订单状态不支持执行 mock paid") + } + + eventID := purchaseGrantEventID(order.ID) + grant, err := txDAO.FindGrantByEventID(ctx, eventID) + if err != nil { + return err + } + + // 1. grant 不存在时才尝试创建,保证账本幂等写入边界只在 event_id。 + // 2. 即使因为历史脏数据或极端并发触发唯一冲突,也要立刻按 event_id 反查旧 grant。 + // 3. 这里不写 user/auth,只把 token-store 自己的账本事实补齐。 + if grant == nil { + sourceRefID := order.ID + orderID := order.ID + newGrant := &tokenmodel.TokenGrant{ + EventID: eventID, + UserID: order.UserID, + Source: tokenmodel.TokenGrantSourcePurchase, + SourceLabel: grantSourceLabel(tokenmodel.TokenGrantSourcePurchase, ""), + SourceRefID: &sourceRefID, + OrderID: &orderID, + Amount: order.TokenAmount, + Status: tokenmodel.TokenGrantStatusRecorded, + QuotaApplied: false, + Description: purchaseGrantDescription(order.ProductName), + } + if err := txDAO.CreateGrant(ctx, newGrant); err != nil { + if !isDuplicateKeyError(err) { + return err + } + newGrant, err = txDAO.FindGrantByEventID(ctx, eventID) + if err != nil { + return err + } + if newGrant == nil { + return tokenStoreBadRequest("Token 发放记录创建后未找到") + } + } + grant = newGrant + } + + // 1. 无论订单原来是 pending、paid 还是 granted,只要 grant 已确定,就把订单补齐到 granted。 + // 2. paid_at 缺失时使用本次确认时间;granted_at 缺失时优先复用 grant.created_at,保证链路时间可追溯。 + // 3. 这样即便出现“grant 已有、订单未完成切流”的历史半状态,也能在重复调用时自愈。 + paidAt := order.PaidAt + if paidAt == nil || paidAt.IsZero() { + paidAt = &now + } + grantedAt := order.GrantedAt + if grantedAt == nil || grantedAt.IsZero() { + if grant != nil && !grant.CreatedAt.IsZero() { + grantCreatedAt := grant.CreatedAt + grantedAt = &grantCreatedAt + } else { + grantedAt = &now + } + } + paymentMode := strings.TrimSpace(order.PaymentMode) + if paymentMode == "" { + paymentMode = strings.TrimSpace(req.MockChannel) + } + if paymentMode == "" { + paymentMode = "mock" + } + if err := txDAO.UpdateOrderState(ctx, order.ID, tokenmodel.TokenOrderStatusGranted, paidAt, grantedAt, paymentMode); err != nil { + return err + } + + order.Status = tokenmodel.TokenOrderStatusGranted + order.PaidAt = paidAt + order.GrantedAt = grantedAt + order.PaymentMode = paymentMode + resultOrder = *order + resultGrant = grant + return nil + }) + if err != nil { + return nil, err + } + + view := orderViewFromModel(resultOrder, resultGrant) + return &view, nil +} + +func (s *Service) orderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) { + order, err := s.tokenDAO.FindOrderByID(ctx, orderID) + if err != nil { + return nil, err + } + if order == nil { + return nil, tokenStoreBadRequest("订单不存在") + } + if order.UserID != actorUserID { + return nil, tokenStoreBadRequest("订单不属于当前用户") + } + + grant, err := s.tokenDAO.FindGrantByOrderID(ctx, order.ID) + if err != nil { + return nil, err + } + view := orderViewFromModel(*order, grant) + return &view, nil +} + +func (s *Service) orderGrantMap(ctx context.Context, orderIDs []uint64) (map[uint64]*tokenmodel.TokenGrant, error) { + result := make(map[uint64]*tokenmodel.TokenGrant, len(orderIDs)) + if len(orderIDs) == 0 { + return result, nil + } + + grants, err := s.tokenDAO.ListGrantsByOrderIDs(ctx, orderIDs) + if err != nil { + return nil, err + } + for i := range grants { + grant := grants[i] + if grant.OrderID == nil { + continue + } + if _, exists := result[*grant.OrderID]; exists { + continue + } + grantCopy := grant + result[*grant.OrderID] = &grantCopy + } + return result, nil +} + +func collectOrderIDs(orders []tokenmodel.TokenOrder) []uint64 { + result := make([]uint64, 0, len(orders)) + for _, order := range orders { + result = append(result, order.ID) + } + return result +} diff --git a/backend/services/tokenstore/sv/product.go b/backend/services/tokenstore/sv/product.go new file mode 100644 index 0000000..57d3532 --- /dev/null +++ b/backend/services/tokenstore/sv/product.go @@ -0,0 +1,34 @@ +package sv + +import ( + "context" + + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" +) + +// ListProducts 返回当前可售商品列表。 +// +// 职责边界: +// 1. 只返回 active 商品,不负责后台商品管理。 +// 2. 负责补齐 price_text,保持前端不必重复格式化价格。 +// 3. actorUserID 当前仅保留为统一接口形状,P0 不参与筛选。 +func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) { + _ = actorUserID + if err := s.Ready(); err != nil { + return nil, err + } + + products, err := s.tokenDAO.ListActiveProducts(ctx) + if err != nil { + return nil, err + } + if len(products) == 0 { + return []tokencontracts.TokenProductView{}, nil + } + + result := make([]tokencontracts.TokenProductView, 0, len(products)) + for _, product := range products { + result = append(result, productViewFromModel(product)) + } + return result, nil +} diff --git a/backend/services/tokenstore/sv/service.go b/backend/services/tokenstore/sv/service.go index b2a360b..3dbcc44 100644 --- a/backend/services/tokenstore/sv/service.go +++ b/backend/services/tokenstore/sv/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" + tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" "gorm.io/gorm" ) @@ -35,12 +36,14 @@ type Options struct { // 3. 不负责真实第三方支付回调,P0 只处理 mock paid。 type Service struct { db *gorm.DB + tokenDAO *tokenstoredao.TokenStoreDAO grantOutlet TokenGrantOutlet } func New(opts Options) *Service { return &Service{ db: opts.DB, + tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB), grantOutlet: opts.GrantOutlet, } } @@ -55,53 +58,3 @@ func (s *Service) Ready() error { } return nil } - -// ListProducts 是商品列表用例占位,第四步实现真实查询。 -func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) { - _ = ctx - _ = actorUserID - return nil, ErrNotImplemented -} - -// GetSummary 是 Token 概览用例占位,第四步实现 grant 账本聚合。 -func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) { - _ = ctx - _ = actorUserID - return nil, ErrNotImplemented -} - -// CreateOrder 是创建订单用例占位,第四步实现商品读取、订单幂等和金额快照。 -func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// ListOrders 是订单列表用例占位,第四步实现用户维度分页查询。 -func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) { - _ = ctx - _ = req - return nil, tokencontracts.PageResult{}, ErrNotImplemented -} - -// GetOrder 是订单详情用例占位,第四步实现订单归属校验。 -func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = actorUserID - _ = orderID - return nil, ErrNotImplemented -} - -// MockPaidOrder 是 P0 mock paid 用例占位,第四步实现支付态流转和 grant 账本。 -func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) { - _ = ctx - _ = req - return nil, ErrNotImplemented -} - -// ListGrants 是 Token 获取记录用例占位,第四步实现账本分页查询。 -func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) { - _ = ctx - _ = req - return nil, tokencontracts.PageResult{}, ErrNotImplemented -} diff --git a/backend/shared/contracts/tokenstore/types.go b/backend/shared/contracts/tokenstore/types.go index ff98025..72e90e8 100644 --- a/backend/shared/contracts/tokenstore/types.go +++ b/backend/shared/contracts/tokenstore/types.go @@ -51,18 +51,21 @@ type TokenGrantView struct { // TokenOrderView 是订单展示结构。 type TokenOrderView struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - Status string `json:"status"` - TokenAmount int64 `json:"token_amount"` - AmountCent int64 `json:"amount_cent"` - PriceText string `json:"price_text"` - Currency string `json:"currency"` - PaymentMode string `json:"payment_mode"` - Grant *TokenGrantView `json:"grant"` - CreatedAt string `json:"created_at"` - PaidAt *string `json:"paid_at"` - GrantedAt *string `json:"granted_at"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + ProductSnapshot string `json:"product_snapshot"` + ProductName string `json:"product_name"` + Quantity int `json:"quantity"` + TokenAmount int64 `json:"token_amount"` + AmountCent int64 `json:"amount_cent"` + PriceText string `json:"price_text"` + Currency string `json:"currency"` + PaymentMode string `json:"payment_mode"` + Grant *TokenGrantView `json:"grant"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + GrantedAt *string `json:"granted_at"` } // CreateTokenOrderRequest 是创建订单请求契约。 From c42f0c5b8c603b85f9a82c9699c54527d609d4f1 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 5 May 2026 10:44:33 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=AE=BA?= =?UTF-8?q?=E5=9D=9B=E5=A5=96=E5=8A=B1=20outbox=20=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/start.go | 1 + backend/cmd/taskclassforum/main.go | 24 +- backend/config.example.yaml | 15 ++ backend/gateway/forumapi/handler.go | 15 +- backend/gateway/tokenstore/client.go | 19 ++ backend/go.sum | 74 ++++++ backend/infra/outbox/engine.go | 210 ++++++++++++---- backend/infra/outbox/migration.go | 28 +++ backend/infra/outbox/repository.go | 29 +++ backend/infra/outbox/repository_publisher.go | 103 ++++++++ backend/infra/outbox/service_catalog.go | 14 ++ backend/infra/outbox/service_route.go | 16 ++ backend/inits/mysql.go | 8 +- .../service/events/core_outbox_handlers.go | 23 +- backend/service/events/forum_reward.go | 135 ++++++++++ backend/service/events/outbox_bus.go | 2 + .../service/events/outbox_handler_routes.go | 2 + .../services/taskclassforum/dao/connect.go | 15 +- backend/services/taskclassforum/dao/forum.go | 13 + backend/services/taskclassforum/sv/import.go | 65 ++++- backend/services/taskclassforum/sv/like.go | 47 +++- backend/services/taskclassforum/sv/service.go | 130 +++++++++- backend/services/tokenstore/dao/connect.go | 13 +- backend/services/tokenstore/dao/tokenstore.go | 25 ++ backend/services/tokenstore/rpc/handler.go | 22 ++ .../tokenstore/rpc/pb/tokenstore.pb.go | 19 ++ .../tokenstore/rpc/pb/tokenstore_grpc.pb.go | 28 ++- .../services/tokenstore/rpc/tokenstore.proto | 12 + backend/services/tokenstore/sv/reward.go | 234 ++++++++++++++++++ backend/shared/contracts/tokenstore/types.go | 13 + backend/shared/events/forum.go | 128 ++++++++++ 31 files changed, 1381 insertions(+), 101 deletions(-) create mode 100644 backend/infra/outbox/migration.go create mode 100644 backend/infra/outbox/repository_publisher.go create mode 100644 backend/service/events/forum_reward.go create mode 100644 backend/services/tokenstore/sv/reward.go create mode 100644 backend/shared/events/forum.go diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 4a7e9ee..cf29b92 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -919,6 +919,7 @@ func (r *appRuntime) registerEventHandlers() error { r.memoryModule, r.activeTriggerWorkflow, r.notificationService, + r.tokenStoreClient, r.userAuthClient, ); err != nil { return err diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go index 6230532..8f98b31 100644 --- a/backend/cmd/taskclassforum/main.go +++ b/backend/cmd/taskclassforum/main.go @@ -5,10 +5,12 @@ import ( "github.com/LoveLosita/smartflow/backend/bootstrap" legacydao "github.com/LoveLosita/smartflow/backend/dao" + outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" "github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" forumsv "github.com/LoveLosita/smartflow/backend/services/taskclassforum/sv" + sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" "github.com/spf13/viper" ) @@ -21,14 +23,19 @@ func main() { if err != nil { log.Fatalf("failed to connect taskclassforum database: %v", err) } + if err := registerForumRewardOutboxRoutes(); err != nil { + log.Fatalf("failed to register taskclassforum outbox routes: %v", err) + } // 1. 复用同一个 DB 句柄装配 legacy TaskClass DAO,避免本轮抢改 task-class 模块。 // 2. 计划广场只通过快照端口读取和创建 TaskClass,不直接写 schedule。 // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) + eventPublisher := outboxinfra.NewRepositoryPublisher(outboxinfra.NewRepository(db), viper.GetInt("kafka.maxRetry")) svc := forumsv.New(forumsv.Options{ - DB: db, - TaskClassPort: taskClassPort, + DB: db, + TaskClassPort: taskClassPort, + EventPublisher: eventPublisher, }) forumrpc.Start(forumrpc.ServerOptions{ ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), @@ -36,3 +43,16 @@ func main() { Service: svc, }) } + +// registerForumRewardOutboxRoutes 负责让独立 taskclassforum RPC 进程认识奖励事件的落表归属。 +// +// 步骤说明: +// 1. 点赞、导入事件都由 token-store 消费并写 token_grants,所以事件路由归属 token-store; +// 2. taskclassforum 进程只负责发布事件,不启动 consumer,也不直接写奖励账本; +// 3. 若注册失败直接阻止启动,避免后续点赞/导入看似成功但 outbox 永远无法入队。 +func registerForumRewardOutboxRoutes() error { + if err := outboxinfra.RegisterEventService(sharedevents.ForumPostLikedEventType, outboxinfra.ServiceTokenStore); err != nil { + return err + } + return outboxinfra.RegisterEventService(sharedevents.ForumPostImportedEventType, outboxinfra.ServiceTokenStore) +} diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 6819397..91ba79a 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -47,6 +47,9 @@ taskclassforum: # Token 商店 zrpc 独立服务与网关客户端配置。 tokenstore: + reward: + forumLikeAmount: 1 + forumImportAmount: 5 rpc: listenOn: "0.0.0.0:9083" endpoints: @@ -64,6 +67,18 @@ kafka: retryBatchSize: 100 maxRetry: 20 +# 服务级 outbox 目录配置;未显式覆盖时会使用代码内置默认值。 +outbox: + services: + taskclass-forum: + topic: "smartflow.taskclass-forum.outbox" + groupID: "smartflow-taskclass-forum-outbox-consumer" + table: "taskclass_forum_outbox_messages" + token-store: + topic: "smartflow.token-store.outbox" + groupID: "smartflow-token-store-outbox-consumer" + table: "token_store_outbox_messages" + # 通知投递配置。 notification: frontendBaseURL: "http://localhost:5173" diff --git a/backend/gateway/forumapi/handler.go b/backend/gateway/forumapi/handler.go index 7564ec4..ff70e8d 100644 --- a/backend/gateway/forumapi/handler.go +++ b/backend/gateway/forumapi/handler.go @@ -14,7 +14,12 @@ import ( "github.com/gin-gonic/gin" ) -const requestTimeout = 2 * time.Second +const ( + requestTimeout = 2 * time.Second + forumLikeRewardAmount = int64(1) + forumImportRewardAmount = int64(5) + rewardHintStatusActive = "rule_active" +) type ForumClient interface { ListPosts(ctx context.Context, actorUserID uint64, page int, pageSize int, sort string, keyword string, tag string) ([]contracts.ForumPostBrief, contracts.PageResult, error) @@ -220,8 +225,8 @@ func (h *Handler) LikePost(c *gin.Context) { LikeCount: counters.LikeCount, RewardHint: &rewardHint{ Receiver: "author", - Status: "recorded", - Amount: 1, + Status: rewardHintStatusActive, + Amount: forumLikeRewardAmount, }, })) } @@ -369,8 +374,8 @@ func (h *Handler) ImportPost(c *gin.Context) { ImportCount: result.ImportCount, RewardHint: rewardHint{ Receiver: "author", - Status: "recorded", - Amount: 2, + Status: rewardHintStatusActive, + Amount: forumImportRewardAmount, }, NextAction: nextAction{ Type: "open_task_class", diff --git a/backend/gateway/tokenstore/client.go b/backend/gateway/tokenstore/client.go index 6706d59..1ee20f1 100644 --- a/backend/gateway/tokenstore/client.go +++ b/backend/gateway/tokenstore/client.go @@ -217,6 +217,25 @@ func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGra return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil } +func (c *Client) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + resp, err := c.rpc.RecordForumRewardGrant(ctx, &pb.RecordForumRewardGrantRequest{ + EventId: req.EventID, + ReceiverUserId: req.ReceiverUserID, + Source: req.Source, + SourceRefId: req.SourceRefID, + }) + if err != nil { + return nil, responseFromRPCError(err) + } + if resp == nil { + return nil, errors.New("tokenstore zrpc service returned empty record forum reward grant response") + } + return tokenGrantFromPB(resp.Grant), nil +} + func (c *Client) ensureReady() error { if c == nil || c.rpc == nil { return errors.New("tokenstore zrpc client is not initialized") diff --git a/backend/go.sum b/backend/go.sum index c24c2ed..824fae7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,10 +1,20 @@ +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/IBM/sarama v1.43.1/go.mod h1:GG5q1RURtDNPz8xxJs3mgX6Ytak8Z9eLhAkJPObe2xE= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= @@ -45,6 +55,7 @@ github.com/cloudwego/eino-ext/components/model/ark v0.1.64 h1:ecsP4xWhOGi6NYxl2N github.com/cloudwego/eino-ext/components/model/ark v0.1.64/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -57,21 +68,30 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -83,6 +103,7 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -109,6 +130,7 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -124,6 +146,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -137,6 +160,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -148,6 +173,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -157,16 +183,34 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M= github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8= +github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -177,10 +221,12 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -210,10 +256,15 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs= github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -222,6 +273,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -239,12 +292,15 @@ github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7s github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -262,15 +318,21 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -294,6 +356,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -336,8 +399,11 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -353,8 +419,10 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoB go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= @@ -461,6 +529,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -502,6 +571,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= @@ -528,6 +598,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= @@ -549,6 +620,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -559,12 +631,14 @@ k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/backend/infra/outbox/engine.go b/backend/infra/outbox/engine.go index cf13c74..dd0f229 100644 --- a/backend/infra/outbox/engine.go +++ b/backend/infra/outbox/engine.go @@ -25,6 +25,8 @@ import ( // 3. 返回 error 表示可重试失败,框架回写 retry 后提交 offset。 type MessageHandler func(ctx context.Context, envelope kafkabus.Envelope) error +const minPublishedRescueAfter = 10 * time.Second + // PublishRequest 是通用事件发布入参。 // // 设计目标: @@ -56,6 +58,10 @@ type Engine struct { maxRetry int scanEvery time.Duration scanBatch int + // publishedRescueAfter 是 published 消息本地兜底消费窗口,避免 Kafka 已投递但 consumer 长时间未完成时永久卡住。 + publishedRescueAfter time.Duration + // publishedRescueEnabled 控制是否启用本地兜底消费;默认只给幂等账务类服务打开。 + publishedRescueEnabled bool handlersMu sync.RWMutex handlers map[string]MessageHandler @@ -91,15 +97,17 @@ func NewEngine(repo *Repository, cfg kafkabus.Config) (*Engine, error) { } return &Engine{ - repo: serviceRepo, - producer: producer, - consumer: consumer, - brokers: cfg.Brokers, - route: route, - maxRetry: cfg.MaxRetry, - scanEvery: cfg.RetryScanInterval, - scanBatch: cfg.RetryBatchSize, - handlers: make(map[string]MessageHandler), + repo: serviceRepo, + producer: producer, + consumer: consumer, + brokers: cfg.Brokers, + route: route, + maxRetry: cfg.MaxRetry, + scanEvery: cfg.RetryScanInterval, + scanBatch: cfg.RetryBatchSize, + publishedRescueAfter: normalizePublishedRescueAfter(cfg.RetryScanInterval), + publishedRescueEnabled: route.ServiceName == ServiceNameTokenStore, + handlers: make(map[string]MessageHandler), }, nil } @@ -265,6 +273,9 @@ func (e *Engine) startDispatchLoop(ctx context.Context) { log.Printf("重试投递 outbox 消息失败(id=%d): %v", msg.ID, err) } } + if err = e.rescueStalePublishedMessages(ctx); err != nil { + log.Printf("兜底消费已投递 outbox 消息失败(service=%s): %v", e.route.ServiceName, err) + } } } } @@ -281,7 +292,7 @@ func (e *Engine) dispatchOne(ctx context.Context, outboxID int64) error { return nil } - eventPayload, payloadErr := parseOutboxEventPayload(outboxMsg.Payload) + envelope, payloadErr := e.envelopeFromOutboxMessage(outboxMsg) if payloadErr != nil { markErr := e.repo.MarkDead(ctx, outboxMsg.ID, "解析 outbox 事件包失败: "+payloadErr.Error()) if markErr != nil { @@ -289,23 +300,6 @@ func (e *Engine) dispatchOne(ctx context.Context, outboxID int64) error { } return payloadErr } - if eventPayload.EventID == "" { - eventPayload.EventID = strconv.FormatInt(outboxMsg.ID, 10) - } - serviceName := strings.TrimSpace(outboxMsg.ServiceName) - if serviceName == "" { - serviceName = e.route.ServiceName - } - - envelope := kafkabus.Envelope{ - OutboxID: outboxMsg.ID, - EventID: eventPayload.EventID, - EventType: eventPayload.EventType, - EventVersion: eventPayload.EventVersion, - ServiceName: serviceName, - AggregateID: eventPayload.AggregateID, - Payload: eventPayload.PayloadJSON, - } raw, err := json.Marshal(envelope) if err != nil { markErr := e.repo.MarkDead(ctx, outboxMsg.ID, "序列化 outbox 封装失败: "+err.Error()) @@ -326,6 +320,93 @@ func (e *Engine) dispatchOne(ctx context.Context, outboxID int64) error { return nil } +// rescueStalePublishedMessages 对 published 后长时间未 consumed 的消息做本地兜底消费。 +// +// 职责边界: +// 1. 只处理当前 service 表内的 stale published 消息,不扫描其它服务; +// 2. 不重新投递 Kafka,直接复用 handler 的幂等消费逻辑,避免同一坏分区长期卡死; +// 3. 单条失败只写日志并继续下一条,避免一条坏消息阻断整批兜底。 +func (e *Engine) rescueStalePublishedMessages(ctx context.Context) error { + if e == nil || !e.publishedRescueEnabled { + return nil + } + before := time.Now().Add(-e.publishedRescueAfter) + messages, err := e.repo.ListStalePublishedMessages(ctx, e.route.ServiceName, before, e.scanBatch) + if err != nil { + return err + } + if len(messages) > 0 { + log.Printf("outbox stale published messages=%d, service=%s start local consume", len(messages), e.route.ServiceName) + } + for _, msg := range messages { + if err := e.consumePublishedOne(ctx, msg.ID); err != nil { + log.Printf("兜底消费 outbox 消息失败(id=%d, service=%s): %v", msg.ID, e.route.ServiceName, err) + } + } + return nil +} + +// consumePublishedOne 兜底消费单条已投递但未完成的 outbox 消息。 +// +// 职责边界: +// 1. 只在当前状态仍为 published 时处理,避免覆盖正常 consumer 的最终态; +// 2. 解析失败标记 dead,业务失败交给 handleEnvelope 推进重试; +// 3. 不提交 Kafka offset,因为这里没有从 Kafka 读取消息。 +func (e *Engine) consumePublishedOne(ctx context.Context, outboxID int64) error { + outboxMsg, err := e.repo.GetByID(ctx, outboxID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if outboxMsg.Status != model.OutboxStatusPublished { + return nil + } + + envelope, payloadErr := e.envelopeFromOutboxMessage(outboxMsg) + if payloadErr != nil { + markErr := e.repo.MarkDead(ctx, outboxMsg.ID, "解析已投递 outbox 事件包失败: "+payloadErr.Error()) + if markErr != nil { + log.Printf("标记 outbox 死信失败(id=%d): %v", outboxMsg.ID, markErr) + } + return payloadErr + } + return e.handleEnvelope(ctx, envelope, false) +} + +// envelopeFromOutboxMessage 把 outbox 表记录还原成统一事件信封。 +// +// 职责边界: +// 1. 只做 payload 外壳解析和缺省字段补齐; +// 2. 不判断业务事件是否合法,具体校验仍交给 handler; +// 3. event_id 缺失时使用 outbox id 兜底,保持历史消息可消费。 +func (e *Engine) envelopeFromOutboxMessage(outboxMsg *model.AgentOutboxMessage) (kafkabus.Envelope, error) { + if outboxMsg == nil { + return kafkabus.Envelope{}, errors.New("outbox message is nil") + } + eventPayload, err := parseOutboxEventPayload(outboxMsg.Payload) + if err != nil { + return kafkabus.Envelope{}, err + } + if eventPayload.EventID == "" { + eventPayload.EventID = strconv.FormatInt(outboxMsg.ID, 10) + } + serviceName := strings.TrimSpace(outboxMsg.ServiceName) + if serviceName == "" { + serviceName = e.route.ServiceName + } + return kafkabus.Envelope{ + OutboxID: outboxMsg.ID, + EventID: eventPayload.EventID, + EventType: eventPayload.EventType, + EventVersion: eventPayload.EventVersion, + ServiceName: serviceName, + AggregateID: eventPayload.AggregateID, + Payload: eventPayload.PayloadJSON, + }, nil +} + func (e *Engine) startConsumeLoop(ctx context.Context) { for { select { @@ -361,12 +442,33 @@ func (e *Engine) handleMessage(ctx context.Context, msg segmentkafka.Message) er return errors.New("Kafka 封装缺少 outbox_id") } + if err := e.handleEnvelope(ctx, envelope, true); err != nil { + if commitErr := e.consumer.Commit(ctx, msg); commitErr != nil { + return commitErr + } + return err + } + return e.consumer.Commit(ctx, msg) +} + +// handleEnvelope 执行统一事件信封的本地 handler 路由和状态推进。 +// +// 职责边界: +// 1. 负责事件类型、服务归属和 handler 存在性校验; +// 2. handler 成功后由业务 handler 自己标记 consumed; +// 3. retryOnFailure=true 时才把失败消息退回 pending,避免本地兜底把已投递消息重复投到 Kafka。 +func (e *Engine) handleEnvelope(ctx context.Context, envelope kafkabus.Envelope, retryOnFailure bool) error { + status, err := e.currentMessageStatus(ctx, envelope.OutboxID) + if err != nil { + return err + } + if status != model.OutboxStatusPublished { + return nil + } + eventType := strings.TrimSpace(envelope.EventType) if eventType == "" { _ = e.repo.MarkDead(ctx, envelope.OutboxID, "消息缺少事件类型") - if err := e.consumer.Commit(ctx, msg); err != nil { - return err - } return nil } @@ -386,9 +488,6 @@ func (e *Engine) handleMessage(ctx context.Context, msg segmentkafka.Message) er eventType, envelope.OutboxID, ) - if err := e.consumer.Commit(ctx, msg); err != nil { - return err - } return nil } } @@ -400,23 +499,50 @@ func (e *Engine) handleMessage(ctx context.Context, msg segmentkafka.Message) er } else { _ = e.repo.MarkDead(ctx, envelope.OutboxID, "本服务未注册 handler: "+eventType) } - if err := e.consumer.Commit(ctx, msg); err != nil { - return err - } return nil } if err := handler(ctx, envelope); err != nil { - if markErr := e.repo.MarkFailedForRetry(ctx, envelope.OutboxID, "消费处理失败: "+err.Error()); markErr != nil { - return markErr - } - if commitErr := e.consumer.Commit(ctx, msg); commitErr != nil { - return commitErr + if retryOnFailure { + if markErr := e.repo.MarkFailedForRetry(ctx, envelope.OutboxID, "消费处理失败: "+err.Error()); markErr != nil { + return markErr + } } return err } - return e.consumer.Commit(ctx, msg) + return nil +} + +// currentMessageStatus 读取 outbox 当前状态,作为重复 Kafka 消息的第一道闸门。 +// +// 职责边界: +// 1. 只返回当前状态,不推进状态机; +// 2. 记录已消失时按最终态处理,避免历史 Kafka 消息造成消费循环报错; +// 3. handler 只允许在 published 状态执行,pending/consumed/dead 都直接跳过。 +func (e *Engine) currentMessageStatus(ctx context.Context, outboxID int64) (string, error) { + msg, err := e.repo.GetByID(ctx, outboxID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.OutboxStatusConsumed, nil + } + return "", err + } + return strings.TrimSpace(msg.Status), nil +} + +// normalizePublishedRescueAfter 根据扫描间隔计算 published 兜底窗口。 +// +// 职责边界: +// 1. 只做最小窗口保护,避免刚投递的消息被立即本地重复消费; +// 2. 不读取配置中心,保持 outbox engine 构造参数单一; +// 3. 返回值越小恢复越快,越大重复消费概率越低。 +func normalizePublishedRescueAfter(scanEvery time.Duration) time.Duration { + rescueAfter := scanEvery * 3 + if rescueAfter < minPublishedRescueAfter { + return minPublishedRescueAfter + } + return rescueAfter } func resolveEngineRoute(repo *Repository, cfg kafkabus.Config) ServiceRoute { diff --git a/backend/infra/outbox/migration.go b/backend/infra/outbox/migration.go new file mode 100644 index 0000000..c3d67e7 --- /dev/null +++ b/backend/infra/outbox/migration.go @@ -0,0 +1,28 @@ +package outbox + +import ( + "fmt" + + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" +) + +// AutoMigrateServiceTable 按服务目录迁移单个服务拥有的 outbox 表。 +// +// 职责边界: +// 1. 只负责创建或补齐服务级 outbox 物理表,不迁移任何业务表; +// 2. table 名统一从 service catalog 解析,避免独立服务和 core 进程各写一份默认值; +// 3. 失败时返回带 service/table 的错误,方便启动期直接定位配置漂移。 +func AutoMigrateServiceTable(db *gorm.DB, serviceName string) error { + if db == nil { + return fmt.Errorf("auto migrate outbox table failed for %s: db is nil", serviceName) + } + cfg, ok := ResolveServiceConfig(serviceName) + if !ok { + return fmt.Errorf("resolve outbox config failed for service %s", serviceName) + } + if err := db.Table(cfg.TableName).AutoMigrate(&model.AgentOutboxMessage{}); err != nil { + return fmt.Errorf("auto migrate outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err) + } + return nil +} diff --git a/backend/infra/outbox/repository.go b/backend/infra/outbox/repository.go index f935859..8b829aa 100644 --- a/backend/infra/outbox/repository.go +++ b/backend/infra/outbox/repository.go @@ -127,6 +127,35 @@ func (d *Repository) ListDueMessages(ctx context.Context, serviceName string, li return messages, nil } +// ListStalePublishedMessages 拉取已经投递到 Kafka 但长时间没有完成业务消费的消息。 +// +// 职责边界: +// 1. 只扫描 published 状态,不修改消息,避免和正常 Kafka consumer 抢状态机; +// 2. before 由调用方决定,仓储层不关心具体兜底窗口; +// 3. 返回结果交给上层幂等 handler 处理,重复消费风险由业务 event_id 兜底。 +func (d *Repository) ListStalePublishedMessages(ctx context.Context, serviceName string, before time.Time, limit int) ([]model.AgentOutboxMessage, error) { + if limit <= 0 { + limit = 100 + } + if before.IsZero() { + before = time.Now() + } + + var messages []model.AgentOutboxMessage + query := d.scopedDB(ctx). + Where("status = ? AND published_at IS NOT NULL AND published_at <= ?", model.OutboxStatusPublished, before). + Order("published_at ASC, id ASC"). + Limit(limit) + serviceName = strings.TrimSpace(serviceName) + if serviceName != "" { + query = query.Where("service_name = ?", serviceName) + } + if err := query.Find(&messages).Error; err != nil { + return nil, err + } + return messages, nil +} + // MarkPublished 标记消息已经成功投递到 Kafka。 func (d *Repository) MarkPublished(ctx context.Context, id int64) error { now := time.Now() diff --git a/backend/infra/outbox/repository_publisher.go b/backend/infra/outbox/repository_publisher.go new file mode 100644 index 0000000..9d365d1 --- /dev/null +++ b/backend/infra/outbox/repository_publisher.go @@ -0,0 +1,103 @@ +package outbox + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "gorm.io/gorm" +) + +// RepositoryPublisher 只负责把事件写入服务级 outbox 表。 +// +// 职责边界: +// 1. 负责复用 Repository 的 eventType -> service -> table 路由能力写入 outbox; +// 2. 不启动 Kafka relay / consumer,也不注册任何 handler; +// 3. 适合独立 RPC 服务进程只发布事件、统一由 worker 进程消费的迁移期场景。 +type RepositoryPublisher struct { + repo *Repository + maxRetry int +} + +// NewRepositoryPublisher 基于 outbox 仓储创建轻量发布器。 +func NewRepositoryPublisher(repo *Repository, maxRetry int) *RepositoryPublisher { + return &RepositoryPublisher{ + repo: repo, + maxRetry: maxRetry, + } +} + +// Publish 写入统一事件外壳,保持与 Engine.Publish 相同的 outbox payload 格式。 +// +// 步骤说明: +// 1. 先校验事件类型和业务 payload,明显坏入参直接返回错误,避免写入不可消费消息; +// 2. 再把业务 payload 序列化成 RawMessage,并包进统一事件外壳,保证 worker 解析口径一致; +// 3. 最后交给 Repository 按事件路由落表;路由缺失时返回错误,由业务侧决定是否降级。 +func (p *RepositoryPublisher) Publish(ctx context.Context, req PublishRequest) error { + if p == nil || p.repo == nil { + return errors.New("outbox repository publisher is nil") + } + + eventType := strings.TrimSpace(req.EventType) + if eventType == "" { + return errors.New("eventType is empty") + } + if req.Payload == nil { + return errors.New("payload is nil") + } + + payloadJSON, err := json.Marshal(req.Payload) + if err != nil { + return err + } + + eventVersion := strings.TrimSpace(req.EventVersion) + if eventVersion == "" { + eventVersion = DefaultEventVersion + } + + eventID := strings.TrimSpace(req.EventID) + messageKey := strings.TrimSpace(req.MessageKey) + if messageKey == "" { + messageKey = eventID + } + if messageKey == "" { + messageKey = eventType + } + + aggregateID := strings.TrimSpace(req.AggregateID) + if aggregateID == "" { + aggregateID = messageKey + } + + _, err = p.repo.CreateMessage(ctx, eventType, messageKey, OutboxEventPayload{ + EventID: eventID, + EventType: eventType, + EventVersion: eventVersion, + AggregateID: aggregateID, + Payload: payloadJSON, + }, p.maxRetry) + return err +} + +// PublishWithTx 使用外部事务写入 outbox 消息。 +// +// 职责边界: +// 1. 只把底层 Repository 切到调用方传入的事务句柄,事件外壳和路由逻辑仍复用 Publish; +// 2. 不提交或回滚事务,事务生命周期由业务用例控制; +// 3. 适合“业务表更新 + outbox 入队”必须原子提交的场景。 +func (p *RepositoryPublisher) PublishWithTx(ctx context.Context, tx *gorm.DB, req PublishRequest) error { + if p == nil || p.repo == nil { + return errors.New("outbox repository publisher 未初始化") + } + if tx == nil { + return errors.New("gorm 事务句柄为空") + } + + txPublisher := &RepositoryPublisher{ + repo: p.repo.WithTx(tx), + maxRetry: p.maxRetry, + } + return txPublisher.Publish(ctx, req) +} diff --git a/backend/infra/outbox/service_catalog.go b/backend/infra/outbox/service_catalog.go index 9fc487a..bf715e9 100644 --- a/backend/infra/outbox/service_catalog.go +++ b/backend/infra/outbox/service_catalog.go @@ -15,6 +15,8 @@ const ( ServiceMemory = "memory" ServiceActiveScheduler = "active-scheduler" ServiceNotification = "notification" + ServiceTaskClassForum = "taskclass-forum" + ServiceTokenStore = "token-store" ) // ServiceConfig 描述一个服务级 outbox 的固定归属。 @@ -83,6 +85,18 @@ func LoadServiceConfigs() map[string]ServiceConfig { GroupID: "smartflow-notification-outbox-consumer", TableName: "notification_outbox_messages", }, + ServiceTaskClassForum: { + Name: ServiceTaskClassForum, + Topic: "smartflow.taskclass-forum.outbox", + GroupID: "smartflow-taskclass-forum-outbox-consumer", + TableName: "taskclass_forum_outbox_messages", + }, + ServiceTokenStore: { + Name: ServiceTokenStore, + Topic: "smartflow.token-store.outbox", + GroupID: "smartflow-token-store-outbox-consumer", + TableName: "token_store_outbox_messages", + }, } for name, entry := range entries { diff --git a/backend/infra/outbox/service_route.go b/backend/infra/outbox/service_route.go index 88e7ef7..66ed937 100644 --- a/backend/infra/outbox/service_route.go +++ b/backend/infra/outbox/service_route.go @@ -10,6 +10,8 @@ const ( ServiceNameMemory = "memory" ServiceNameActiveScheduler = "active-scheduler" ServiceNameNotification = "notification" + ServiceNameTaskClassForum = "taskclass-forum" + ServiceNameTokenStore = "token-store" ) // ServiceRoute 描述一个 outbox 服务的终态路由信息。 @@ -56,6 +58,18 @@ var builtinServiceRoutes = map[string]ServiceRoute{ Topic: "smartflow.notification.outbox", GroupID: "smartflow-notification-outbox-consumer", }, + ServiceNameTaskClassForum: { + ServiceName: ServiceNameTaskClassForum, + TableName: "taskclass_forum_outbox_messages", + Topic: "smartflow.taskclass-forum.outbox", + GroupID: "smartflow-taskclass-forum-outbox-consumer", + }, + ServiceNameTokenStore: { + ServiceName: ServiceNameTokenStore, + TableName: "token_store_outbox_messages", + Topic: "smartflow.token-store.outbox", + GroupID: "smartflow-token-store-outbox-consumer", + }, } // DefaultServiceRoutes 返回当前已知服务的默认路由清单。 @@ -71,6 +85,8 @@ func DefaultServiceRoutes() []ServiceRoute { builtinServiceRoutes[ServiceNameMemory], builtinServiceRoutes[ServiceNameActiveScheduler], builtinServiceRoutes[ServiceNameNotification], + builtinServiceRoutes[ServiceNameTaskClassForum], + builtinServiceRoutes[ServiceNameTokenStore], } } diff --git a/backend/inits/mysql.go b/backend/inits/mysql.go index b3cd5e2..a4e1447 100644 --- a/backend/inits/mysql.go +++ b/backend/inits/mysql.go @@ -65,12 +65,8 @@ func autoMigrateOutboxTables(db *gorm.DB) error { // 1. 这里必须按服务目录读取最终生效的 table 名,而不能只看默认内置映射。 // 2. 这样即使后续通过配置覆盖 outbox.services.*.table,启动建表也会和运行时写入保持一致。 for _, serviceName := range outboxinfra.ServiceNames() { - cfg, ok := outboxinfra.ResolveServiceConfig(serviceName) - if !ok { - return fmt.Errorf("resolve outbox config failed for service %s", serviceName) - } - if err := db.Table(cfg.TableName).AutoMigrate(&model.AgentOutboxMessage{}); err != nil { - return fmt.Errorf("auto migrate outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err) + if err := outboxinfra.AutoMigrateServiceTable(db, serviceName); err != nil { + return err } } return nil diff --git a/backend/service/events/core_outbox_handlers.go b/backend/service/events/core_outbox_handlers.go index 90647dd..dabf6c7 100644 --- a/backend/service/events/core_outbox_handlers.go +++ b/backend/service/events/core_outbox_handlers.go @@ -49,9 +49,10 @@ func RegisterAllOutboxHandlers( memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, notificationService *notification.NotificationService, + forumRewardRecorder ForumRewardGrantRecorder, adjuster ports.TokenUsageAdjuster, ) error { - if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow, notificationService); err != nil { + if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow, notificationService, forumRewardRecorder); err != nil { return err } @@ -64,6 +65,7 @@ func RegisterAllOutboxHandlers( memoryModule, activeTriggerWorkflow, notificationService, + forumRewardRecorder, adjuster, )) } @@ -112,6 +114,7 @@ func validateAllOutboxHandlerDeps( memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, notificationService *notification.NotificationService, + forumRewardRecorder ForumRewardGrantRecorder, ) error { if err := validateCoreOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule); err != nil { return err @@ -122,6 +125,9 @@ func validateAllOutboxHandlerDeps( if notificationService == nil { return errors.New("notification service is nil") } + if forumRewardRecorder == nil { + return errors.New("forum reward grant recorder is nil") + } return nil } @@ -191,10 +197,25 @@ func allOutboxHandlerRoutes( memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, notificationService *notification.NotificationService, + forumRewardRecorder ForumRewardGrantRecorder, adjuster ports.TokenUsageAdjuster, ) []outboxHandlerRoute { routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster) routes = append(routes, + outboxHandlerRoute{ + EventType: sharedevents.ForumPostLikedEventType, + Service: outboxHandlerServiceTokenStore, + Register: func() error { + return RegisterForumPostLikedRewardHandler(eventBus, outboxRepo, forumRewardRecorder) + }, + }, + outboxHandlerRoute{ + EventType: sharedevents.ForumPostImportedEventType, + Service: outboxHandlerServiceTokenStore, + Register: func() error { + return RegisterForumPostImportedRewardHandler(eventBus, outboxRepo, forumRewardRecorder) + }, + }, outboxHandlerRoute{ EventType: sharedevents.ActiveScheduleTriggeredEventType, Service: outboxHandlerServiceActiveScheduler, diff --git a/backend/service/events/forum_reward.go b/backend/service/events/forum_reward.go new file mode 100644 index 0000000..421e093 --- /dev/null +++ b/backend/service/events/forum_reward.go @@ -0,0 +1,135 @@ +package events + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" + outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" +) + +// ForumRewardGrantRecorder 描述论坛奖励事件消费后写入 token-store 账本所需的最小能力。 +// +// 职责边界: +// 1. 只暴露论坛奖励入账能力,不暴露商品、订单和用户可见查询接口; +// 2. 由 token-store 自己解析奖励额度和幂等规则,handler 不计算 Token 数量; +// 3. 接口用于隔离 service/events 与 token-store 具体 RPC client 实现。 +type ForumRewardGrantRecorder interface { + RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) +} + +// RegisterForumPostLikedRewardHandler 注册计划被点赞奖励消费者。 +func RegisterForumPostLikedRewardHandler( + bus OutboxBus, + outboxRepo *outboxinfra.Repository, + recorder ForumRewardGrantRecorder, +) error { + return registerForumRewardHandler(bus, outboxRepo, recorder, sharedevents.ForumPostLikedEventType, sharedevents.ForumRewardSourceLike) +} + +// RegisterForumPostImportedRewardHandler 注册计划被导入奖励消费者。 +func RegisterForumPostImportedRewardHandler( + bus OutboxBus, + outboxRepo *outboxinfra.Repository, + recorder ForumRewardGrantRecorder, +) error { + return registerForumRewardHandler(bus, outboxRepo, recorder, sharedevents.ForumPostImportedEventType, sharedevents.ForumRewardSourceImport) +} + +// registerForumRewardHandler 收敛论坛奖励事件的通用解析、校验和入账流程。 +// +// 步骤说明: +// 1. 先校验 outbox 与 token-store 依赖,避免启动期注册半截 handler; +// 2. 消费时先检查版本和 payload,明显不可修复的坏消息直接标记 dead; +// 3. 再调用 token-store 内部 RPC 幂等写 token_grants,RPC 临时失败返回 error 交给 outbox 重试; +// 4. 入账成功后标记 consumed,确保重复消费不会重复发放。 +func registerForumRewardHandler( + bus OutboxBus, + outboxRepo *outboxinfra.Repository, + recorder ForumRewardGrantRecorder, + eventType string, + source string, +) error { + if bus == nil { + return errors.New("event bus is nil") + } + if outboxRepo == nil { + return errors.New("outbox repository is nil") + } + if recorder == nil { + return errors.New("forum reward grant recorder is nil") + } + eventOutboxRepo, err := scopedOutboxRepoForEvent(outboxRepo, eventType) + if err != nil { + return err + } + + handler := func(ctx context.Context, envelope kafkabus.Envelope) error { + if !isAllowedForumRewardEventVersion(envelope.EventVersion) { + _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件版本不受支持: %s", envelope.EventVersion)) + return nil + } + + var payload sharedevents.ForumPostRewardPayload + if unmarshalErr := json.Unmarshal(envelope.Payload, &payload); unmarshalErr != nil { + _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析论坛奖励载荷失败: "+unmarshalErr.Error()) + return nil + } + if validateErr := payload.Validate(); validateErr != nil { + _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励载荷非法: "+validateErr.Error()) + return nil + } + if payload.EventType() != eventType { + _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件类型不匹配: envelope=%s payload=%s", eventType, payload.EventType())) + return nil + } + + eventID := strings.TrimSpace(envelope.EventID) + if eventID == "" { + eventID = strings.TrimSpace(payload.EventID) + } + if eventID == "" { + _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励 event_id 为空") + return nil + } + + _, err := recorder.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{ + EventID: eventID, + ReceiverUserID: payload.RewardReceiverUserID, + Source: forumRewardSource(payload, source), + SourceRefID: forumRewardSourceRefID(payload, source), + }) + if err != nil { + return err + } + return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID) + } + + return bus.RegisterEventHandler(eventType, handler) +} + +func isAllowedForumRewardEventVersion(version string) bool { + version = strings.TrimSpace(version) + return version == "" || version == sharedevents.ForumRewardEventVersion +} + +func forumRewardSource(payload sharedevents.ForumPostRewardPayload, fallback string) string { + source := strings.TrimSpace(payload.Source) + if source != "" { + return source + } + return fallback +} + +func forumRewardSourceRefID(payload sharedevents.ForumPostRewardPayload, source string) string { + if source == sharedevents.ForumRewardSourceImport && payload.ImportID > 0 { + return strconv.FormatUint(payload.ImportID, 10) + } + return strconv.FormatUint(payload.PostID, 10) +} diff --git a/backend/service/events/outbox_bus.go b/backend/service/events/outbox_bus.go index 2de4cfe..a17bbc6 100644 --- a/backend/service/events/outbox_bus.go +++ b/backend/service/events/outbox_bus.go @@ -171,6 +171,8 @@ func OutboxServiceNames() []string { string(outboxHandlerServiceMemory), string(outboxHandlerServiceActiveScheduler), string(outboxHandlerServiceNotification), + string(outboxHandlerServiceTaskClassForum), + string(outboxHandlerServiceTokenStore), } } diff --git a/backend/service/events/outbox_handler_routes.go b/backend/service/events/outbox_handler_routes.go index 1748ca6..c7a3955 100644 --- a/backend/service/events/outbox_handler_routes.go +++ b/backend/service/events/outbox_handler_routes.go @@ -18,6 +18,8 @@ const ( outboxHandlerServiceMemory outboxHandlerService = "memory" outboxHandlerServiceActiveScheduler outboxHandlerService = "active-scheduler" outboxHandlerServiceNotification outboxHandlerService = "notification" + outboxHandlerServiceTaskClassForum outboxHandlerService = "taskclass-forum" + outboxHandlerServiceTokenStore outboxHandlerService = "token-store" ) // outboxHandlerRoute 显式描述“事件类型 -> 服务归属 -> handler 注册动作”。 diff --git a/backend/services/taskclassforum/dao/connect.go b/backend/services/taskclassforum/dao/connect.go index 37d9c4d..982059c 100644 --- a/backend/services/taskclassforum/dao/connect.go +++ b/backend/services/taskclassforum/dao/connect.go @@ -3,6 +3,7 @@ package dao import ( "fmt" + outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" "github.com/spf13/viper" "gorm.io/driver/mysql" @@ -12,7 +13,7 @@ import ( // OpenDBFromConfig 创建计划广场服务自己的数据库句柄,并迁移本服务私有表。 // // 职责边界: -// 1. 只迁移 forum_* 表,不迁移 task_classes / task_items,避免抢占 task-class 拆分线; +// 1. 只迁移 forum_* 表和本服务 outbox 表,不迁移 task_classes / task_items,避免抢占 task-class 拆分线; // 2. 不负责装配 legacy TaskClass adapter,adapter 在服务实现阶段单独注入; // 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。 func OpenDBFromConfig() (*gorm.DB, error) { @@ -41,8 +42,10 @@ func OpenDBFromConfig() (*gorm.DB, error) { // // 步骤说明: // 1. 先创建帖子、模板、条目、点赞、评论、导入记录表; -// 2. 唯一约束交给 GORM tag 生成,保证点赞和导入幂等有数据库兜底; -// 3. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。 +// 2. 再按 service catalog 创建 taskclass-forum outbox 表,为后续论坛自身异步事件预留稳定目录; +// 3. 迁移期论坛奖励事件直接写 token-store outbox 表,发布端也兜底创建目标表,避免独立启动顺序导致奖励漏表; +// 4. 唯一约束交给 GORM tag 生成,保证点赞和导入幂等有数据库兜底; +// 5. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。 func AutoMigrate(db *gorm.DB) error { if db == nil { return fmt.Errorf("taskclassforum auto migrate failed: db is nil") @@ -57,5 +60,11 @@ func AutoMigrate(db *gorm.DB) error { ); err != nil { return fmt.Errorf("auto migrate taskclassforum tables failed: %w", err) } + if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTaskClassForum); err != nil { + return err + } + if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil { + return err + } return nil } diff --git a/backend/services/taskclassforum/dao/forum.go b/backend/services/taskclassforum/dao/forum.go index faeabed..57706a8 100644 --- a/backend/services/taskclassforum/dao/forum.go +++ b/backend/services/taskclassforum/dao/forum.go @@ -29,6 +29,19 @@ func (dao *ForumDAO) WithTx(tx *gorm.DB) *ForumDAO { return &ForumDAO{db: tx} } +// GormDB 返回当前 DAO 绑定的 GORM 句柄。 +// +// 职责边界: +// 1. 只提供给需要和 forum 业务事务同提交的基础设施使用,例如 outbox 入队; +// 2. 不鼓励业务层绕过 DAO 任意读写 forum_* 表; +// 3. 若当前 DAO 来自 WithTx,返回值就是同一个事务句柄。 +func (dao *ForumDAO) GormDB() *gorm.DB { + if dao == nil { + return nil + } + return dao.db +} + // Transaction 在一个数据库事务内执行计划广场写操作。 func (dao *ForumDAO) Transaction(ctx context.Context, fn func(txDAO *ForumDAO) error) error { return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/backend/services/taskclassforum/sv/import.go b/backend/services/taskclassforum/sv/import.go index 6172a9e..82b15d9 100644 --- a/backend/services/taskclassforum/sv/import.go +++ b/backend/services/taskclassforum/sv/import.go @@ -10,6 +10,7 @@ import ( forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" ) // ImportPost 从论坛模板导入当前用户自己的 TaskClass 副本。 @@ -41,7 +42,7 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum return nil, err } if existing != nil && existing.Status == forummodel.ForumImportStatusImported { - return importResultFromModel(*existing), nil + return s.importResultWithCurrentImportCount(ctx, *existing), nil } } existing, err := s.forumDAO.FindImport(ctx, req.PostID, req.ActorUserID) @@ -49,7 +50,7 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum return nil, err } if existing != nil && existing.Status == forummodel.ForumImportStatusImported { - return importResultFromModel(*existing), nil + return s.importResultWithCurrentImportCount(ctx, *existing), nil } if existing != nil && existing.Status == forummodel.ForumImportStatusFailed && existing.NewTaskClassID != nil { return s.recoverCreatedImport(ctx, req, *existing) @@ -73,9 +74,7 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum return nil, err } if pending.Status == forummodel.ForumImportStatusImported { - result := importResultFromModel(*pending) - result.ImportCount = post.ImportCount - return result, nil + return s.importResultWithCurrentImportCount(ctx, *pending), nil } created, err := s.taskClassPort.CreateTaskClassFromSnapshot(ctx, req.ActorUserID, snapshot, targetTitle) @@ -90,6 +89,7 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum } var imported forummodel.ForumImport + var rewardPayload *sharedevents.ForumPostRewardPayload if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) @@ -105,7 +105,8 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum imported = *again return nil } - if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, time.Now()); err != nil { + finalizedAt := time.Now() + if err := txDAO.FinalizeImport(ctx, pending.ID, created.TaskClassID, created.Title, finalizedAt); err != nil { return err } imported = *again @@ -113,6 +114,18 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum imported.TargetTitle = created.Title imported.Status = forummodel.ForumImportStatusImported if again.Status != forummodel.ForumImportStatusImported { + payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt) + if again.EventID != "" { + payload.EventID = again.EventID + } + // 调用目的:导入成功和作者奖励事件必须同事务提交,避免只创建副本却永久漏发奖励。 + handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload) + if publishErr != nil { + return publishErr + } + if !handled { + rewardPayload = &payload + } return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1) } return nil @@ -120,6 +133,9 @@ func (s *Service) ImportPost(ctx context.Context, req forumcontracts.ImportForum _ = s.forumDAO.MarkImportFailedAfterTaskClassCreated(ctx, pending.ID, created.TaskClassID, created.Title, err.Error(), time.Now()) return nil, err } + if rewardPayload != nil { + s.publishForumRewardEventBestEffort(*rewardPayload) + } result := importResultFromModel(imported) if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil { result.ImportCount = postAfter.ImportCount @@ -183,6 +199,7 @@ func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.I return nil, respond.RequestIsProcessing } imported := existing + var rewardPayload *sharedevents.ForumPostRewardPayload if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { if _, err := txDAO.LockPublishedPost(ctx, req.PostID); err != nil { return normalizeRecordNotFound(err, respond.UserTaskClassNotFound) @@ -201,15 +218,31 @@ func (s *Service) recoverCreatedImport(ctx context.Context, req forumcontracts.I if again.Status != forummodel.ForumImportStatusFailed || again.NewTaskClassID == nil { return respond.RequestIsProcessing } - if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, time.Now()); err != nil { + finalizedAt := time.Now() + if err := txDAO.FinalizeImport(ctx, again.ID, *again.NewTaskClassID, again.TargetTitle, finalizedAt); err != nil { return err } imported = *again imported.Status = forummodel.ForumImportStatusImported + payload := sharedevents.NewForumPostImportedPayload(req.PostID, again.ID, again.AuthorUserID, req.ActorUserID, finalizedAt) + if again.EventID != "" { + payload.EventID = again.EventID + } + // 调用目的:恢复已创建副本的导入记录时,同步补齐奖励 outbox,保证恢复路径和首次成功路径一致。 + handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload) + if publishErr != nil { + return publishErr + } + if !handled { + rewardPayload = &payload + } return txDAO.AddPostCounter(ctx, req.PostID, "import_count", 1) }); err != nil { return nil, err } + if rewardPayload != nil { + s.publishForumRewardEventBestEffort(*rewardPayload) + } result := importResultFromModel(imported) if postAfter, err := s.forumDAO.FindPublishedPost(ctx, req.PostID); err == nil { result.ImportCount = postAfter.ImportCount @@ -231,6 +264,20 @@ func importResultFromModel(item forummodel.ForumImport) *forumcontracts.ImportFo } } -func forumImportEventID(postID uint64, userID uint64) string { - return fmt.Sprintf("forum.post.imported:%d:%d", postID, userID) +// importResultWithCurrentImportCount 复用已有导入记录时补齐帖子当前导入计数。 +// +// 职责边界: +// 1. 只补齐响应展示用的 import_count,不改变 forum_imports 状态; +// 2. 查询帖子失败时保留基础导入回执,避免幂等重放因为展示字段失败而误报导入失败; +// 3. 新导入路径仍以事务内 AddPostCounter 为准,这里只处理已导入短路路径。 +func (s *Service) importResultWithCurrentImportCount(ctx context.Context, item forummodel.ForumImport) *forumcontracts.ImportForumPostResult { + result := importResultFromModel(item) + if post, err := s.forumDAO.FindPublishedPost(ctx, item.PostID); err == nil { + result.ImportCount = post.ImportCount + } + return result +} + +func forumImportEventID(postID uint64, userID uint64) string { + return sharedevents.ForumRewardEventID(sharedevents.ForumPostImportedEventType, postID, userID) } diff --git a/backend/services/taskclassforum/sv/like.go b/backend/services/taskclassforum/sv/like.go index 26b1212..fc2d81d 100644 --- a/backend/services/taskclassforum/sv/like.go +++ b/backend/services/taskclassforum/sv/like.go @@ -2,21 +2,21 @@ package sv import ( "context" - "fmt" "time" "github.com/LoveLosita/smartflow/backend/respond" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" ) // LikePost 点赞计划帖子。 // // 职责边界: -// 1. 负责保证同一用户同一帖子只有一个 active 点赞状态; -// 2. 负责维护帖子 like_count 计数字段; -// 3. 不直接发放 Token,只写稳定 event_id,后续奖励链路可基于该 ID 幂等消费。 +// 1. 保证同一用户同一帖子只有一个 active 点赞状态; +// 2. 维护帖子 like_count 计数字段; +// 3. 只在首次创建 like 记录时补发 outbox 事件,取消后重新激活旧记录不重复发奖励。 func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { if err := s.Ready(); err != nil { return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err @@ -25,6 +25,7 @@ func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint6 return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, respond.MissingParam } + var rewardPayload *sharedevents.ForumPostRewardPayload if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { post, err := txDAO.LockPublishedPost(ctx, postID) if err != nil { @@ -35,7 +36,19 @@ func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint6 return err } if like == nil { - return createActiveLike(ctx, txDAO, post, actorUserID) + payload, createErr := createActiveLike(ctx, txDAO, post, actorUserID) + if createErr != nil { + return createErr + } + // 调用目的:优先把首次点赞奖励事件写入当前事务,保证点赞记录和 outbox 入队原子提交。 + handled, publishErr := s.publishForumRewardEventInTx(ctx, txDAO.GormDB(), payload) + if publishErr != nil { + return publishErr + } + if !handled { + rewardPayload = &payload + } + return nil } if like.Status == forummodel.ForumLikeStatusActive { return nil @@ -47,6 +60,10 @@ func (s *Service) LikePost(ctx context.Context, actorUserID uint64, postID uint6 }); err != nil { return forumcontracts.ForumPostCounters{}, forumcontracts.ForumPostViewerState{}, err } + + if rewardPayload != nil { + s.publishForumRewardEventBestEffort(*rewardPayload) + } return s.postInteractionState(ctx, actorUserID, postID) } @@ -80,7 +97,7 @@ func (s *Service) UnlikePost(ctx context.Context, actorUserID uint64, postID uin return s.postInteractionState(ctx, actorUserID, postID) } -func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) error { +func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forummodel.ForumPost, actorUserID uint64) (sharedevents.ForumPostRewardPayload, error) { like := &forummodel.ForumLike{ PostID: post.ID, UserID: actorUserID, @@ -89,9 +106,21 @@ func createActiveLike(ctx context.Context, txDAO *forumdao.ForumDAO, post *forum EventID: forumLikeEventID(post.ID, actorUserID), } if err := txDAO.CreateLike(ctx, like); err != nil { - return err + return sharedevents.ForumPostRewardPayload{}, err } - return txDAO.AddPostCounter(ctx, post.ID, "like_count", 1) + if err := txDAO.AddPostCounter(ctx, post.ID, "like_count", 1); err != nil { + return sharedevents.ForumPostRewardPayload{}, err + } + + likedAt := like.LikedAt + if likedAt.IsZero() { + likedAt = time.Now() + } + payload := sharedevents.NewForumPostLikedPayload(post.ID, post.AuthorUserID, actorUserID, likedAt) + if like.EventID != "" { + payload.EventID = like.EventID + } + return payload, nil } func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, postID uint64) (forumcontracts.ForumPostCounters, forumcontracts.ForumPostViewerState, error) { @@ -107,5 +136,5 @@ func (s *Service) postInteractionState(ctx context.Context, actorUserID uint64, } func forumLikeEventID(postID uint64, userID uint64) string { - return fmt.Sprintf("forum.post.liked:%d:%d", postID, userID) + return sharedevents.ForumRewardEventID(sharedevents.ForumPostLikedEventType, postID, userID) } diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go index bceaffe..b1daa55 100644 --- a/backend/services/taskclassforum/sv/service.go +++ b/backend/services/taskclassforum/sv/service.go @@ -3,16 +3,27 @@ package sv import ( "context" "errors" + "log" + "strings" + "time" + outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" "gorm.io/gorm" ) -// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 的端口。 +const forumRewardPublishTimeout = 800 * time.Millisecond + +type transactionalEventPublisher interface { + PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error +} + +// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。 // // 职责边界: -// 1. P0 由 legacy adapter 适配旧 TaskClass DAO / Service; -// 2. 业务层只依赖快照语义,不关心底层是旧表、旧服务还是后续 RPC; +// 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) @@ -56,8 +67,9 @@ type CreatedTaskClass struct { // Options 是计划广场服务的依赖注入参数。 type Options struct { - DB *gorm.DB - TaskClassPort TaskClassSnapshotPort + DB *gorm.DB + TaskClassPort TaskClassSnapshotPort + EventPublisher outboxinfra.EventPublisher } // Service 承载计划广场服务内部业务编排。 @@ -65,24 +77,26 @@ type Options struct { // 职责边界: // 1. 负责帖子、模板快照、点赞、评论、导入记录的事务编排; // 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response; -// 3. 不拥有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 +// 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 type Service struct { - db *gorm.DB - forumDAO *forumdao.ForumDAO - taskClassPort TaskClassSnapshotPort + db *gorm.DB + forumDAO *forumdao.ForumDAO + taskClassPort TaskClassSnapshotPort + eventPublisher outboxinfra.EventPublisher } func New(opts Options) *Service { return &Service{ - db: opts.DB, - forumDAO: forumdao.NewForumDAO(opts.DB), - taskClassPort: opts.TaskClassPort, + db: opts.DB, + forumDAO: forumdao.NewForumDAO(opts.DB), + taskClassPort: opts.TaskClassPort, + eventPublisher: opts.EventPublisher, } } // Ready 用于第二步骨架阶段的依赖检查。 // -// 后续实现真实用例时,具体方法会做更细的参数校验;这里先帮助 cmd / 测试快速发现依赖未注入。 +// 后续实现真实用例时,具体方法会做更细的参数校验;这里只先帮助 cmd / 测试快速发现依赖未注入。 func (s *Service) Ready() error { if s == nil { return errors.New("taskclassforum service is nil") @@ -92,3 +106,93 @@ func (s *Service) Ready() error { } return nil } + +// publishForumRewardEventBestEffort 在主事务成功后补发论坛奖励 outbox 事件。 +// +// 职责边界: +// 1. 这里只处理“事务已经成功提交后的补发”,不再回头影响点赞/导入接口的成功结果; +// 2. 改用独立短超时 context,避免客户端断开直接打断补发,也避免 outbox 写入长时间拖慢接口尾部; +// 3. 发布失败时只记日志不返回 error,这是 P0 的明确取舍:先保住主链路,再靠日志和稳定 event_id 排障/补偿。 +func (s *Service) publishForumRewardEventBestEffort(payload sharedevents.ForumPostRewardPayload) { + if s == nil || s.eventPublisher == nil { + return + } + if err := payload.Validate(); err != nil { + log.Printf( + "forum reward outbox payload 非法,跳过发布: event_id=%s post_id=%d import_id=%d source=%s err=%v", + payload.EventID, + payload.PostID, + payload.ImportID, + payload.Source, + err, + ) + return + } + + eventType := strings.TrimSpace(payload.EventType()) + if eventType == "" { + log.Printf( + "forum reward outbox 事件类型为空,跳过发布: event_id=%s post_id=%d import_id=%d source=%s", + payload.EventID, + payload.PostID, + payload.ImportID, + payload.Source, + ) + return + } + + publishCtx, cancel := context.WithTimeout(context.Background(), forumRewardPublishTimeout) + defer cancel() + + if err := s.eventPublisher.Publish(publishCtx, outboxinfra.PublishRequest{ + EventType: eventType, + EventVersion: sharedevents.ForumRewardEventVersion, + MessageKey: payload.MessageKey(), + AggregateID: payload.AggregateID(), + EventID: payload.EventID, + Payload: payload, + }); err != nil { + log.Printf( + "forum reward outbox 发布失败,按 P0 约定忽略主链路错误: event_type=%s event_id=%s post_id=%d import_id=%d actor_user_id=%d err=%v", + eventType, + payload.EventID, + payload.PostID, + payload.ImportID, + payload.ActorUserID, + err, + ) + } +} + +// publishForumRewardEventInTx 尝试把论坛奖励事件写进当前业务事务。 +// +// 返回值说明: +// 1. handled=true 表示发布器支持事务写入,调用方不需要再做事务后 best-effort 补发; +// 2. handled=false 表示当前发布器不支持事务写入,调用方可退回旧的事务后补发路径; +// 3. error 非空表示 outbox 入队失败,业务事务应一起回滚,避免成功互动永久漏奖。 +func (s *Service) publishForumRewardEventInTx(ctx context.Context, tx *gorm.DB, payload sharedevents.ForumPostRewardPayload) (bool, error) { + if s == nil || s.eventPublisher == nil { + return false, nil + } + publisher, ok := s.eventPublisher.(transactionalEventPublisher) + if !ok { + return false, nil + } + if err := payload.Validate(); err != nil { + return true, err + } + + eventType := strings.TrimSpace(payload.EventType()) + if eventType == "" { + return true, errors.New("论坛奖励事件类型为空") + } + + return true, publisher.PublishWithTx(ctx, tx, outboxinfra.PublishRequest{ + EventType: eventType, + EventVersion: sharedevents.ForumRewardEventVersion, + MessageKey: payload.MessageKey(), + AggregateID: payload.AggregateID(), + EventID: payload.EventID, + Payload: payload, + }) +} diff --git a/backend/services/tokenstore/dao/connect.go b/backend/services/tokenstore/dao/connect.go index e5af2ba..fca2d8f 100644 --- a/backend/services/tokenstore/dao/connect.go +++ b/backend/services/tokenstore/dao/connect.go @@ -3,6 +3,7 @@ package dao import ( "fmt" + outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" "github.com/spf13/viper" "gorm.io/driver/mysql" @@ -13,7 +14,7 @@ import ( // OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。 // // 职责边界: -// 1. 只迁移 token_* 表,不迁移 users,避免和 user/auth 服务边界冲突; +// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users,避免和 user/auth 服务边界冲突; // 2. 自动迁移后执行 P0 seed,确保前端商品页有可展示商品; // 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。 func OpenDBFromConfig() (*gorm.DB, error) { @@ -45,8 +46,9 @@ func OpenDBFromConfig() (*gorm.DB, error) { // // 步骤说明: // 1. 先创建商品、订单、获取账本和奖励规则表; -// 2. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入; -// 3. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。 +// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录; +// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入; +// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。 func AutoMigrate(db *gorm.DB) error { if db == nil { return fmt.Errorf("tokenstore auto migrate failed: db is nil") @@ -59,6 +61,9 @@ func AutoMigrate(db *gorm.DB) error { ); err != nil { return fmt.Errorf("auto migrate tokenstore tables failed: %w", err) } + if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil { + return err + } return nil } @@ -172,7 +177,7 @@ func defaultTokenRewardRules() []tokenmodel.TokenRewardRule { { Source: tokenmodel.TokenGrantSourceForumImport, Name: "计划被导入奖励", - Amount: 2, + Amount: 5, Status: tokenmodel.TokenRewardRuleStatusActive, }, } diff --git a/backend/services/tokenstore/dao/tokenstore.go b/backend/services/tokenstore/dao/tokenstore.go index 0323645..e8436ae 100644 --- a/backend/services/tokenstore/dao/tokenstore.go +++ b/backend/services/tokenstore/dao/tokenstore.go @@ -64,6 +64,31 @@ func (dao *TokenStoreDAO) ListActiveProducts(ctx context.Context) ([]tokenmodel. return products, err } +// FindRewardRuleBySource 按来源读取社区奖励规则。 +// +// 职责边界: +// 1. 只读取 token_reward_rules,不计算最终发放金额,也不判断停用语义; +// 2. 未找到规则时返回 nil,由服务层决定配置或默认值兜底; +// 3. source 在 DAO 层做一次规范化,避免大小写和空格造成规则漏命中。 +func (dao *TokenStoreDAO) FindRewardRuleBySource(ctx context.Context, source string) (*tokenmodel.TokenRewardRule, error) { + source = strings.ToLower(strings.TrimSpace(source)) + if source == "" { + return nil, nil + } + + var rule tokenmodel.TokenRewardRule + err := dao.db.WithContext(ctx). + Where("source = ?", source). + First(&rule).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &rule, nil +} + func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) { var product tokenmodel.TokenProduct err := dao.db.WithContext(ctx). diff --git a/backend/services/tokenstore/rpc/handler.go b/backend/services/tokenstore/rpc/handler.go index 92835d5..335d77b 100644 --- a/backend/services/tokenstore/rpc/handler.go +++ b/backend/services/tokenstore/rpc/handler.go @@ -171,6 +171,28 @@ func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest }, nil } +// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。 +func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) { + svc, err := h.service() + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingParam) + } + + grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{ + EventID: req.EventId, + ReceiverUserID: req.ReceiverUserId, + Source: req.Source, + SourceRefID: req.SourceRefId, + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil +} + func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse { return &pb.PageResponse{ Page: int32(page.Page), diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go index 659032d..bda3321 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -210,3 +210,22 @@ type ListTokenGrantsResponse struct { func (m *ListTokenGrantsResponse) Reset() { *m = ListTokenGrantsResponse{} } func (m *ListTokenGrantsResponse) String() string { return proto.CompactTextString(m) } func (*ListTokenGrantsResponse) ProtoMessage() {} + +type RecordForumRewardGrantRequest struct { + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + ReceiverUserId uint64 `protobuf:"varint,2,opt,name=receiver_user_id,json=receiverUserId,proto3" json:"receiver_user_id,omitempty"` + Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"` + SourceRefId string `protobuf:"bytes,4,opt,name=source_ref_id,json=sourceRefId,proto3" json:"source_ref_id,omitempty"` +} + +func (m *RecordForumRewardGrantRequest) Reset() { *m = RecordForumRewardGrantRequest{} } +func (m *RecordForumRewardGrantRequest) String() string { return proto.CompactTextString(m) } +func (*RecordForumRewardGrantRequest) ProtoMessage() {} + +type RecordForumRewardGrantResponse struct { + Grant *TokenGrantView `protobuf:"bytes,1,opt,name=grant,proto3" json:"grant,omitempty"` +} + +func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} } +func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) } +func (*RecordForumRewardGrantResponse) ProtoMessage() {} diff --git a/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go index 1c5fd54..d9b02b0 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go @@ -9,13 +9,14 @@ import ( ) const ( - TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary" - TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts" - TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder" - TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders" - TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder" - TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder" - TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants" + TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary" + TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts" + TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder" + TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders" + TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder" + TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder" + TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants" + TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant" ) type TokenStoreServiceClient interface { @@ -26,6 +27,7 @@ type TokenStoreServiceClient interface { GetOrder(ctx context.Context, in *GetTokenOrderRequest, opts ...grpc.CallOption) (*GetTokenOrderResponse, error) MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error) ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error) + RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error) } type tokenStoreServiceClient struct { @@ -64,6 +66,10 @@ func (c *tokenStoreServiceClient) ListGrants(ctx context.Context, in *ListTokenG return invokeTokenStore[ListTokenGrantsResponse](ctx, c.cc, TokenStoreService_ListGrants_FullMethodName, in, opts...) } +func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error) { + return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...) +} + func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) { out := new(Resp) err := cc.Invoke(ctx, fullMethod, in, out, opts...) @@ -81,6 +87,7 @@ type TokenStoreServiceServer interface { GetOrder(context.Context, *GetTokenOrderRequest) (*GetTokenOrderResponse, error) MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error) ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error) + RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error) } type UnimplementedTokenStoreServiceServer struct{} @@ -113,6 +120,10 @@ func (UnimplementedTokenStoreServiceServer) ListGrants(context.Context, *ListTok return nil, status.Errorf(codes.Unimplemented, "method ListGrants not implemented") } +func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented") +} + func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) { s.RegisterService(&TokenStoreService_ServiceDesc, srv) } @@ -165,6 +176,9 @@ var TokenStoreService_ServiceDesc = grpc.ServiceDesc{ tokenStoreUnaryHandler[ListTokenGrantsRequest]("ListGrants", TokenStoreService_ListGrants_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListTokenGrantsRequest) (interface{}, error) { return s.ListGrants(ctx, req) }), + tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) { + return s.RecordForumRewardGrant(ctx, req) + }), }, Streams: []grpc.StreamDesc{}, Metadata: "tokenstore.proto", diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto index 30c3738..9669777 100644 --- a/backend/services/tokenstore/rpc/tokenstore.proto +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -12,6 +12,7 @@ service TokenStoreService { rpc GetOrder(GetTokenOrderRequest) returns (GetTokenOrderResponse); rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse); rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse); + rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse); } message PageResponse { @@ -142,3 +143,14 @@ message ListTokenGrantsResponse { repeated TokenGrantView items = 1; PageResponse page = 2; } + +message RecordForumRewardGrantRequest { + string event_id = 1; + uint64 receiver_user_id = 2; + string source = 3; + string source_ref_id = 4; +} + +message RecordForumRewardGrantResponse { + TokenGrantView grant = 1; +} diff --git a/backend/services/tokenstore/sv/reward.go b/backend/services/tokenstore/sv/reward.go new file mode 100644 index 0000000..51654d6 --- /dev/null +++ b/backend/services/tokenstore/sv/reward.go @@ -0,0 +1,234 @@ +package sv + +import ( + "context" + "errors" + "strconv" + "strings" + + tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" + tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + "github.com/spf13/viper" +) + +const ( + forumLikeRewardConfigKey = "tokenstore.reward.forumLikeAmount" + forumImportRewardConfigKey = "tokenstore.reward.forumImportAmount" + + defaultForumLikeRewardAmount int64 = 1 + defaultForumImportRewardAmount int64 = 5 +) + +type forumRewardGrantRequest struct { + EventID string + ReceiverUserID uint64 + Source string + SourceRefID uint64 +} + +type forumRewardDecision struct { + Amount int64 + Status string + Description string +} + +// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。 +// +// 职责边界: +// 1. 只处理 forum_like / forum_import 两类奖励账本写入,不修改 users,也不调用 user/auth; +// 2. 以 event_id 作为最终幂等边界,重复请求校验一致后返回既有 grant; +// 3. 奖励金额优先读取 token_reward_rules,配置和代码默认值只作为兜底。 +func (s *Service) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) { + if err := s.Ready(); err != nil { + return nil, err + } + + normalized, err := normalizeForumRewardGrantRequest(req) + if err != nil { + return nil, err + } + + // 1. 先按 event_id 回查,命中时直接视为成功,避免 outbox 重试重复写账本。 + // 2. 命中后必须校验用户、来源和来源业务 ID,避免错误复用 event_id 时静默吞掉错账。 + // 3. 校验通过才返回既有 grant,兼容“首次已成功、调用方超时后重试”的常见场景。 + existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID) + if err != nil { + return nil, err + } + if existing != nil { + if err := validateExistingForumRewardGrant(*existing, normalized); err != nil { + return nil, err + } + view := grantViewFromModel(*existing) + return &view, nil + } + + sourceRefID := normalized.SourceRefID + decision, err := s.forumRewardDecision(ctx, normalized.Source) + if err != nil { + return nil, err + } + grant := tokenmodel.TokenGrant{ + EventID: normalized.EventID, + UserID: normalized.ReceiverUserID, + Source: normalized.Source, + SourceLabel: grantSourceLabel(normalized.Source, ""), + SourceRefID: &sourceRefID, + Amount: decision.Amount, + Status: decision.Status, + QuotaApplied: false, + Description: decision.Description, + } + + // 1. 账本写入只依赖 token_grants.event_id 唯一约束兜底并发幂等。 + // 2. 若并发下插入触发唯一键冲突,立刻回查 event_id,把已有 grant 当作成功结果返回。 + // 3. 只有“冲突后仍查不到旧记录”这种异常态才上抛内部错误,避免吞掉真实一致性问题。 + if err := s.tokenDAO.CreateGrant(ctx, &grant); err != nil { + if !isDuplicateKeyError(err) { + return nil, err + } + + existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, errors.New("forum reward grant duplicated but not found by event_id") + } + if err := validateExistingForumRewardGrant(*existing, normalized); err != nil { + return nil, err + } + view := grantViewFromModel(*existing) + return &view, nil + } + + view := grantViewFromModel(grant) + return &view, nil +} + +func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantRequest) (forumRewardGrantRequest, error) { + normalized := forumRewardGrantRequest{ + EventID: strings.TrimSpace(req.EventID), + ReceiverUserID: req.ReceiverUserID, + Source: strings.ToLower(strings.TrimSpace(req.Source)), + } + + switch { + case normalized.EventID == "": + return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空") + case normalized.ReceiverUserID == 0: + return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空") + } + + sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID) + if err != nil { + return forumRewardGrantRequest{}, err + } + normalized.SourceRefID = sourceRefID + + switch normalized.Source { + case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport: + return normalized, nil + default: + return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import") + } +} + +func parseForumRewardSourceRefID(raw string) (uint64, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return 0, tokenStoreBadRequest("source_ref_id 不能为空") + } + + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil || parsed == 0 { + return 0, tokenStoreBadRequest("source_ref_id 必须是正整数") + } + return parsed, nil +} + +// validateExistingForumRewardGrant 校验重复 event_id 是否真的是同一条论坛奖励。 +// +// 职责边界: +// 1. 只比较幂等所需的最小字段:接收人、来源和来源业务 ID; +// 2. 不比较金额和状态,避免规则调整后重放旧事件被误判; +// 3. 不一致时返回业务校验错误,让上游暴露这类错账风险。 +func validateExistingForumRewardGrant(existing tokenmodel.TokenGrant, req forumRewardGrantRequest) error { + sourceRefID := uint64(0) + if existing.SourceRefID != nil { + sourceRefID = *existing.SourceRefID + } + if existing.UserID != req.ReceiverUserID || existing.Source != req.Source || sourceRefID != req.SourceRefID { + return tokenStoreBadRequest("event_id 幂等冲突:已有奖励记录与本次论坛奖励请求不一致") + } + return nil +} + +// forumRewardDecision 解析论坛奖励发放决策。 +// +// 职责边界: +// 1. 优先读取 token_reward_rules,保持“从表里读”的 P0 口径; +// 2. 规则停用或金额非正时写 skipped 账本,消费 outbox 但不增加 Token; +// 3. 表规则缺失时再读取配置和代码默认值,兼容旧环境尚未 seed 的情况。 +func (s *Service) forumRewardDecision(ctx context.Context, source string) (forumRewardDecision, error) { + rule, err := s.tokenDAO.FindRewardRuleBySource(ctx, source) + if err != nil { + return forumRewardDecision{}, err + } + if rule != nil { + if strings.TrimSpace(rule.Status) != tokenmodel.TokenRewardRuleStatusActive { + return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Token"), nil + } + if rule.Amount <= 0 { + return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Token"), nil + } + return recordedForumRewardDecision(source, rule.Amount), nil + } + + switch strings.TrimSpace(source) { + case tokenmodel.TokenGrantSourceForumLike: + return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumLikeRewardConfigKey, defaultForumLikeRewardAmount)), nil + case tokenmodel.TokenGrantSourceForumImport: + return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumImportRewardConfigKey, defaultForumImportRewardAmount)), nil + default: + return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Token"), nil + } +} + +func recordedForumRewardDecision(source string, amount int64) forumRewardDecision { + if amount <= 0 { + return skippedForumRewardDecision(source, "奖励金额非正,未发放 Token") + } + return forumRewardDecision{ + Amount: amount, + Status: tokenmodel.TokenGrantStatusRecorded, + Description: forumRewardDescription(source), + } +} + +func skippedForumRewardDecision(source string, description string) forumRewardDecision { + return forumRewardDecision{ + Amount: 0, + Status: tokenmodel.TokenGrantStatusSkipped, + Description: strings.TrimSpace(description), + } +} + +func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 { + amount := viper.GetInt64(configKey) + if amount <= 0 { + return fallback + } + return amount +} + +func forumRewardDescription(source string) string { + switch strings.TrimSpace(source) { + case tokenmodel.TokenGrantSourceForumLike: + return "计划被点赞奖励" + case tokenmodel.TokenGrantSourceForumImport: + return "计划被导入奖励" + default: + return "论坛奖励入账" + } +} diff --git a/backend/shared/contracts/tokenstore/types.go b/backend/shared/contracts/tokenstore/types.go index 72e90e8..576ce81 100644 --- a/backend/shared/contracts/tokenstore/types.go +++ b/backend/shared/contracts/tokenstore/types.go @@ -100,6 +100,19 @@ type ListTokenGrantsRequest struct { Source string `json:"source"` } +// RecordForumRewardGrantRequest 是论坛奖励入账的内部 RPC 契约。 +// +// 职责边界: +// 1. 只描述一条待记录到 token_grants 的论坛奖励事实; +// 2. 不携带最终奖励金额,金额由 token-store 按 source 和配置解析; +// 3. source_ref_id 使用字符串承接 post_id / import_id,服务层再按当前库表结构落成整数。 +type RecordForumRewardGrantRequest struct { + EventID string `json:"event_id"` + ReceiverUserID uint64 `json:"receiver_user_id"` + Source string `json:"source"` + SourceRefID string `json:"source_ref_id"` +} + // TokenGrantRecord 是 token-store 内部发放出口使用的获取事实。 type TokenGrantRecord struct { EventID string `json:"event_id"` diff --git a/backend/shared/events/forum.go b/backend/shared/events/forum.go new file mode 100644 index 0000000..3dc673f --- /dev/null +++ b/backend/shared/events/forum.go @@ -0,0 +1,128 @@ +package events + +import ( + "errors" + "fmt" + "strings" + "time" +) + +const ( + ForumPostLikedEventType = "forum.post.liked" + ForumPostImportedEventType = "forum.post.imported" + ForumRewardEventVersion = "v1" + + ForumRewardSourceLike = "forum_like" + ForumRewardSourceImport = "forum_import" +) + +// ForumPostRewardPayload 是计划广场作者奖励事件的统一载荷。 +// +// 职责边界: +// 1. 只描述“哪个帖子因什么互动触发了作者奖励”,不直接携带最终 Token 数额; +// 2. source 负责表达奖励来源,真正的奖励规则仍由 token-store 自己解析; +// 3. event_id 必须稳定,供 outbox 重试和下游记账幂等共同使用。 +type ForumPostRewardPayload struct { + EventID string `json:"event_id"` + PostID uint64 `json:"post_id"` + ImportID uint64 `json:"import_id"` + AuthorUserID uint64 `json:"author_user_id"` + ActorUserID uint64 `json:"actor_user_id"` + RewardReceiverUserID uint64 `json:"reward_receiver_user_id"` + Source string `json:"source"` + OccurredAt time.Time `json:"occurred_at"` +} + +func NewForumPostLikedPayload(postID uint64, authorUserID uint64, actorUserID uint64, occurredAt time.Time) ForumPostRewardPayload { + return newForumPostRewardPayload( + ForumPostLikedEventType, + ForumRewardSourceLike, + postID, + 0, + authorUserID, + actorUserID, + occurredAt, + ) +} + +func NewForumPostImportedPayload(postID uint64, importID uint64, authorUserID uint64, actorUserID uint64, occurredAt time.Time) ForumPostRewardPayload { + return newForumPostRewardPayload( + ForumPostImportedEventType, + ForumRewardSourceImport, + postID, + importID, + authorUserID, + actorUserID, + occurredAt, + ) +} + +func newForumPostRewardPayload( + eventType string, + source string, + postID uint64, + importID uint64, + authorUserID uint64, + actorUserID uint64, + occurredAt time.Time, +) ForumPostRewardPayload { + if occurredAt.IsZero() { + occurredAt = time.Now() + } + return ForumPostRewardPayload{ + EventID: ForumRewardEventID(eventType, postID, actorUserID), + PostID: postID, + ImportID: importID, + AuthorUserID: authorUserID, + ActorUserID: actorUserID, + RewardReceiverUserID: authorUserID, + Source: strings.TrimSpace(source), + OccurredAt: occurredAt, + } +} + +func ForumRewardEventID(eventType string, postID uint64, actorUserID uint64) string { + return fmt.Sprintf("%s:%d:%d", strings.TrimSpace(eventType), postID, actorUserID) +} + +// EventType 根据 source 反推出当前奖励事件类型。 +func (p ForumPostRewardPayload) EventType() string { + switch strings.TrimSpace(p.Source) { + case ForumRewardSourceLike: + return ForumPostLikedEventType + case ForumRewardSourceImport: + return ForumPostImportedEventType + default: + return "" + } +} + +func (p ForumPostRewardPayload) MessageKey() string { + return strings.TrimSpace(p.EventID) +} + +func (p ForumPostRewardPayload) AggregateID() string { + return fmt.Sprintf("post:%d", p.PostID) +} + +func (p ForumPostRewardPayload) Validate() error { + if strings.TrimSpace(p.EventID) == "" { + return errors.New("forum reward event_id 不能为空") + } + if strings.TrimSpace(p.EventType()) == "" { + return errors.New("forum reward source 非法") + } + if p.PostID == 0 { + return errors.New("forum reward post_id 不能为空") + } + if p.AuthorUserID == 0 || p.ActorUserID == 0 || p.RewardReceiverUserID == 0 { + return errors.New("forum reward user_id 不能为空") + } + if strings.TrimSpace(p.Source) == ForumRewardSourceImport && p.ImportID == 0 { + return errors.New("forum import reward import_id 不能为空") + } + if p.OccurredAt.IsZero() { + return errors.New("forum reward occurred_at 不能为空") + } + return nil +} From 2204fac84eed35fb68180d4c00dcbd06a98cce08 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 5 May 2026 11:10:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=AE=BA?= =?UTF-8?q?=E5=9D=9B=E8=AF=84=E8=AE=BA=E6=A0=91=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/taskclassforum/main.go | 14 +- backend/services/taskclassforum/dao/cache.go | 146 ++++++++++++++++++ backend/services/taskclassforum/sv/comment.go | 90 ++++++++++- backend/services/taskclassforum/sv/service.go | 38 +++-- .../计划广场与Token商店后端实施方案.md | 17 +- 5 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 backend/services/taskclassforum/dao/cache.go diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go index 8f98b31..ea0f66e 100644 --- a/backend/cmd/taskclassforum/main.go +++ b/backend/cmd/taskclassforum/main.go @@ -6,6 +6,7 @@ import ( "github.com/LoveLosita/smartflow/backend/bootstrap" legacydao "github.com/LoveLosita/smartflow/backend/dao" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" + "github.com/LoveLosita/smartflow/backend/inits" "github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" @@ -32,10 +33,17 @@ func main() { // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) eventPublisher := outboxinfra.NewRepositoryPublisher(outboxinfra.NewRepository(db), viper.GetInt("kafka.maxRetry")) + commentTreeCache := forumsv.CommentTreeCachePort(nil) + if rdb, redisErr := inits.OpenRedisFromConfig(); redisErr != nil { + log.Printf("taskclassforum 评论树缓存已降级关闭,Redis 连接失败: %v", redisErr) + } else { + commentTreeCache = forumdao.NewCommentTreeCache(rdb) + } svc := forumsv.New(forumsv.Options{ - DB: db, - TaskClassPort: taskClassPort, - EventPublisher: eventPublisher, + DB: db, + TaskClassPort: taskClassPort, + EventPublisher: eventPublisher, + CommentTreeCache: commentTreeCache, }) forumrpc.Start(forumrpc.ServerOptions{ ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), diff --git a/backend/services/taskclassforum/dao/cache.go b/backend/services/taskclassforum/dao/cache.go new file mode 100644 index 0000000..736db78 --- /dev/null +++ b/backend/services/taskclassforum/dao/cache.go @@ -0,0 +1,146 @@ +package dao + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "github.com/go-redis/redis/v8" +) + +const commentTreeCacheTTL = 2 * time.Minute + +type commentTreeCachePayload struct { + Items []forumcontracts.ForumCommentNode `json:"items"` + Page forumcontracts.PageResult `json:"page"` +} + +// CommentTreeCache 承载计划广场评论树的 Redis 缓存能力。 +// +// 职责边界: +// 1. 只负责评论树读模型的 JSON 缓存和版本号失效,不读写 MySQL; +// 2. 不计算当前用户是否可删除评论,避免把用户视角写进共享缓存; +// 3. Redis 异常向上返回,由 service 层决定是否降级回源 DB。 +type CommentTreeCache struct { + client *redis.Client + ttl time.Duration +} + +func NewCommentTreeCache(client *redis.Client) *CommentTreeCache { + return &CommentTreeCache{ + client: client, + ttl: commentTreeCacheTTL, + } +} + +func commentTreeVersionKey(postID uint64) string { + return fmt.Sprintf("forum:comments:%d:version", postID) +} + +func commentTreeDataKey(postID uint64, version int64, sort string, page int, pageSize int) string { + return fmt.Sprintf( + "forum:comments:%d:v%d:sort:%s:page:%d:size:%d", + postID, + version, + strings.TrimSpace(sort), + page, + pageSize, + ) +} + +// GetCommentTree 读取指定帖子、排序和分页维度下的评论树缓存。 +// +// 返回值语义: +// 1. hit=true 表示命中缓存,items/page 可直接用于返回前的用户视角补全; +// 2. hit=false 且 error=nil 表示未命中,调用方应回源 DB; +// 3. error 非空表示 Redis 或 JSON 异常,调用方应记录日志并回源 DB。 +func (c *CommentTreeCache) GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) { + if c == nil || c.client == nil { + return nil, forumcontracts.PageResult{}, false, errors.New("评论树缓存未初始化") + } + version, err := c.currentCommentTreeVersion(ctx, postID) + if err != nil { + return nil, forumcontracts.PageResult{}, false, err + } + + raw, err := c.client.Get(ctx, commentTreeDataKey(postID, version, sort, page, pageSize)).Result() + if errors.Is(err, redis.Nil) { + return nil, forumcontracts.PageResult{}, false, nil + } + if err != nil { + return nil, forumcontracts.PageResult{}, false, err + } + + var payload commentTreeCachePayload + if err = json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, forumcontracts.PageResult{}, false, err + } + if payload.Items == nil { + payload.Items = []forumcontracts.ForumCommentNode{} + } + return payload.Items, payload.Page, true, nil +} + +// SetCommentTree 写入指定帖子、排序和分页维度下的评论树缓存。 +// +// 步骤说明: +// 1. 先读取当前版本号,保证写入 key 与后续读取 key 一致; +// 2. 再序列化去个性化后的评论树,避免缓存里带入某个用户的 can_delete; +// 3. 最后写入短 TTL,让版本失效失败时也能靠自然过期兜底。 +func (c *CommentTreeCache) SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error { + if c == nil || c.client == nil { + return errors.New("评论树缓存未初始化") + } + version, err := c.currentCommentTreeVersion(ctx, postID) + if err != nil { + return err + } + + if items == nil { + items = []forumcontracts.ForumCommentNode{} + } + data, err := json.Marshal(commentTreeCachePayload{ + Items: items, + Page: pageResult, + }) + if err != nil { + return err + } + return c.client.Set(ctx, commentTreeDataKey(postID, version, sort, page, pageSize), data, c.ttl).Err() +} + +// BumpCommentTreeVersion 递增帖子评论树版本号,让旧分页缓存自然失效。 +// +// 职责边界: +// 1. 只做版本递增,不扫描删除旧 data key,避免写评论时阻塞 Redis; +// 2. 旧 data key 依赖短 TTL 自动回收; +// 3. 当 version key 不存在时 INCR 会从 1 开始,能够让默认 v0 缓存失效。 +func (c *CommentTreeCache) BumpCommentTreeVersion(ctx context.Context, postID uint64) error { + if c == nil || c.client == nil { + return errors.New("评论树缓存未初始化") + } + return c.client.Incr(ctx, commentTreeVersionKey(postID)).Err() +} + +func (c *CommentTreeCache) currentCommentTreeVersion(ctx context.Context, postID uint64) (int64, error) { + raw, err := c.client.Get(ctx, commentTreeVersionKey(postID)).Result() + if errors.Is(err, redis.Nil) { + return 0, nil + } + if err != nil { + return 0, err + } + version, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, err + } + if version < 0 { + return 0, nil + } + return version, nil +} diff --git a/backend/services/taskclassforum/sv/comment.go b/backend/services/taskclassforum/sv/comment.go index 6f1333b..e12dda5 100644 --- a/backend/services/taskclassforum/sv/comment.go +++ b/backend/services/taskclassforum/sv/comment.go @@ -2,6 +2,7 @@ package sv import ( "context" + "log" "strings" "time" @@ -17,7 +18,7 @@ import ( // 职责边界: // 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端; // 2. 数据库存储仍是扁平 parent_comment_id,树结构由 commenttree 包组装; -// 3. 不做评论缓存,新增、回复、删除后直接读库保持语义简单。 +// 3. 采用 cache-aside 缓存去个性化评论树,返回前再补当前用户的删除权限。 func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) { if err := s.Ready(); err != nil { return nil, forumcontracts.PageResult{}, err @@ -26,10 +27,15 @@ func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID u return nil, forumcontracts.PageResult{}, respond.MissingParam } page, pageSize = normalizePage(page, pageSize) + sortBy = normalizeCommentSort(sortBy) if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil { return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) } + if cachedItems, cachedPage, hit := s.getCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy); hit { + return personalizeCommentNodesForActor(cachedItems, actorUserID), cachedPage, nil + } + total, err := s.forumDAO.CountRootComments(ctx, postID) if err != nil { return nil, forumcontracts.PageResult{}, err @@ -38,15 +44,19 @@ func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID u if err != nil { return nil, forumcontracts.PageResult{}, err } + resultPage := pageResult(page, pageSize, total) if len(roots) == 0 { - return []forumcontracts.ForumCommentNode{}, pageResult(page, pageSize, total), nil + emptyItems := []forumcontracts.ForumCommentNode{} + s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, emptyItems, resultPage) + return emptyItems, resultPage, nil } allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID) if err != nil { return nil, forumcontracts.PageResult{}, err } - nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID) - return nodes, pageResult(page, pageSize, total), nil + sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0) + s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage) + return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil } // CreateComment 创建帖子评论或多层回复。 @@ -100,6 +110,7 @@ func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateFo }); err != nil { return nil, err } + s.bumpCommentTreeVersionBestEffort(req.PostID) return commentModelToNode(created, req.ActorUserID), nil } @@ -113,6 +124,7 @@ func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, comment } var deletedAt *string + var changedPostID uint64 status := forummodel.ForumCommentStatusDeleted if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { comment, err := txDAO.LockCommentByID(ctx, commentID) @@ -133,11 +145,15 @@ func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, comment if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil { return err } + changedPostID = comment.PostID deletedAt = formatTimePtr(&now) return nil }); err != nil { return nil, err } + if changedPostID != 0 { + s.bumpCommentTreeVersionBestEffort(changedPostID) + } return &forumcontracts.DeleteForumCommentResult{ CommentID: commentID, Status: status, @@ -200,3 +216,69 @@ func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumCom collectDescendantCommentIDs(comment.ID, comments, result) } } + +func normalizeCommentSort(sortBy string) string { + if strings.TrimSpace(sortBy) == "latest" { + return "latest" + } + return "oldest" +} + +func (s *Service) getCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool) { + if s == nil || s.commentTreeCache == nil { + return nil, forumcontracts.PageResult{}, false + } + items, resultPage, hit, err := s.commentTreeCache.GetCommentTree(ctx, postID, page, pageSize, sortBy) + if err != nil { + log.Printf("评论树缓存读取失败,已降级回源 DB post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err) + return nil, forumcontracts.PageResult{}, false + } + return items, resultPage, hit +} + +func (s *Service) setCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string, items []forumcontracts.ForumCommentNode, resultPage forumcontracts.PageResult) { + if s == nil || s.commentTreeCache == nil { + return + } + if err := s.commentTreeCache.SetCommentTree(ctx, postID, page, pageSize, sortBy, items, resultPage); err != nil { + log.Printf("评论树缓存写入失败,已保持 DB 结果返回 post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err) + } +} + +func (s *Service) bumpCommentTreeVersionBestEffort(postID uint64) { + if s == nil || s.commentTreeCache == nil || postID == 0 { + return + } + + // 1. 写库事务已经成功,缓存失效不应再反向影响评论发布/删除结果。 + // 2. 使用独立短超时 context,避免客户端取消请求后漏掉版本递增。 + // 3. 失败时只记录日志,旧缓存依靠短 TTL 自然过期作为兜底。 + cacheCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + if err := s.commentTreeCache.BumpCommentTreeVersion(cacheCtx, postID); err != nil { + log.Printf("评论树缓存版本递增失败,等待短 TTL 自然过期 post_id=%d err=%v", postID, err) + } +} + +func personalizeCommentNodesForActor(nodes []forumcontracts.ForumCommentNode, actorUserID uint64) []forumcontracts.ForumCommentNode { + if nodes == nil { + return []forumcontracts.ForumCommentNode{} + } + result := make([]forumcontracts.ForumCommentNode, 0, len(nodes)) + for _, node := range nodes { + result = append(result, personalizeCommentNodeForActor(node, actorUserID)) + } + return result +} + +func personalizeCommentNodeForActor(node forumcontracts.ForumCommentNode, actorUserID uint64) forumcontracts.ForumCommentNode { + children := make([]forumcontracts.ForumCommentNode, 0, len(node.Children)) + for _, child := range node.Children { + children = append(children, personalizeCommentNodeForActor(child, actorUserID)) + } + node.Children = children + node.CanDelete = actorUserID != 0 && + node.Author.UserID == actorUserID && + node.Status == forummodel.ForumCommentStatusVisible + return node +} diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go index b1daa55..3a3d668 100644 --- a/backend/services/taskclassforum/sv/service.go +++ b/backend/services/taskclassforum/sv/service.go @@ -9,6 +9,7 @@ import ( outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" + forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" "gorm.io/gorm" ) @@ -19,6 +20,18 @@ type transactionalEventPublisher interface { PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error } +// CommentTreeCachePort 是计划广场评论树缓存端口。 +// +// 职责边界: +// 1. 只暴露“读分页树、写分页树、递增版本”三个能力,避免 service 依赖 Redis 细节; +// 2. 缓存内容必须是去个性化读模型,不能带入当前用户的 can_delete; +// 3. Redis 异常不应影响主链路,service 层会降级回源 DB。 +type CommentTreeCachePort interface { + GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) + SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error + BumpCommentTreeVersion(ctx context.Context, postID uint64) error +} + // TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。 // // 职责边界: @@ -67,9 +80,10 @@ type CreatedTaskClass struct { // Options 是计划广场服务的依赖注入参数。 type Options struct { - DB *gorm.DB - TaskClassPort TaskClassSnapshotPort - EventPublisher outboxinfra.EventPublisher + DB *gorm.DB + TaskClassPort TaskClassSnapshotPort + EventPublisher outboxinfra.EventPublisher + CommentTreeCache CommentTreeCachePort } // Service 承载计划广场服务内部业务编排。 @@ -79,18 +93,20 @@ type Options struct { // 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response; // 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 type Service struct { - db *gorm.DB - forumDAO *forumdao.ForumDAO - taskClassPort TaskClassSnapshotPort - eventPublisher outboxinfra.EventPublisher + db *gorm.DB + forumDAO *forumdao.ForumDAO + taskClassPort TaskClassSnapshotPort + eventPublisher outboxinfra.EventPublisher + commentTreeCache CommentTreeCachePort } func New(opts Options) *Service { return &Service{ - db: opts.DB, - forumDAO: forumdao.NewForumDAO(opts.DB), - taskClassPort: opts.TaskClassPort, - eventPublisher: opts.EventPublisher, + db: opts.DB, + forumDAO: forumdao.NewForumDAO(opts.DB), + taskClassPort: opts.TaskClassPort, + eventPublisher: opts.EventPublisher, + commentTreeCache: opts.CommentTreeCache, } } diff --git a/docs/backend/计划广场与Token商店后端实施方案.md b/docs/backend/计划广场与Token商店后端实施方案.md index 2d1bd8e..b08c39a 100644 --- a/docs/backend/计划广场与Token商店后端实施方案.md +++ b/docs/backend/计划广场与Token商店后端实施方案.md @@ -794,14 +794,17 @@ Token 侧: ### 10.7 缓存策略 -P0 不引入复杂缓存,优先靠表结构、索引和分页控制复杂度: +P0 不引入复杂缓存,但评论区读多写少,评论树需要接短 TTL 缓存: -1. 评论树 P0 不做整树缓存。评论是强互动数据,新增、回复、删除都会影响树结构,缓存失效成本高;当前场景多数用户看完即切,直接查库并组树更简单。 -2. 评论接口按根评论分页,后端读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论。 -3. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效。 -4. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。 -5. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。 -6. 后续若上 Elasticsearch,只缓存搜索索引,不改变前端接口和论坛业务编排。 +1. 评论树采用 cache-aside + 版本号失效,缓存粒度为 `post_id + sort + page + page_size + version`。版本 key 为 `forum:comments:{post_id}:version`,数据 key 为 `forum:comments:{post_id}:v{version}:sort:{sort}:page:{page}:size:{page_size}`。 +2. 缓存内容是“当前页根评论 + 子孙评论”组装后的去个性化评论树 JSON,并连同分页结果一起缓存;`can_delete` 这类当前用户视角字段不进共享缓存,返回前由 service 按 `actor_user_id` 补齐。 +3. 新增评论、回复或删除评论的 DB 事务成功后,递增 `forum:comments:{post_id}:version`。旧 data key 不扫描删除,依赖短 TTL 自然回收,避免写评论时阻塞 Redis。 +4. Redis 读取、写入或版本递增失败都不影响主链路:读失败直接回源 DB,写失败保持 DB 结果返回,版本递增失败则等待短 TTL 兜底。 +5. 评论接口仍按根评论分页,后端只读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论。 +6. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效。 +7. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。 +8. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。 +9. 后续若上 Elasticsearch,只缓存搜索索引,不改变前端接口和论坛业务编排。 ### 10.8 联调与验收 From 816a29c062eef68bc85ded60da24bdfc8e4cf574 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 5 May 2026 23:31:24 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat=EF=BC=9A=E5=89=8D=E7=AB=AF=E5=81=9A?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E4=BA=9B=E6=94=B9=E5=96=84=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E4=B8=BB=E9=A1=B5demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 5 +- frontend/package.json | 1 + frontend/src/App.vue | 78 +- frontend/src/assets/feature-ai.png | Bin 0 -> 527007 bytes frontend/src/assets/feature-schedule.png | Bin 0 -> 301354 bytes frontend/src/assets/feature-tools.png | Bin 0 -> 316589 bytes frontend/src/assets/hero-dashboard.png | Bin 0 -> 292277 bytes .../src/components/common/MainSidebar.vue | 9 +- .../components/dashboard/TaskQuadrantCard.vue | 5 + .../components/dashboard/TodayTimeline.vue | 76 +- frontend/src/router/index.ts | 29 +- frontend/src/views/DashboardView.vue | 21 +- frontend/src/views/ForumView.vue | 967 ++++++++++++++++++ frontend/src/views/HomeView.vue | 610 +++++++++++ frontend/src/views/PlanDetailView.vue | 868 ++++++++++++++++ frontend/src/views/StoreView.vue | 672 ++++++++++++ 16 files changed, 3281 insertions(+), 60 deletions(-) create mode 100644 frontend/src/assets/feature-ai.png create mode 100644 frontend/src/assets/feature-schedule.png create mode 100644 frontend/src/assets/feature-tools.png create mode 100644 frontend/src/assets/hero-dashboard.png create mode 100644 frontend/src/views/ForumView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/PlanDetailView.vue create mode 100644 frontend/src/views/StoreView.vue diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fa0f51f..6ec8c73 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { - "name": "smartflow-frontend", + "name": "smartmate-frontend", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "smartflow-frontend", + "name": "smartmate-frontend", "version": "0.1.0", "dependencies": { + "@element-plus/icons-vue": "^2.3.0", "@vue/shared": "^3.5.0", "axios": "^1.8.0", "element-plus": "^2.9.0", diff --git a/frontend/package.json b/frontend/package.json index 6cf5a8c..ef38110 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@vue/shared": "^3.5.0", "axios": "^1.8.0", "element-plus": "^2.9.0", + "@element-plus/icons-vue": "^2.3.0", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "pinia": "^2.2.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4b2dcd5..a9a2613 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,16 +1,53 @@ @@ -187,16 +190,9 @@ const renderSlots = computed(() => flex-direction: column; justify-content: space-between; min-height: 140px; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); cursor: default; } -.pastel-item:hover { - transform: scale(1.03) translateY(-4px); - box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.1); - z-index: 10; -} - .item-time { font-size: 12px; font-weight: 800; @@ -266,20 +262,16 @@ const renderSlots = computed(() => animation: pill-shimmer 1.5s infinite linear; } +.skeleton-pill.is-pause { + min-height: 80px; +} + @keyframes pill-shimmer { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } } -/* 动画效果 */ -.grid-pop-enter-active { - transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); -} -.grid-pop-enter-from { - opacity: 0; - transform: scale(0.9); -} @media (max-width: 1200px) { .pastel-grid { grid-template-columns: repeat(4, 1fr); } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b545505..cd87674 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,12 +7,15 @@ import DashboardView from '@/views/DashboardView.vue' import ScheduleView from '@/views/ScheduleView.vue' import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue' +import HomeView from '@/views/HomeView.vue' + const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', - redirect: '/dashboard', + name: 'home', + component: HomeView, }, { path: '/auth', @@ -46,6 +49,30 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/forum', + name: 'forum', + component: () => import('@/views/ForumView.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/forum/:id', + name: 'plan-detail', + component: () => import('@/views/PlanDetailView.vue'), + meta: { + requiresAuth: true, + }, + }, + { + path: '/store', + name: 'store', + component: () => import('@/views/StoreView.vue'), + meta: { + requiresAuth: true, + }, + }, { path: '/debug/tool-card', name: 'debug-tool-card', diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index f4ef3a4..6ef6e45 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..9d4dd37 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/frontend/src/views/PlanDetailView.vue b/frontend/src/views/PlanDetailView.vue new file mode 100644 index 0000000..c68b4b5 --- /dev/null +++ b/frontend/src/views/PlanDetailView.vue @@ -0,0 +1,868 @@ + + + + + diff --git a/frontend/src/views/StoreView.vue b/frontend/src/views/StoreView.vue new file mode 100644 index 0000000..fd03b90 --- /dev/null +++ b/frontend/src/views/StoreView.vue @@ -0,0 +1,672 @@ + + + + +