内容模块
上一篇你深入了数据访问层(SQLSharp 的视图、whereIf、DB-First 代码生成)。这一篇把博客最核心的业务——文章、评论、收藏、关注、分类和标签——串起来,看它们在分层架构里是怎么落地的。
这个模块管什么
内容模块由 BlogController 统一入口,覆盖五组能力:
| 能力 | 接口 | 读/写 |
|---|---|---|
| 文章 | 列表、详情、创建、更新、删除、浏览量 | 读+写 |
| 评论 | 列表、创建、删除 | 读+写 |
| 收藏 | 切换状态、检查状态 | 写 |
| 关注 | 切换状态、检查状态 | 写 |
| 分类与标签 | 获取全部 | 读 |
这五组能力各有特点,下面逐组看实现。
文章:最完整的读写案例
文章模块是所有能力里最完整的——既有复杂的分页筛选,又有完整的增删改,还带浏览量这种简单更新。
读:文章列表
文章列表是动态筛选的典型场景。用户可能按分类筛、按标签搜、按关键词搜,也可能什么都不传。这种多变组合不适合塞进仓储,所以直接在 Queries 层做:
// 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_username、category_label、stars计数做好了
读:文章详情
详情查询就简单了——按 id 或 slug 查单条:
// 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 长这样:
// BlogCommands.cj
@Serialization[superType: BlogPost]
public class CreateBlogPostCommand <: IRequest<Unit> {}
// 字段从 BlogPost 继承,不需要重复声明@Serialization[superType: BlogPost] 告诉序列化器:这个 Command 的字段结构跟 BlogPost 一样,自动映射。
Handler 负责真正的业务逻辑:
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 做的事情是一串明确的步骤:拿用户 → 映射字段 → 补充系统字段 → 写入 → 保存。
写:更新和删除
更新时必须校验作者身份——你只能改自己的文章:
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()
}
}删除时除了校验作者,还要级联清理关联数据——文章删了,它的标签关联、收藏、评论也要一起删:
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:
// 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)
}评论:嵌套结构
评论支持两层结构——直接评论文章 + 回复别人的评论。
// 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()
}删除评论时先删子回复,再删评论本身:
// 先删所有回复
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):调一次是收藏,再调一次是取消。
// 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)。
分类和标签
这两个是纯读取的字典数据,实现最简单:
// 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 | 需要事务保护"查+写" |