feat: 接入论坛奖励 outbox 链路

This commit is contained in:
Losita
2026-05-05 10:44:33 +08:00
parent 4fc6c0cac3
commit c42f0c5b8c
31 changed files with 1381 additions and 101 deletions

View File

@@ -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,
})
}