新模块开发指南
本指南将带你一步步完成一个新模块的开发,从需求分析到测试验证。
开发流程
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 = ArticleMongoRepositoryStep 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 = ArticleMariaRepositoryStep 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 = ArticleServiceStep 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 = ArticleControllerStep 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
}
}
}下一步
- 📖 Repository 模式 - 深入理解 Repository
- 🏗️ 三层架构 - 了解架构设计
- 🗄️ 双数据库支持 - 学习数据库切换
- 🚀 快速开始 - 开始使用 DoraCMS