Version: 0.9.78.dev.260506

This commit is contained in:
Losita
2026-05-06 00:30:08 +08:00
parent 3b6fca44a6
commit 33227e48a7
71 changed files with 13137 additions and 62 deletions

View File

@@ -0,0 +1,214 @@
package sv
import (
"context"
"errors"
"log"
"strings"
"time"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
"gorm.io/gorm"
)
const forumRewardPublishTimeout = 800 * time.Millisecond
type transactionalEventPublisher interface {
PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error
}
// CommentTreeCachePort 是计划广场评论树缓存端口。
//
// 职责边界:
// 1. 只暴露“读分页树、写分页树、递增版本”三个能力,避免 service 依赖 Redis 细节;
// 2. 缓存内容必须是去个性化读模型,不能带入当前用户的 can_delete
// 3. Redis 异常不应影响主链路service 层会降级回源 DB。
type CommentTreeCachePort interface {
GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error)
SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error
BumpCommentTreeVersion(ctx context.Context, postID uint64) error
}
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。
//
// 职责边界:
// 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)
CreateTaskClassFromSnapshot(ctx context.Context, userID uint64, snapshot TaskClassSnapshot, targetTitle string) (*CreatedTaskClass, error)
}
// TaskClassSnapshot 是可分享的 TaskClass 白名单快照。
//
// 注意:这里刻意不包含 embedded_time、schedule 绑定和用户私有排程状态。
type TaskClassSnapshot struct {
TaskClassID uint64
Title string
Mode string
StartDate string
EndDate string
SubjectType string
DifficultyLevel string
CognitiveIntensity string
TotalSlots int
AllowFillerCourse bool
Strategy string
ExcludedSlots []int
ExcludedDaysOfWeek []int
StrategyLabels []string
Items []TaskClassSnapshotItem
ConfigSnapshotJSON string
}
// TaskClassSnapshotItem 是 TaskClassItem 的可分享条目快照。
type TaskClassSnapshotItem struct {
TaskItemID uint64
Order int
Content string
}
// CreatedTaskClass 是导入后创建出的当前用户 TaskClass。
type CreatedTaskClass struct {
TaskClassID uint64
Title string
}
// Options 是计划广场服务的依赖注入参数。
type Options struct {
DB *gorm.DB
TaskClassPort TaskClassSnapshotPort
EventPublisher outboxinfra.EventPublisher
CommentTreeCache CommentTreeCachePort
}
// Service 承载计划广场服务内部业务编排。
//
// 职责边界:
// 1. 负责帖子、模板快照、点赞、评论、导入记录的事务编排;
// 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response
// 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。
type Service struct {
db *gorm.DB
forumDAO *forumdao.ForumDAO
taskClassPort TaskClassSnapshotPort
eventPublisher outboxinfra.EventPublisher
commentTreeCache CommentTreeCachePort
}
func New(opts Options) *Service {
return &Service{
db: opts.DB,
forumDAO: forumdao.NewForumDAO(opts.DB),
taskClassPort: opts.TaskClassPort,
eventPublisher: opts.EventPublisher,
commentTreeCache: opts.CommentTreeCache,
}
}
// Ready 用于第二步骨架阶段的依赖检查。
//
// 后续实现真实用例时,具体方法会做更细的参数校验;这里只先帮助 cmd / 测试快速发现依赖未注入。
func (s *Service) Ready() error {
if s == nil {
return errors.New("taskclassforum service is nil")
}
if s.db == nil {
return errors.New("taskclassforum db is nil")
}
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,
})
}