Skip to content

新模块开发指南

本指南将带你一步步完成一个新模块的开发,从需求分析到测试验证。

开发流程

1. 需求分析 ➡️ 2. 数据建模 ➡️ 3. Repository 层 
    ⬇️
8. 测试验证 ⬅️ 7. 路由配置 ⬅️ 6. Controller 层 ⬅️ 5. Service 层 ⬅️ 4. 创建 Schema/Model

实战案例:文章管理模块

让我们通过一个完整的示例来学习如何开发新模块。

Step 1: 需求分析

假设我们要开发一个文章管理模块,需求如下:

功能需求:

  • ✅ 文章的增删改查
  • ✅ 文章分类管理
  • ✅ 文章标签管理
  • ✅ 文章状态(草稿、已发布、已下架)
  • ✅ 支持富文本编辑
  • ✅ 支持封面图片
  • ✅ 文章浏览统计
  • ✅ 关联作者信息

数据字段:

  • 标题、内容、摘要
  • 分类、标签
  • 作者、状态
  • 浏览数、点赞数
  • 发布时间、更新时间

Step 2: 数据建模

MongoDB Schema

javascript
// server/app/model/article.js
module.exports = app => {
  const mongoose = app.mongoose
  const Schema = mongoose.Schema
  
  const ArticleSchema = new Schema({
    // 基本信息
    title: { 
      type: String, 
      required: true,
      index: true 
    },
    content: { 
      type: String, 
      required: true 
    },
    summary: { 
      type: String,
      maxlength: 500 
    },
    coverImage: String,
    
    // 分类和标签
    category: { 
      type: Schema.Types.ObjectId, 
      ref: 'Category',
      required: true 
    },
    tags: [{ 
      type: Schema.Types.ObjectId, 
      ref: 'Tag' 
    }],
    
    // 作者信息
    author: { 
      type: Schema.Types.ObjectId, 
      ref: 'User',
      required: true 
    },
    
    // 状态
    status: { 
      type: String,
      enum: ['draft', 'published', 'archived'],
      default: 'draft',
      index: true
    },
    
    // 统计信息
    viewCount: { 
      type: Number, 
      default: 0 
    },
    likeCount: { 
      type: Number, 
      default: 0 
    },
    commentCount: { 
      type: Number, 
      default: 0 
    },
    
    // 时间信息
    publishedAt: Date,
    createdAt: { 
      type: Date, 
      default: Date.now,
      index: true 
    },
    updatedAt: { 
      type: Date, 
      default: Date.now 
    }
  })
  
  // 索引
  ArticleSchema.index({ title: 'text', content: 'text', summary: 'text' })
  ArticleSchema.index({ author: 1, status: 1, createdAt: -1 })
  
  return mongoose.model('Article', ArticleSchema)
}

MariaDB Model

javascript
// server/app/model/article.js
module.exports = app => {
  const { STRING, TEXT, INTEGER, DATE, ENUM } = app.Sequelize
  
  const Article = app.model.define('article', {
    id: { 
      type: STRING(24), 
      primaryKey: true 
    },
    
    // 基本信息
    title: { 
      type: STRING(200), 
      allowNull: false,
      comment: '文章标题'
    },
    content: { 
      type: TEXT('long'), 
      allowNull: false,
      comment: '文章内容'
    },
    summary: { 
      type: STRING(500),
      comment: '文章摘要'
    },
    coverImage: { 
      type: STRING(500),
      comment: '封面图片'
    },
    
    // 关联 ID
    categoryId: { 
      type: STRING(24),
      allowNull: false,
      comment: '分类 ID'
    },
    authorId: { 
      type: STRING(24),
      allowNull: false,
      comment: '作者 ID'
    },
    
    // 状态
    status: { 
      type: ENUM('draft', 'published', 'archived'),
      defaultValue: 'draft',
      comment: '状态'
    },
    
    // 统计信息
    viewCount: { 
      type: INTEGER, 
      defaultValue: 0,
      comment: '浏览数'
    },
    likeCount: { 
      type: INTEGER, 
      defaultValue: 0,
      comment: '点赞数'
    },
    commentCount: { 
      type: INTEGER, 
      defaultValue: 0,
      comment: '评论数'
    },
    
    // 时间信息
    publishedAt: { 
      type: DATE,
      comment: '发布时间'
    },
    createdAt: { 
      type: DATE, 
      defaultValue: Date.now 
    },
    updatedAt: { 
      type: DATE, 
      defaultValue: Date.now 
    }
  }, {
    indexes: [
      { fields: ['status'] },
      { fields: ['authorId', 'status', 'createdAt'] }
    ]
  })
  
  // 定义关联
  Article.associate = function() {
    app.model.Article.belongsTo(app.model.Category, {
      foreignKey: 'categoryId',
      as: 'category'
    })
    app.model.Article.belongsTo(app.model.User, {
      foreignKey: 'authorId',
      as: 'author'
    })
    app.model.Article.belongsToMany(app.model.Tag, {
      through: 'ArticleTag',
      foreignKey: 'articleId',
      otherKey: 'tagId',
      as: 'tags'
    })
  }
  
  return Article
}

