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,146 @@
package dao
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"github.com/go-redis/redis/v8"
)
const commentTreeCacheTTL = 2 * time.Minute
type commentTreeCachePayload struct {
Items []forumcontracts.ForumCommentNode `json:"items"`
Page forumcontracts.PageResult `json:"page"`
}
// CommentTreeCache 承载计划广场评论树的 Redis 缓存能力。
//
// 职责边界:
// 1. 只负责评论树读模型的 JSON 缓存和版本号失效,不读写 MySQL
// 2. 不计算当前用户是否可删除评论,避免把用户视角写进共享缓存;
// 3. Redis 异常向上返回,由 service 层决定是否降级回源 DB。
type CommentTreeCache struct {
client *redis.Client
ttl time.Duration
}
func NewCommentTreeCache(client *redis.Client) *CommentTreeCache {
return &CommentTreeCache{
client: client,
ttl: commentTreeCacheTTL,
}
}
func commentTreeVersionKey(postID uint64) string {
return fmt.Sprintf("forum:comments:%d:version", postID)
}
func commentTreeDataKey(postID uint64, version int64, sort string, page int, pageSize int) string {
return fmt.Sprintf(
"forum:comments:%d:v%d:sort:%s:page:%d:size:%d",
postID,
version,
strings.TrimSpace(sort),
page,
pageSize,
)
}
// GetCommentTree 读取指定帖子、排序和分页维度下的评论树缓存。
//
// 返回值语义:
// 1. hit=true 表示命中缓存items/page 可直接用于返回前的用户视角补全;
// 2. hit=false 且 error=nil 表示未命中,调用方应回源 DB
// 3. error 非空表示 Redis 或 JSON 异常,调用方应记录日志并回源 DB。
func (c *CommentTreeCache) GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) {
if c == nil || c.client == nil {
return nil, forumcontracts.PageResult{}, false, errors.New("评论树缓存未初始化")
}
version, err := c.currentCommentTreeVersion(ctx, postID)
if err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
raw, err := c.client.Get(ctx, commentTreeDataKey(postID, version, sort, page, pageSize)).Result()
if errors.Is(err, redis.Nil) {
return nil, forumcontracts.PageResult{}, false, nil
}
if err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
var payload commentTreeCachePayload
if err = json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
if payload.Items == nil {
payload.Items = []forumcontracts.ForumCommentNode{}
}
return payload.Items, payload.Page, true, nil
}
// SetCommentTree 写入指定帖子、排序和分页维度下的评论树缓存。
//
// 步骤说明:
// 1. 先读取当前版本号,保证写入 key 与后续读取 key 一致;
// 2. 再序列化去个性化后的评论树,避免缓存里带入某个用户的 can_delete
// 3. 最后写入短 TTL让版本失效失败时也能靠自然过期兜底。
func (c *CommentTreeCache) SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error {
if c == nil || c.client == nil {
return errors.New("评论树缓存未初始化")
}
version, err := c.currentCommentTreeVersion(ctx, postID)
if err != nil {
return err
}
if items == nil {
items = []forumcontracts.ForumCommentNode{}
}
data, err := json.Marshal(commentTreeCachePayload{
Items: items,
Page: pageResult,
})
if err != nil {
return err
}
return c.client.Set(ctx, commentTreeDataKey(postID, version, sort, page, pageSize), data, c.ttl).Err()
}
// BumpCommentTreeVersion 递增帖子评论树版本号,让旧分页缓存自然失效。
//
// 职责边界:
// 1. 只做版本递增,不扫描删除旧 data key避免写评论时阻塞 Redis
// 2. 旧 data key 依赖短 TTL 自动回收;
// 3. 当 version key 不存在时 INCR 会从 1 开始,能够让默认 v0 缓存失效。
func (c *CommentTreeCache) BumpCommentTreeVersion(ctx context.Context, postID uint64) error {
if c == nil || c.client == nil {
return errors.New("评论树缓存未初始化")
}
return c.client.Incr(ctx, commentTreeVersionKey(postID)).Err()
}
func (c *CommentTreeCache) currentCommentTreeVersion(ctx context.Context, postID uint64) (int64, error) {
raw, err := c.client.Get(ctx, commentTreeVersionKey(postID)).Result()
if errors.Is(err, redis.Nil) {
return 0, nil
}
if err != nil {
return 0, err
}
version, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, err
}
if version < 0 {
return 0, nil
}
return version, nil
}

View File

@@ -0,0 +1,70 @@
package dao
import (
"fmt"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// OpenDBFromConfig 创建计划广场服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 forum_* 表和本服务 outbox 表,不迁移 task_classes / task_items避免抢占 task-class 拆分线;
// 2. 不负责装配 legacy TaskClass adapteradapter 在服务实现阶段单独注入;
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
func OpenDBFromConfig() (*gorm.DB, error) {
host := viper.GetString("database.host")
port := viper.GetString("database.port")
user := viper.GetString("database.user")
password := viper.GetString("database.password")
dbname := viper.GetString("database.dbname")
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, password, host, port, dbname,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
if err = AutoMigrate(db); err != nil {
return nil, err
}
return db, nil
}
// AutoMigrate 只迁移计划广场服务拥有的表。
//
// 步骤说明:
// 1. 先创建帖子、模板、条目、点赞、评论、导入记录表;
// 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")
}
if err := db.AutoMigrate(
&forummodel.ForumPost{},
&forummodel.ForumPostTemplate{},
&forummodel.ForumPostTemplateItem{},
&forummodel.ForumLike{},
&forummodel.ForumComment{},
&forummodel.ForumImport{},
); 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

@@ -0,0 +1,466 @@
package dao
import (
"context"
"errors"
"strings"
"time"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ForumDAO 承载计划广场私有表的持久化访问。
//
// 职责边界:
// 1. 只访问 forum_* 表,不直接读写旧 task_classes / task_items
// 2. 只做查询、事务和基础状态更新,不组装前端 DTO
// 3. 业务规则由 sv 层控制DAO 仅提供必要的数据原子操作。
type ForumDAO struct {
db *gorm.DB
}
func NewForumDAO(db *gorm.DB) *ForumDAO {
return &ForumDAO{db: db}
}
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 {
return fn(dao.WithTx(tx))
})
}
type ListPostsQuery struct {
Page int
PageSize int
Sort string
Keyword string
Tag string
}
// CreatePostSnapshot 在同一事务中写帖子、模板和模板条目。
func (dao *ForumDAO) CreatePostSnapshot(ctx context.Context, post *forummodel.ForumPost, template *forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) error {
return dao.Transaction(ctx, func(txDAO *ForumDAO) error {
if err := txDAO.db.Create(post).Error; err != nil {
return err
}
template.PostID = post.ID
if err := txDAO.db.Create(template).Error; err != nil {
return err
}
for i := range items {
items[i].PostID = post.ID
items[i].TemplateID = template.ID
}
if len(items) > 0 {
if err := txDAO.db.Create(&items).Error; err != nil {
return err
}
}
return nil
})
}
func (dao *ForumDAO) FindPostByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumPost, error) {
var post forummodel.ForumPost
err := dao.db.WithContext(ctx).
Where("author_user_id = ? AND idempotency_key = ?", userID, key).
First(&post).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &post, nil
}
func (dao *ForumDAO) ListPosts(ctx context.Context, query ListPostsQuery) ([]forummodel.ForumPost, int64, error) {
db := dao.db.WithContext(ctx).
Model(&forummodel.ForumPost{}).
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished)
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
db = db.Where("title LIKE ? OR summary LIKE ?", like, like)
}
if tag := strings.TrimSpace(query.Tag); tag != "" {
db = db.Where("JSON_CONTAINS(tags_json, JSON_QUOTE(?))", tag)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
orderBy := "created_at DESC"
switch strings.TrimSpace(query.Sort) {
case "likes":
orderBy = "like_count DESC, created_at DESC"
case "imports":
orderBy = "import_count DESC, created_at DESC"
}
var posts []forummodel.ForumPost
err := db.Order(orderBy).
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&posts).Error
if err != nil {
return nil, 0, err
}
return posts, total, nil
}
func (dao *ForumDAO) ListPublishedTagJSONs(ctx context.Context) ([]string, error) {
var rows []struct {
TagsJSON string
}
err := dao.db.WithContext(ctx).
Model(&forummodel.ForumPost{}).
Select("tags_json").
Where("status = ? AND deleted_at IS NULL", forummodel.ForumPostStatusPublished).
Find(&rows).Error
if err != nil {
return nil, err
}
result := make([]string, 0, len(rows))
for _, row := range rows {
result = append(result, row.TagsJSON)
}
return result, nil
}
func (dao *ForumDAO) FindPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
var post forummodel.ForumPost
err := dao.db.WithContext(ctx).
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
First(&post).Error
if err != nil {
return nil, err
}
return &post, nil
}
func (dao *ForumDAO) LockPublishedPost(ctx context.Context, postID uint64) (*forummodel.ForumPost, error) {
var post forummodel.ForumPost
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ? AND deleted_at IS NULL", postID, forummodel.ForumPostStatusPublished).
First(&post).Error
if err != nil {
return nil, err
}
return &post, nil
}
func (dao *ForumDAO) FindTemplateByPostID(ctx context.Context, postID uint64) (*forummodel.ForumPostTemplate, error) {
var template forummodel.ForumPostTemplate
err := dao.db.WithContext(ctx).Where("post_id = ?", postID).First(&template).Error
if err != nil {
return nil, err
}
return &template, nil
}
func (dao *ForumDAO) ListTemplateItemsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumPostTemplateItem, error) {
var items []forummodel.ForumPostTemplateItem
err := dao.db.WithContext(ctx).
Where("post_id = ?", postID).
Order("item_order ASC").
Find(&items).Error
return items, err
}
func (dao *ForumDAO) FindTemplatesByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]forummodel.ForumPostTemplate, error) {
var templates []forummodel.ForumPostTemplate
err := dao.db.WithContext(ctx).Where("post_id IN ?", postIDs).Find(&templates).Error
if err != nil {
return nil, err
}
result := make(map[uint64]forummodel.ForumPostTemplate, len(templates))
for _, template := range templates {
result[template.PostID] = template
}
return result, nil
}
func (dao *ForumDAO) CountTemplateItemsByPostIDs(ctx context.Context, postIDs []uint64) (map[uint64]int, error) {
var rows []struct {
PostID uint64
Count int
}
err := dao.db.WithContext(ctx).
Model(&forummodel.ForumPostTemplateItem{}).
Select("post_id, COUNT(*) AS count").
Where("post_id IN ?", postIDs).
Group("post_id").
Find(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint64]int, len(rows))
for _, row := range rows {
result[row.PostID] = row.Count
}
return result, nil
}
func (dao *ForumDAO) LikedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
var likes []forummodel.ForumLike
err := dao.db.WithContext(ctx).
Select("post_id").
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumLikeStatusActive).
Find(&likes).Error
if err != nil {
return nil, err
}
result := make(map[uint64]bool, len(likes))
for _, like := range likes {
result[like.PostID] = true
}
return result, nil
}
func (dao *ForumDAO) ImportedPostIDSet(ctx context.Context, userID uint64, postIDs []uint64) (map[uint64]bool, error) {
var imports []forummodel.ForumImport
err := dao.db.WithContext(ctx).
Select("post_id").
Where("user_id = ? AND post_id IN ? AND status = ?", userID, postIDs, forummodel.ForumImportStatusImported).
Find(&imports).Error
if err != nil {
return nil, err
}
result := make(map[uint64]bool, len(imports))
for _, item := range imports {
result[item.PostID] = true
}
return result, nil
}
func (dao *ForumDAO) FindLike(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumLike, error) {
var like forummodel.ForumLike
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &like, nil
}
func (dao *ForumDAO) CreateLike(ctx context.Context, like *forummodel.ForumLike) error {
return dao.db.WithContext(ctx).Create(like).Error
}
func (dao *ForumDAO) ActivateLike(ctx context.Context, likeID uint64) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumLike{}).
Where("id = ?", likeID).
Updates(map[string]any{
"status": forummodel.ForumLikeStatusActive,
"canceled_at": nil,
"updated_at": time.Now(),
}).Error
}
func (dao *ForumDAO) CancelLike(ctx context.Context, likeID uint64, now time.Time) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumLike{}).
Where("id = ?", likeID).
Updates(map[string]any{
"status": forummodel.ForumLikeStatusCanceled,
"canceled_at": &now,
"updated_at": now,
}).Error
}
func (dao *ForumDAO) AddPostCounter(ctx context.Context, postID uint64, column string, delta int64) error {
expr := "CASE WHEN " + column + " + ? < 0 THEN 0 ELSE " + column + " + ? END"
return dao.db.WithContext(ctx).
Model(&forummodel.ForumPost{}).
Where("id = ?", postID).
UpdateColumn(column, gorm.Expr(expr, delta, delta)).Error
}
func (dao *ForumDAO) FindCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
var comment forummodel.ForumComment
err := dao.db.WithContext(ctx).Where("id = ?", commentID).First(&comment).Error
if err != nil {
return nil, err
}
return &comment, nil
}
func (dao *ForumDAO) LockCommentByID(ctx context.Context, commentID uint64) (*forummodel.ForumComment, error) {
var comment forummodel.ForumComment
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", commentID).
First(&comment).Error
if err != nil {
return nil, err
}
return &comment, nil
}
func (dao *ForumDAO) FindCommentByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumComment, error) {
var comment forummodel.ForumComment
err := dao.db.WithContext(ctx).
Where("user_id = ? AND idempotency_key = ?", userID, key).
First(&comment).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &comment, nil
}
func (dao *ForumDAO) CreateComment(ctx context.Context, comment *forummodel.ForumComment) error {
return dao.db.WithContext(ctx).Create(comment).Error
}
func (dao *ForumDAO) CountRootComments(ctx context.Context, postID uint64) (int64, error) {
var total int64
err := dao.db.WithContext(ctx).
Model(&forummodel.ForumComment{}).
Where("post_id = ? AND parent_comment_id IS NULL", postID).
Count(&total).Error
return total, err
}
func (dao *ForumDAO) ListRootComments(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forummodel.ForumComment, error) {
orderBy := "created_at ASC"
if strings.TrimSpace(sort) == "latest" {
orderBy = "created_at DESC"
}
var comments []forummodel.ForumComment
err := dao.db.WithContext(ctx).
Where("post_id = ? AND parent_comment_id IS NULL", postID).
Order(orderBy).
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&comments).Error
return comments, err
}
func (dao *ForumDAO) ListCommentsByPostID(ctx context.Context, postID uint64) ([]forummodel.ForumComment, error) {
var comments []forummodel.ForumComment
err := dao.db.WithContext(ctx).
Where("post_id = ?", postID).
Order("created_at ASC").
Find(&comments).Error
return comments, err
}
func (dao *ForumDAO) SoftDeleteComment(ctx context.Context, commentID uint64, now time.Time) error {
tx := dao.db.WithContext(ctx).
Model(&forummodel.ForumComment{}).
Where("id = ? AND status = ?", commentID, forummodel.ForumCommentStatusVisible).
Updates(map[string]any{
"status": forummodel.ForumCommentStatusDeleted,
"deleted_at": &now,
"updated_at": now,
})
return tx.Error
}
func (dao *ForumDAO) FindImport(ctx context.Context, postID uint64, userID uint64) (*forummodel.ForumImport, error) {
var item forummodel.ForumImport
err := dao.db.WithContext(ctx).Where("post_id = ? AND user_id = ?", postID, userID).First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (dao *ForumDAO) FindImportByIdempotencyKey(ctx context.Context, userID uint64, key string) (*forummodel.ForumImport, error) {
var item forummodel.ForumImport
err := dao.db.WithContext(ctx).
Where("user_id = ? AND idempotency_key = ?", userID, key).
First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (dao *ForumDAO) CreateImport(ctx context.Context, item *forummodel.ForumImport) error {
return dao.db.WithContext(ctx).Create(item).Error
}
func (dao *ForumDAO) UpdateImportProcessing(ctx context.Context, importID uint64, title string, now time.Time) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumImport{}).
Where("id = ?", importID).
Updates(map[string]any{
"target_title": title,
"status": forummodel.ForumImportStatusPending,
"last_error": nil,
"updated_at": now,
}).Error
}
func (dao *ForumDAO) FinalizeImport(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, now time.Time) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumImport{}).
Where("id = ?", importID).
Updates(map[string]any{
"new_task_class_id": &newTaskClassID,
"target_title": targetTitle,
"status": forummodel.ForumImportStatusImported,
"last_error": nil,
"updated_at": now,
}).Error
}
func (dao *ForumDAO) MarkImportFailed(ctx context.Context, importID uint64, message string, now time.Time) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumImport{}).
Where("id = ?", importID).
Updates(map[string]any{
"status": forummodel.ForumImportStatusFailed,
"last_error": &message,
"updated_at": now,
}).Error
}
func (dao *ForumDAO) MarkImportFailedAfterTaskClassCreated(ctx context.Context, importID uint64, newTaskClassID uint64, targetTitle string, message string, now time.Time) error {
return dao.db.WithContext(ctx).
Model(&forummodel.ForumImport{}).
Where("id = ?", importID).
Updates(map[string]any{
"new_task_class_id": &newTaskClassID,
"target_title": targetTitle,
"status": forummodel.ForumImportStatusFailed,
"last_error": &message,
"updated_at": now,
}).Error
}