Skip to content

内容模块

上一篇你深入了数据访问层(SQLSharp 的视图、whereIf、DB-First 代码生成)。这一篇把博客最核心的业务——文章、评论、收藏、关注、分类和标签——串起来,看它们在分层架构里是怎么落地的。

这个模块管什么

内容模块由 BlogController 统一入口,覆盖五组能力:

能力接口读/写
文章列表、详情、创建、更新、删除、浏览量读+写
评论列表、创建、删除读+写
收藏切换状态、检查状态
关注切换状态、检查状态
分类与标签获取全部

这五组能力各有特点,下面逐组看实现。

文章:最完整的读写案例

文章模块是所有能力里最完整的——既有复杂的分页筛选,又有完整的增删改,还带浏览量这种简单更新。

读:文章列表

文章列表是动态筛选的典型场景。用户可能按分类筛、按标签搜、按关键词搜,也可能什么都不传。这种多变组合不适合塞进仓储,所以直接在 Queries 层做:

cangjie
// BlogQueries.cj
public func getBlogPostList(model: BlogPostSearchModel): PageResult<VBlogPost> {
    var (list, total) = context
        .vBlogPosts                          // 查视图,已 join 好 author + category + 统计
        .params(model)
        .whereIf("is_public = 1")            // 公开列表只查已发布文章
        .whereIf("author_id = @authorId", model.authorId.isSome())
        .whereIf("POSITION(@search IN title) > 0 OR POSITION(@search IN summary) > 0",
            model.search.isSome())
        .whereIf("id IN (SELECT pt.post_id FROM post_tag pt JOIN tag t ON t.id = pt.tag_id WHERE t.name = @tagName)",
            model.tagName.isSome())
        .whereIf("category_code = @categoryCode", model.categoryCode.isSome())
        .toPageResult(model)                 // 自动处理排序 + 分页

    return this.data(list, total)
}

几个要点:

  • whereIf 的第二个参数决定这个条件是否生效。比如 model.search.isSome() 为 false 时,搜索条件直接跳过
  • toPageResult(model) 会从 model.orders 里读排序字段,经过白名单校验后拼到 SQL 里
  • 查的是 vBlogPosts 视图而非 blogPosts 表,视图已经把 author_usernamecategory_labelstars 计数做好了

读:文章详情

详情查询就简单了——按 id 或 slug 查单条:

cangjie
// BlogQueries.cj
public func getBlogPostDetail(id: Int64): VBlogPost {
    return context
        .vBlogPosts
        .whereIf("id = ${id}")              // 固定条件不需要 params
        .firstOrDefault()
        .getOrThrow { KnowException("文章不存在") }
}

public func getBlogPostBySlug(slug: String): VBlogPost {
    let param = DbParameters()
    param.add("slug", slug)
    return context
        .vBlogPosts
        .params(param)
        .whereIf("slug = @slug")            // 变量条件通过 @参数 绑定
        .firstOrDefault()
        .getOrThrow { KnowException("文章不存在") }
}

注意 id = ${id}slug = @slug 两种写法的区别:

  • ${id} 是直接拼值进 SQL(id 是整数,无注入风险)
  • @slug 是参数化查询(slug 是字符串,防注入)

写:创建文章

写操作走 Mediator。一个创建文章的 Command 长这样:

cangjie
// BlogCommands.cj
@Serialization[superType: BlogPost]
public class CreateBlogPostCommand <: IRequest<Unit> {}
// 字段从 BlogPost 继承,不需要重复声明

@Serialization[superType: BlogPost] 告诉序列化器:这个 Command 的字段结构跟 BlogPost 一样,自动映射。

Handler 负责真正的业务逻辑:

cangjie
public class CreateBlogPostCommandHandler <: IRequestHandler<CreateBlogPostCommand, Unit> {
    public CreateBlogPostCommandHandler(let context: AdminDbContext, let userAccessor: UserAccessor) {}

    public func handle(request: CreateBlogPostCommand): Unit {
        let loginUser = userAccessor.getLoginUser()               // ① 获取当前用户
        let entity = MapperUtilities.map<BlogPost>(request)       // ② 从请求映射实体
        entity.slug = generateSlug(context, request.title)        // ③ 生成唯一 slug
        entity.createdAt = DateTime.now()
        entity.updatedAt = DateTime.now()
        entity.authorId = loginUser.id                            // ④ 绑定作者
        context.blogPosts.add(entity)
        context.saveChanges()
    }
}

Handler 做的事情是一串明确的步骤:拿用户 → 映射字段 → 补充系统字段 → 写入 → 保存

写:更新和删除

更新时必须校验作者身份——你只能改自己的文章:

