实战项目一:构建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"))
    }
}
小提示:JWT的密钥千万别硬编码在代码里。我习惯用环境变量或者配置文件加载。曾经有个项目把密钥提交到了Git仓库,第二天就被安全团队约谈了。

文章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的请求流转过程。我画的时候特意把认证中间件单独拎出来,因为它是所有受保护接口的守门员:

客户端 路由层 JWT 认证 Service 层 Repository 层 PostgreSQL HTTP 请求 需认证接口 SQL 查询 响应返回 图例 外部请求 路由分发 认证拦截 业务逻辑 数据访问 数据库

避坑指南

最后分享几个我踩过的坑:

  • 密码哈希别偷懒:我曾经用SHA-256直接存密码,结果被朋友骂了一顿。BCrypt虽然慢一点,但安全第一。
  • 事务别忘了:创建文章和初始化评论数,这两个操作应该在一个事务里。我早期分开写,结果有一次文章写进去了,评论计数器没更新,数据对不上了。
  • 接口版本号:建议从第一天就加/api/v1/前缀。我有个项目没加版本号,后来改接口结构时,老客户端全挂了。
  • 日志要打全:每个接口的入参和出参都打日志。排查问题时你会发现,日志就是你的救命稻草。

核心要点回顾:

  • 项目按功能分层:路由 → 服务 → 仓库,职责清晰
  • JWT + BCrypt 是认证标配,别自己造轮子
  • 权限校验在路由层做,业务逻辑在Service层做
  • 评论系统优先选扁平结构,除非有明确嵌套需求
  • 事务、日志、版本号——这些细节决定项目质量

好了,这一章的内容就到这里。代码都在上面了,你可以直接照着搭。如果遇到问题,多半是依赖版本没对齐——Ktor和Exposed的版本兼容性有时候挺坑的。

公众号:蓝海资料掘金营,微信deep3321