Step 3: MongoDB Repository

javascript
// server/app/repository/ArticleMongoRepository.js
const BaseMongoRepository = require('../../lib/repository/BaseMongoRepository')

class ArticleMongoRepository extends BaseMongoRepository {
  constructor(app) {
    super(app, app.model.Article)
  }
  
  /**
   * 配置钩子:默认关联查询
   */
  _getDefaultPopulate() {
    return ['author', 'category', 'tags']
  }
  
  /**
   * 配置钩子:默认排序
   */
  _getDefaultSort() {
    return { publishedAt: -1, createdAt: -1 }
  }
  
  /**
   * 配置钩子:默认搜索字段
   */
  _getDefaultSearchKeys() {
    return ['title', 'summary', 'content']
  }
  
  /**
   * 配置钩子:状态映射
   */
  _getStatusMapping() {
    return {
      field: 'status',
      values: {
        draft: 'draft',
        published: 'published',
        archived: 'archived'
      }
    }
  }
  
  /**
   * 数据处理钩子:处理单条数据
   */
  _customProcessDataItem(article) {
    if (article) {
      // 格式化日期
      if (article.publishedAt) {
        article.publishedAtFormatted = this.app.helper.formatDate(article.publishedAt)
      }
      
      // 生成摘要(如果没有)
      if (!article.summary && article.content) {
        article.summary = article.content.substring(0, 200) + '...'
      }
    }
    return article
  }
  
  /**
   * 业务方法:查询已发布文章
   */
  async findPublished(params = {}) {
    return await this.findMany({
      ...params,
      filters: {
        ...params.filters,
        status: 'published',
        publishedAt: { $lte: new Date() }
      }
    })
  }
  
  /**
   * 业务方法:查询草稿
   */
  async findDrafts(authorId, params = {}) {
    return await this.findMany({
      ...params,
      filters: {
        ...params.filters,
        author: authorId,
        status: 'draft'
      }
    })
  }
  
  /**
   * 业务方法:查询热门文章
   */
  async findHot(limit = 10) {
    return await this.findMany({
      filters: { status: 'published' },
      sort: { viewCount: -1, likeCount: -1 },
      pagination: { limit }
    })
  }
  
  /**
   * 业务方法:查询相关文章
   */
  async findRelated(articleId, limit = 5) {
    const article = await this.findById(articleId)
    if (!article) return []
    
    return await this.findMany({
      filters: {
        _id: { $ne: articleId },
        status: 'published',
        $or: [
          { category: article.category },
          { tags: { $in: article.tags } }
        ]
      },
      pagination: { limit }
    })
  }
  
  /**
   * 业务方法:增加浏览数
   */
  async incrementView(articleId) {
    return await this.updateById(articleId, {
      $inc: { viewCount: 1 }
    })
  }
  
  /**
   * 业务方法:发布文章
   */
  async publish(articleId) {
    return await this.updateById(articleId, {
      status: 'published',
      publishedAt: new Date()
    })
  }
  
