feat: 接入论坛奖励 outbox 链路
This commit is contained in:
@@ -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