实战项目一:构建RESTful博客API(用户认证、文章CRUD、评论系统)
终于到了实战环节。说实话,前面讲了那么多Ktor的理论和特性,我自己都觉得有点手痒。这一章我们直接上手,从零搭一个完整的博客API。你想想看,用户注册登录、发文章、写评论——这些功能几乎是所有后端项目的标配。
我个人习惯把项目拆成三个核心模块:用户认证、文章管理、评论系统。每个模块独立开发,最后再串起来。这样做的好处是,万一某个模块出了问题,不会牵连整个系统。
项目结构概览
先看看整个项目的目录结构。我一般用Ktor的默认模板,然后按功能分包:
com.blogapi/
├── Application.kt // 入口,配置路由和插件
├── config/
│ └── DatabaseConfig.kt // 数据库连接配置
├── model/
│ ├── User.kt
│ ├── Article.kt
│ └── Comment.kt
├── route/
│ ├── AuthRoutes.kt // 登录注册路由
│ ├── ArticleRoutes.kt // 文章CRUD路由
│ └── CommentRoutes.kt // 评论路由
├── service/
│ ├── AuthService.kt
│ ├── ArticleService.kt
│ └── CommentService.kt
└── repository/
├── UserRepository.kt
├── ArticleRepository.kt
└── CommentRepository.kt
嗯,这里要注意:路由层只做请求解析和响应封装,真正的业务逻辑全放在service层。我在项目中遇到过有人把SQL查询直接写在路由里,后来改需求时简直想哭。
核心数据模型设计
先定义三个数据类。我用的是Exposed ORM,它和Ktor配合得不错:
// User.kt
object Users : Table("users") {
val id = integer("id").autoIncrement()
val username = varchar("username", 50).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val createdAt = datetime("created_at")
override val primaryKey = PrimaryKey(id)
}
// Article.kt
object Articles : Table("articles") {
val id = integer("id").autoIncrement()
val title = varchar("title", 200)
val content = text("content")
val authorId = integer("author_id").references(Users.id)
val createdAt = datetime("created_at")
val updatedAt = datetime("updated_at")
override val primaryKey = PrimaryKey(id)
}
// Comment.kt
object Comments : Table("comments") {
val id = integer("id").autoIncrement()
val content = text("content")
val articleId = integer("article_id").references(Articles.id)
val authorId = integer("author_id").references(Users.id)
val createdAt = datetime("created_at")
override val primaryKey = PrimaryKey(id)
}
为什么把评论和文章分开?我曾经图省事把评论直接存成JSON字段,结果后来要按时间排序、按用户筛选,JSON解析搞得我头皮发麻。所以,一对多关系还是老老实实用外键。
用户认证:JWT + BCrypt
认证这块我选JWT。为什么?无状态,适合API服务。密码存储用BCrypt,别问为什么不用MD5——问就是被脱裤过。
// AuthService.kt
class AuthService(private val db: Database) {
private val bcrypt = BCrypt()
fun register(username: String, password: String): User? {
// 检查用户名是否已存在
val existing = db.query {
Users.selectAll().where { Users.username eq username }.singleOrNull()
}
if (existing != null) return null
val hash = bcrypt.hash(password, BCrypt.SaltCycle(10))
val id = db.query {
Users.insert {
it[Users.username] = username
it[Users.passwordHash] = hash
it[Users.createdAt] = Clock.System.now()
} get Users.id
}
return User(id, username, hash, Clock.System.now())
}
fun login(username: String, password: String): String? {
val user = db.query {
Users.selectAll().where { Users.username eq username }.singleOrNull()
} ?: return null
if (!bcrypt.verify(password, user[Users.passwordHash])) return null
// 生成JWT,有效期24小时
return JWT.create()
.withClaim("userId", user[Users.id])
.withExpiresAt(Date(System.currentTimeMillis() + 86400000))
.sign(Algorithm.HMAC256("your-secret-key"))
}
}
文章CRUD:权限控制是关键
文章接口看起来简单,但权限控制容易漏。比如:只有作者才能修改或删除自己的文章。我在路由里加了个中间件来校验:
// ArticleRoutes.kt
fun Application.articleRoutes() {
routing {
route("/api/articles") {
// 获取所有文章(公开)
get {
val articles = articleService.getAll()
call.respond(articles)
}
// 获取单篇文章(公开)
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
val article = articleService.getById(id)
if (article == null) call.respondText("Not Found", status = HttpStatusCode.NotFound)
else call.respond(article)
}
// 创建文章(需要认证)
post {
val userId = call.getUserIdFromJWT() ?: return@post call.respondText("Unauthorized", status = HttpStatusCode.Unauthorized)
val request = call.receive<CreateArticleRequest>()
val article = articleService.create(userId, request.title, request.content)
call.respond(HttpStatusCode.Created, article)
}
// 更新文章(需要认证 + 作者校验)
put("/{id}") {
val userId = call.getUserIdFromJWT() ?: return@put call.respondText("Unauthorized", status = HttpStatusCode.Unauthorized)
val id = call.parameters["id"]?.toIntOrNull() ?: return@put call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
val request = call.receive<UpdateArticleRequest>()
val updated = articleService.update(id, userId, request.title, request.content)
if (updated == null) call.respondText("Forbidden or Not Found", status = HttpStatusCode.Forbidden)
else call.respond(updated)
}
// 删除文章(需要认证 + 作者校验)
delete("/{id}") {
val userId = call.getUserIdFromJWT() ?: return@delete call.respondText("Unauthorized", status = HttpStatusCode.Unauthorized)
val id = call.parameters["id"]?.toIntOrNull() ?: return@delete call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
val deleted = articleService.delete(id, userId)
if (!deleted) call.respondText("Forbidden or Not Found", status = HttpStatusCode.Forbidden)
else call.respondText("Deleted")
}
}
}
}
getUserIdFromJWT()是我自定义的扩展函数,实际项目中建议用Ktor的Authentication插件统一管理。我早期手写过JWT解析,后来发现官方插件更稳定。
评论系统:嵌套还是扁平?
评论系统有个经典问题:要不要支持嵌套回复?我这次选择扁平结构,每条评论只关联文章,不关联父评论。为什么?
- 扁平结构查询简单,一次SQL就能拿到所有评论
- 前端排序和展示更灵活
- 避免递归查询带来的性能坑
当然,如果你需要楼中楼效果,可以加一个parentId字段。我在一个社区项目里做过嵌套评论,深度限制在3层,再深就折叠了——不然数据库要炸。
// CommentService.kt
class CommentService(private val db: Database) {
fun getByArticle(articleId: Int): List<Comment> {
return db.query {
Comments.selectAll()
.where { Comments.articleId eq articleId }
.orderBy(Comments.createdAt, SortOrder.ASC)
.map { it.toComment() }
}
}
fun create(articleId: Int, authorId: Int, content: String): Comment? {
// 先检查文章是否存在
val articleExists = db.query {
Articles.selectAll().where { Articles.id eq articleId }.singleOrNull()
} ?: return null
val id = db.query {
Comments.insert {
it[Comments.articleId] = articleId
it[Comments.authorId] = authorId
it[Comments.content] = content
it[Comments.createdAt] = Clock.System.now()
} get Comments.id
}
return Comment(id, content, articleId, authorId, Clock.System.now())
}
}
整体架构图
下面这张图展示了整个博客API的请求流转过程。我画的时候特意把认证中间件单独拎出来,因为它是所有受保护接口的守门员:
避坑指南
最后分享几个我踩过的坑:
- 密码哈希别偷懒:我曾经用SHA-256直接存密码,结果被朋友骂了一顿。BCrypt虽然慢一点,但安全第一。
- 事务别忘了:创建文章和初始化评论数,这两个操作应该在一个事务里。我早期分开写,结果有一次文章写进去了,评论计数器没更新,数据对不上了。
- 接口版本号:建议从第一天就加
/api/v1/前缀。我有个项目没加版本号,后来改接口结构时,老客户端全挂了。 - 日志要打全:每个接口的入参和出参都打日志。排查问题时你会发现,日志就是你的救命稻草。
核心要点回顾:
- 项目按功能分层:路由 → 服务 → 仓库,职责清晰
- JWT + BCrypt 是认证标配,别自己造轮子
- 权限校验在路由层做,业务逻辑在Service层做
- 评论系统优先选扁平结构,除非有明确嵌套需求
- 事务、日志、版本号——这些细节决定项目质量
好了,这一章的内容就到这里。代码都在上面了,你可以直接照着搭。如果遇到问题,多半是依赖版本没对齐——Ktor和Exposed的版本兼容性有时候挺坑的。