  /**
   * 业务方法:归档文章
   */
  async archive(articleId) {
    return await this.updateById(articleId, {
      status: 'archived'
    })
  }
  
  /**
   * 业务方法:检查标题唯一性
   */
  async checkTitleUnique(title, excludeId) {
    const article = await this.findOne({
      filters: {
        title,
        _id: { $ne: excludeId }
      }
    })
    return !article
  }
}

module.exports = ArticleMongoRepository

Step 4: MariaDB Repository

javascript
// server/app/repository/ArticleMariaRepository.js
const BaseMariaRepository = require('../../lib/repository/BaseMariaRepository')

class ArticleMariaRepository extends BaseMariaRepository {
  constructor(app) {
    super(app, app.model.Article)
  }
  
  /**
   * 配置钩子:默认关联查询
   */
  _getDefaultPopulate() {
    return ['author', 'category', 'tags']
  }
  
  /**
   * 配置钩子:默认排序
   */
  _getDefaultSort() {
    return { publishedAt: -1, createdAt: -1 }
  }
  
  /**
   * 配置钩子:有效字段
   */
  _getValidTableFields() {
    return [
      'id', 'title', 'content', 'summary', 'coverImage',
      'categoryId', 'authorId', 'status',
      'viewCount', 'likeCount', 'commentCount',
      'publishedAt', 'createdAt', 'updatedAt'
    ]
  }
  
  /**
   * 数据处理钩子
   */
  _customProcessDataItem(article) {
    if (article) {
      if (article.publishedAt) {
        article.publishedAtFormatted = this.app.helper.formatDate(article.publishedAt)
      }
      if (!article.summary && article.content) {
        article.summary = article.content.substring(0, 200) + '...'
      }
    }
    return article
  }
  
  /**
   * 业务方法:查询已发布文章
   */
  async findPublished(params = {}) {
    const { Op } = this.app.Sequelize
    return await this.findMany({
      ...params,
      filters: {
        ...params.filters,
        status: 'published',
        publishedAt: { [Op.lte]: new Date() }
      }
    })
  }
  
  /**
   * 业务方法:增加浏览数
   */
  async incrementView(articleId) {
    const article = await this.model.findByPk(articleId)
    if (article) {
      article.viewCount += 1
      await article.save()
    }
    return article
  }
  
  /**
   * 业务方法:发布文章
   */
  async publish(articleId) {
    return await this.updateById(articleId, {
      status: 'published',
      publishedAt: new Date()
    })
  }
  
  // ... 其他方法与 Mongo 版本类似
}

module.exports = ArticleMariaRepository

Step 5: Service 层

javascript
// server/app/service/article.js
const { Service } = require('egg')

class ArticleService extends Service {
  /**
   * 获取文章列表
   */
  async getArticleList({ page, limit, status, categoryId, keyword }) {
    const filters = {}
    
    // 状态过滤
    if (status) filters.status = status
    
    // 分类过滤
    if (categoryId) filters.category = categoryId
    
    return await this.ctx.repo.article.findByPage({
      filters,
      search: keyword ? { keyword, fields: ['title', 'summary'] } : undefined,
      populate: ['author', 'category', 'tags'],
      sort: { createdAt: -1 },
      pagination: { page, limit }
    })
  }
  
  /**
   * 获取文章详情
   */
  async getArticleDetail(id) {
    // 查询文章
    const article = await this.ctx.repo.article.findById(id, {
      populate: ['author', 'category', 'tags']
    })
    
    if (!article) {
      throw new Error('文章不存在')
    }
    
    // 增加浏览数(异步,不影响响应)
    this.ctx.repo.article.incrementView(id).catch(err => {
      this.ctx.logger.error('Increment view count failed:', err)
    })
    
    return article
  }
  
