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,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 adapteradapter 在服务实现阶段单独注入;
// 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
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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).

View File

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

View File

@@ -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() {}

View File

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

View File

@@ -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;
}

View File

@@ -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 "论坛奖励入账"
}
}