cangjie
public class UpdateBlogPostCommandHandler <: IRequestHandler<UpdateBlogPostCommand, Unit> {
    public func handle(request: UpdateBlogPostCommand): Unit {
        let loginUser = userAccessor.getLoginUser()
        let entity = context.blogPosts.find(request.id)
            .getOrThrow { KnowException("文章不存在") }

        if (entity.authorId != loginUser.id) {
            throw KnowException("无权编辑此文章")     // 权限校验
        }

        entity.title = request.title
        entity.summary = request.summary
        entity.content = request.content
        entity.updatedAt = DateTime.now()

        context.blogPosts.update(entity)
        context.saveChanges()
    }
}

删除时除了校验作者,还要级联清理关联数据——文章删了,它的标签关联、收藏、评论也要一起删:

cangjie
public class DeleteBlogPostCommandHandler <: IRequestHandler<DeleteBlogPostCommand, Unit> {
    public func handle(request: DeleteBlogPostCommand): Unit {
        let loginUser = userAccessor.getLoginUser()
        let post = context.blogPosts.find(request.id)
            .getOrThrow { KnowException("文章不存在") }

        if (post.authorId != loginUser.id) {
            throw KnowException("无权删除此文章")
        }

        // 级联清理
        context.postTags.whereIf("post_id = ${request.id}").toList().forEach { pt => context.postTags.remove(pt) }
        context.stars.whereIf("post_id = ${request.id}").toList().forEach { s => context.stars.remove(s) }
        context.comments.whereIf("post_id = ${request.id}").toList().forEach { c => context.comments.remove(c) }

        // 删除文章本身
        let entity = BlogPost()
        entity.id = request.id
        context.blogPosts.remove(entity)
        context.saveChanges()
    }
}

写:浏览量

浏览量是一个不需要走 Mediator 的简单写操作——没有权限校验,也不需要事务包裹。直接走 Service:

cangjie
// BlogService.cj
public func incrementView(postId: Int64): Unit {
    let param = DbParameters()
    param.add("postId", postId)
    context.execute("UPDATE blog_post SET views = views + 1 WHERE id = @postId", param)
}

评论:嵌套结构

评论支持两层结构——直接评论文章 + 回复别人的评论。

cangjie
// CreateCommentCommandHandler 的核心逻辑
public func handle(request: CreateCommentCommand): Unit {
    // ① 校验文章存在
    context.blogPosts.find(request.postId)
        .getOrThrow { KnowException("文章不存在") }

    // ② 如果有 parentId,校验父评论存在
    if (let Some(parentId) <- request.parentId) {
        context.comments.find(parentId)
            .getOrThrow { KnowException("父评论不存在") }
    }

    // ③ 创建评论
    let loginUser = userAccessor.getLoginUser()
    let entity = MapperUtilities.map<Comment>(request)
    entity.createdAt = DateTime.now()
    entity.authorId = loginUser.id
    context.comments.add(entity)
    context.saveChanges()
}

删除评论时先删子回复,再删评论本身:

cangjie
// 先删所有回复
context.comments.whereIf("parent_id = ${request.id}").toList().forEach {
    reply => context.comments.remove(reply)
}
// 再删评论
let entity = Comment()
entity.id = request.id
context.comments.remove(entity)
context.saveChanges()

收藏和关注:切换式接口

收藏和关注用了同一种模式——切换(Toggle):调一次是收藏,再调一次是取消。

cangjie
// ToggleStarCommandHandler — 收藏/取消收藏
public func handle(request: ToggleStarCommand): Bool {
    let loginUser = userAccessor.getLoginUser()

    let param = DbParameters()
    param.add("userId", loginUser.id)
    param.add("postId", request.postId)

    // 查是否已收藏
    if (let Some(exists) <- context.stars
        .params(param)
        .whereIf("user_id = @userId AND post_id = @postId")
        .firstOrDefault()) {
        // 已收藏 → 取消
        context.stars.remove(exists)
        context.saveChanges()
        return false
    }

    // 未收藏 → 收藏
    let star = Star()
    star.userId = loginUser.id
    star.postId = request.postId
    star.createdAt = DateTime.now()
    context.stars.add(star)
    context.saveChanges()
    return true    // 返回 true 表示当前是已收藏状态
}

关注(ToggleFollow)的逻辑完全一样,只是操作的实体不同(Follow 替换 Star)。

分类和标签

这两个是纯读取的字典数据,实现最简单:

cangjie
// BlogQueries.cj
public func getTags(): ArrayList<Tag> {
    return context.tags.toList()
}

public func getCategories(): ArrayList<Category> {
    return context.categories.toList()
}

虽然实现简单,但在文章列表筛选里确实在用——whereIf("category_code = @categoryCode", ...) 和标签子查询。它们是列表筛选接口的数据源。

要点回顾

场景走什么路径为什么
动态筛选列表Queries → DbContext 视图条件组合多变,不适合固定接口
单条详情Queries → DbContext 视图简单查询,不需要命令管道
创建/更新/删除Mediator → CommandHandler需要事务、需要权限校验
简单更新(浏览量)Controller → Service无需事务,直接执行 SQL
切换状态(收藏/关注)Mediator → CommandHandler需要事务保护"查+写"