  /**
   * 创建文章
   */
  async createArticle(articleData) {
    const { ctx } = this
    
    // 1. 验证标题唯一性
    const titleExists = await ctx.repo.article.checkTitleUnique(articleData.title)
    if (!titleExists) {
      throw new Error('文章标题已存在')
    }
    
    // 2. 验证分类是否存在
    const category = await ctx.repo.category.findById(articleData.categoryId)
    if (!category) {
      throw new Error('分类不存在')
    }
    
    // 3. 验证标签是否存在
    if (articleData.tagIds && articleData.tagIds.length > 0) {
      const tags = await ctx.repo.tag.findMany({
        filters: { _id: { $in: articleData.tagIds } }
      })
      if (tags.length !== articleData.tagIds.length) {
        throw new Error('部分标签不存在')
      }
    }
    
    // 4. 设置作者
    articleData.author = ctx.user.id
    
    // 5. 生成摘要(如果没有提供)
    if (!articleData.summary && articleData.content) {
      articleData.summary = this._generateSummary(articleData.content)
    }
    
    // 6. 创建文章
    const article = await ctx.repo.article.create(articleData)
    
    // 7. 更新分类文章数(异步)
    ctx.repo.category.incrementArticleCount(articleData.categoryId).catch(err => {
      ctx.logger.error('Update category count failed:', err)
    })
    
    // 8. 记录日志
    await ctx.service.log.create({
      action: 'article.create',
      targetId: article.id,
      userId: ctx.user.id
    })
    
    return article
  }
  
  /**
   * 更新文章
   */
  async updateArticle(id, articleData) {
    const { ctx } = this
    
    // 1. 检查文章是否存在
    const article = await ctx.repo.article.findById(id)
    if (!article) {
      throw new Error('文章不存在')
    }
    
    // 2. 权限检查:只能更新自己的文章
    if (article.author.toString() !== ctx.user.id && !ctx.user.isAdmin) {
      throw new Error('无权限修改此文章')
    }
    
    // 3. 验证标题唯一性
    if (articleData.title && articleData.title !== article.title) {
      const titleExists = await ctx.repo.article.checkTitleUnique(articleData.title, id)
      if (!titleExists) {
        throw new Error('文章标题已存在')
      }
    }
    
    // 4. 更新文章
    const updatedArticle = await ctx.repo.article.updateById(id, {
      ...articleData,
      updatedAt: new Date()
    })
    
    return updatedArticle
  }
  
  /**
   * 删除文章
   */
  async deleteArticle(id) {
    const { ctx } = this
    
    // 1. 检查文章是否存在
    const article = await ctx.repo.article.findById(id)
    if (!article) {
      throw new Error('文章不存在')
    }
    
    // 2. 权限检查
    if (article.author.toString() !== ctx.user.id && !ctx.user.isAdmin) {
      throw new Error('无权限删除此文章')
    }
    
    // 3. 删除文章
    await ctx.repo.article.deleteById(id)
    
    // 4. 更新分类文章数
    ctx.repo.category.decrementArticleCount(article.category).catch(err => {
      ctx.logger.error('Update category count failed:', err)
    })
    
    return true
  }
  
  /**
   * 发布文章
   */
  async publishArticle(id) {
    const { ctx } = this
    
    const article = await ctx.repo.article.findById(id)
    if (!article) {
      throw new Error('文章不存在')
    }
    
    if (article.status === 'published') {
      throw new Error('文章已发布')
    }
    
    return await ctx.repo.article.publish(id)
  }
  
  /**
   * 生成摘要
   */
  _generateSummary(content, maxLength = 200) {
    // 移除 HTML 标签
    const text = content.replace(/<[^>]+>/g, '')
    // 截取指定长度
    return text.substring(0, maxLength) + (text.length > maxLength ? '...' : '')
  }
}

module.exports = ArticleService

Step 6: Controller 层

javascript
// server/app/controller/article.js
const { Controller } = require('egg')

class ArticleController extends Controller {
  /**
   * 获取文章列表
   * GET /api/articles
   */
  async index() {
    const { ctx } = this
    
    // 参数验证
    ctx.validate({
      page: { type: 'number', required: false, min: 1 },
      limit: { type: 'number', required: false, min: 1, max: 100 },
      status: { type: 'string', required: false },
      categoryId: { type: 'string', required: false },
      keyword: { type: 'string', required: false }
    }, ctx.query)
    
    const { page = 1, limit = 20, status, categoryId, keyword } = ctx.query
    
    const result = await ctx.service.article.getArticleList({
      page: parseInt(page),
      limit: parseInt(limit),
      status,
      categoryId,
      keyword
    })
    
    ctx.body = {
      code: 0,
      message: 'success',
      data: result
    }
  }
  
