feat: 接入论坛奖励 outbox 链路
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user