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

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

View File

@@ -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_grantsRPC 临时失败返回 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)
}

View File

@@ -171,6 +171,8 @@ func OutboxServiceNames() []string {
string(outboxHandlerServiceMemory),
string(outboxHandlerServiceActiveScheduler),
string(outboxHandlerServiceNotification),
string(outboxHandlerServiceTaskClassForum),
string(outboxHandlerServiceTokenStore),
}
}

View File

@@ -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 注册动作”。