  /**
   * 获取文章详情
   * GET /api/articles/:id
   */
  async show() {
    const { ctx } = this
    const { id } = ctx.params
    
    const article = await ctx.service.article.getArticleDetail(id)
    
    ctx.body = {
      code: 0,
      message: 'success',
      data: article
    }
  }
  
  /**
   * 创建文章
   * POST /api/articles
   */
  async create() {
    const { ctx } = this
    
    // 权限检查
    if (!ctx.user.hasPermission('article:create')) {
      ctx.status = 403
      ctx.body = { code: 403, message: '无权限' }
      return
    }
    
    // 参数验证
    ctx.validate({
      title: { type: 'string', required: true, max: 200 },
      content: { type: 'string', required: true },
      summary: { type: 'string', required: false, max: 500 },
      coverImage: { type: 'string', required: false },
      categoryId: { type: 'string', required: true },
      tagIds: { type: 'array', required: false, itemType: 'string' },
      status: { type: 'enum', values: ['draft', 'published'], required: false }
    })
    
    const article = await ctx.service.article.createArticle(ctx.request.body)
    
    ctx.body = {
      code: 0,
      message: '创建成功',
      data: article
    }
  }
  
  /**
   * 更新文章
   * PUT /api/articles/:id
   */
  async update() {
    const { ctx } = this
    const { id } = ctx.params
    
    const article = await ctx.service.article.updateArticle(id, ctx.request.body)
    
    ctx.body = {
      code: 0,
      message: '更新成功',
      data: article
    }
  }
  
  /**
   * 删除文章
   * DELETE /api/articles/:id
   */
  async destroy() {
    const { ctx } = this
    const { id } = ctx.params
    
    // 权限检查
    if (!ctx.user.hasPermission('article:delete')) {
      ctx.status = 403
      ctx.body = { code: 403, message: '无权限' }
      return
    }
    
    await ctx.service.article.deleteArticle(id)
    
    ctx.body = {
      code: 0,
      message: '删除成功'
    }
  }
  
  /**
   * 发布文章
   * POST /api/articles/:id/publish
   */
  async publish() {
    const { ctx } = this
    const { id } = ctx.params
    
    const article = await ctx.service.article.publishArticle(id)
    
    ctx.body = {
      code: 0,
      message: '发布成功',
      data: article
    }
  }
}

module.exports = ArticleController

Step 7: 路由配置

javascript
// server/app/router.js
module.exports = app => {
  const { router, controller, middleware } = app
  const auth = middleware.auth()
  
  // 文章相关路由
  router.get('/api/articles', controller.article.index)           // 文章列表
  router.get('/api/articles/:id', controller.article.show)        // 文章详情
  router.post('/api/articles', auth, controller.article.create)   // 创建文章
  router.put('/api/articles/:id', auth, controller.article.update) // 更新文章
  router.delete('/api/articles/:id', auth, controller.article.destroy) // 删除文章
  router.post('/api/articles/:id/publish', auth, controller.article.publish) // 发布文章
}

Step 8: 测试验证

单元测试

javascript
// test/app/service/article.test.js
const { app, assert } = require('egg-mock/bootstrap')

describe('test/app/service/article.test.js', () => {
  let ctx
  
  before(async () => {
    ctx = app.mockContext()
  })
  
  describe('createArticle()', () => {
    it('should create article successfully', async () => {
      const articleData = {
        title: 'Test Article',
        content: 'Test Content',
        categoryId: 'category-id',
        tagIds: ['tag1', 'tag2']
      }
      
      const article = await ctx.service.article.createArticle(articleData)
      
      assert(article.id)
      assert(article.title === 'Test Article')
      assert(article.status === 'draft')
    })
    
    it('should throw error if title exists', async () => {
      // 创建第一篇文章
      await ctx.service.article.createArticle({
        title: 'Duplicate Title',
        content: 'Content',
        categoryId: 'category-id'
      })
      
      // 尝试创建同标题文章
      try {
        await ctx.service.article.createArticle({
          title: 'Duplicate Title',
          content: 'Content',
          categoryId: 'category-id'
        })
        assert.fail('Should throw error')
      } catch (error) {
        assert(error.message === '文章标题已存在')
      }
    })
  })
})

API 测试

bash
# 创建文章
curl -X POST http://localhost:8080/api/articles \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "title": "测试文章",
    "content": "文章内容",
    "categoryId": "category-id",
    "status": "draft"
  }'

# 获取文章列表
curl http://localhost:8080/api/articles?page=1&limit=10

# 获取文章详情
curl http://localhost:8080/api/articles/article-id

# 更新文章
curl -X PUT http://localhost:8080/api/articles/article-id \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "title": "更新后的标题"
  }'

# 删除文章
curl -X DELETE http://localhost:8080/api/articles/article-id \
  -H "Authorization: Bearer YOUR_TOKEN"

开发检查清单

✅ 架构层次检查

  • [ ] 是否遵循三层架构(Controller → Service → Repository)
  • [ ] Controller 层是否只负责请求响应
  • [ ] Service 层是否包含所有业务逻辑
  • [ ] Repository 层是否只负责数据访问

✅ MongoDB Repository 检查

  • [ ] 是否继承 BaseMongoRepository
  • [ ] 是否实现必要的钩子方法
  • [ ] 是否实现业务特定方法
  • [ ] 是否处理了关联查询

✅ MariaDB Repository 检查

  • [ ] 是否继承 BaseMariaRepository
  • [ ] 是否配置了有效字段列表
  • [ ] 是否处理了字段映射
  • [ ] 是否定义了模型关联

✅ Service 层检查

  • [ ] 是否有完善的业务验证
  • [ ] 是否处理了事务
  • [ ] 是否有错误处理
  • [ ] 是否记录了日志

✅ Controller 层检查

  • [ ] 是否有参数验证
  • [ ] 是否有权限检查
  • [ ] 是否有错误处理
  • [ ] 是否返回标准格式

✅ 异常处理检查

  • [ ] 是否使用统一的异常类型
  • [ ] 是否有清晰的错误信息
  • [ ] 是否正确传播异常

✅ 测试覆盖检查

  • [ ] 是否有单元测试
  • [ ] 是否有集成测试
  • [ ] 是否测试了异常情况
  • [ ] 测试覆盖率是否达标

常见错误

1. Repository 包含业务逻辑

javascript
// ❌ 错误:在 Repository 中进行业务验证
class ArticleRepository extends BaseMongoRepository {
  async create(data) {
    if (data.status === 'published' && !data.publishedAt) {
      throw new Error('发布文章必须设置发布时间')
    }
    return await super.create(data)
  }
}

// ✅ 正确:业务逻辑在 Service 层
class ArticleService extends Service {
  async createArticle(data) {
    if (data.status === 'published' && !data.publishedAt) {
      throw new Error('发布文章必须设置发布时间')
    }
    return await this.ctx.repo.article.create(data)
  }
}

2. Controller 直接操作 Repository

javascript
// ❌ 错误
class ArticleController extends Controller {
  async index() {
    const articles = await this.ctx.repo.article.findMany({})
    this.ctx.body = articles
  }
}

// ✅ 正确
class ArticleController extends Controller {
  async index() {
    const articles = await this.ctx.service.article.getArticleList(this.ctx.query)
    this.ctx.body = { code: 0, data: articles }
  }
}

3. 忘记处理异常

javascript
// ❌ 错误
class ArticleService extends Service {
  async createArticle(data) {
    return await this.ctx.repo.article.create(data)
  }
}

// ✅ 正确
class ArticleService extends Service {
  async createArticle(data) {
    try {
      return await this.ctx.repo.article.create(data)
    } catch (error) {
      this.ctx.logger.error('Create article failed:', error)
      throw error
    }
  }
}

下一步

最后更新于: