Feathers.js 第一个实战项目 - 构建待办事项API
发布时间:2024-05-15
作者:一介布衣
标签:Feathers.js, 实战项目, 待办事项, API开发
前言
前面三篇文章我们学习了 Feathers.js 的基础知识和核心概念,今天咱们来做一个完整的实战项目 - 构建一个功能丰富的待办事项 API。说实话,待办事项虽然看起来简单,但要做好还是有很多细节需要考虑的。
我记得刚开始学编程的时候,总觉得 Todo 应用太简单了,没什么技术含量。后来做了几个项目才发现,一个好的 Todo 应用需要考虑用户权限、数据验证、实时同步、性能优化等很多方面。用 Feathers.js 来实现,正好可以体验它在实际项目中的强大功能。
今天我们要构建的不是一个简单的 Todo,而是一个功能完整的任务管理系统,包含用户管理、任务分类、实时协作等功能。
项目需求分析
功能需求
用户管理:
- 用户注册和登录
- 用户资料管理
- 密码修改
任务管理:
- 创建、编辑、删除任务
- 任务状态管理(待办、进行中、已完成)
- 任务优先级设置
- 任务截止时间
- 任务分类和标签
协作功能:
- 任务分享
- 实时同步
- 任务评论
高级功能:
- 任务搜索和过滤
- 数据统计
- 导入导出
技术架构
- 后端框架:Feathers.js
- 数据库:MongoDB
- 认证:JWT
- 实时通信:Socket.io
- API文档:自动生成
项目初始化
1. 创建项目
bash
# 创建新项目
feathers generate app
# 项目配置选择
? Project name: todo-api
? Description: A powerful todo API built with Feathers.js
? What folder should the source files live in? src
? Which package manager are you using? npm
? What type of API are you making? REST, Realtime via Socket.io
? Which testing framework do you prefer? Jest
? This app uses authentication: Yes
? What authentication methods do you want to use? Email + Password
? What is the source of your database? MongoDB
? Which database are you connecting to? MongoDB
? Does your app require users to verify their email? No
2. 安装额外依赖
bash
cd todo-api
npm install
# 安装额外的工具包
npm install moment lodash validator
npm install --save-dev @faker-js/faker
3. 启动 MongoDB
bash
# 使用 Docker 启动 MongoDB
docker run -d -p 27017:27017 --name mongodb mongo:latest
# 或者使用本地安装的 MongoDB
mongod
4. 启动项目
bash
npm run dev
数据模型设计
1. 用户模型扩展
虽然 Feathers.js 已经生成了基础的用户模型,我们需要扩展一些字段:
javascript
// src/models/users.model.js
module.exports = function (app) {
const modelName = 'users';
const mongooseClient = app.get('mongooseClient');
const { Schema } = mongooseClient;
const schema = new Schema({
email: {
type: String,
unique: true,
lowercase: true,
required: true
},
password: {
type: String,
required: true
},
// 扩展字段
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 30
},
firstName: {
type: String,
trim: true,
maxlength: 50
},
lastName: {
type: String,
trim: true,
maxlength: 50
},
avatar: {
type: String,
default: null
},
timezone: {
type: String,
default: 'UTC'
},
preferences: {
theme: {
type: String,
enum: ['light', 'dark', 'auto'],
default: 'light'
},
language: {
type: String,
default: 'en'
},
notifications: {
email: { type: Boolean, default: true },
push: { type: Boolean, default: true }
}
},
// 统计信息
stats: {
totalTasks: { type: Number, default: 0 },
completedTasks: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now }
}
}, {
timestamps: true
});
// 添加索引
schema.index({ email: 1 });
schema.index({ username: 1 });
// 虚拟字段
schema.virtual('fullName').get(function() {
return `\${this.firstName || ''} \${this.lastName || ''}`.trim();
});
// 实例方法
schema.methods.toSafeObject = function() {
const obj = this.toObject();
delete obj.password;
return obj;
};
return mongooseClient.model(modelName, schema);
};
2. 任务分类模型
javascript
// src/models/categories.model.js
module.exports = function (app) {
const modelName = 'categories';
const mongooseClient = app.get('mongooseClient');
const { Schema } = mongooseClient;
const schema = new Schema({
name: {
type: String,
required: true,
trim: true,
maxlength: 50
},
description: {
type: String,
maxlength: 200
},
color: {
type: String,
default: '#007bff',
match: /^#[0-9A-F]{6}$/i
},
icon: {
type: String,
default: 'folder'
},
userId: {
type: Schema.Types.ObjectId,
ref: 'users',
required: true
},
isDefault: {
type: Boolean,
default: false
},
sortOrder: {
type: Number,
default: 0
}
}, {
timestamps: true
});
// 索引
schema.index({ userId: 1, name: 1 }, { unique: true });
schema.index({ userId: 1, sortOrder: 1 });
return mongooseClient.model(modelName, schema);
};
3. 任务模型
javascript
// src/models/tasks.model.js
module.exports = function (app) {
const modelName = 'tasks';
const mongooseClient = app.get('mongooseClient');
const { Schema } = mongooseClient;
const schema = new Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 200
},
description: {
type: String,
maxlength: 1000
},
// 状态和优先级
status: {
type: String,
enum: ['todo', 'in_progress', 'completed', 'cancelled'],
default: 'todo'
},
priority: {
type: String,
enum: ['low', 'medium', 'high', 'urgent'],
default: 'medium'
},
// 时间相关
dueDate: {
type: Date,
default: null
},
completedAt: {
type: Date,
default: null
},
estimatedTime: {
type: Number, // 预估时间(分钟)
min: 0
},
actualTime: {
type: Number, // 实际时间(分钟)
min: 0
},
// 关联
userId: {
type: Schema.Types.ObjectId,
ref: 'users',
required: true
},
categoryId: {
type: Schema.Types.ObjectId,
ref: 'categories',
default: null
},
// 标签
tags: [{
type: String,
trim: true,
maxlength: 30
}],
// 协作
assignedTo: [{
type: Schema.Types.ObjectId,
ref: 'users'
}],
sharedWith: [{
user: {
type: Schema.Types.ObjectId,
ref: 'users'
},
permission: {
type: String,
enum: ['read', 'write'],
default: 'read'
}
}],
// 附件
attachments: [{
name: String,
url: String,
size: Number,
type: String,
uploadedAt: { type: Date, default: Date.now }
}],
// 子任务
parentId: {
type: Schema.Types.ObjectId,
ref: 'tasks',
default: null
},
// 元数据
metadata: {
source: {
type: String,
enum: ['web', 'mobile', 'api', 'import'],
default: 'web'
},
location: {
type: String,
maxlength: 100
}
}
}, {
timestamps: true
});
// 索引
schema.index({ userId: 1, status: 1 });
schema.index({ userId: 1, dueDate: 1 });
schema.index({ userId: 1, priority: 1 });
schema.index({ userId: 1, categoryId: 1 });
schema.index({ tags: 1 });
schema.index({ 'sharedWith.user': 1 });
// 虚拟字段
schema.virtual('isOverdue').get(function() {
return this.dueDate && this.dueDate < new Date() && this.status !== 'completed';
});
schema.virtual('timeSpent').get(function() {
return this.actualTime || 0;
});
// 实例方法
schema.methods.markCompleted = function() {
this.status = 'completed';
this.completedAt = new Date();
return this.save();
};
schema.methods.canUserAccess = function(userId, permission = 'read') {
// 检查用户是否有权限访问任务
if (this.userId.toString() === userId.toString()) {
return true;
}
const sharedUser = this.sharedWith.find(
share => share.user.toString() === userId.toString()
);
if (!sharedUser) return false;
if (permission === 'read') return true;
if (permission === 'write') return sharedUser.permission === 'write';
return false;
};
return mongooseClient.model(modelName, schema);
};
4. 任务评论模型
javascript
// src/models/comments.model.js
module.exports = function (app) {
const modelName = 'comments';
const mongooseClient = app.get('mongooseClient');
const { Schema } = mongooseClient;
const schema = new Schema({
content: {
type: String,
required: true,
trim: true,
maxlength: 500
},
taskId: {
type: Schema.Types.ObjectId,
ref: 'tasks',
required: true
},
userId: {
type: Schema.Types.ObjectId,
ref: 'users',
required: true
},
parentId: {
type: Schema.Types.ObjectId,
ref: 'comments',
default: null
},
mentions: [{
type: Schema.Types.ObjectId,
ref: 'users'
}],
attachments: [{
name: String,
url: String,
type: String
}]
}, {
timestamps: true
});
// 索引
schema.index({ taskId: 1, createdAt: -1 });
schema.index({ userId: 1 });
return mongooseClient.model(modelName, schema);
};
服务实现
1. 生成服务
bash
# 生成分类服务
feathers generate service
? What kind of service is it? Mongoose
? What is the name of the service? categories
? Which path should the service be registered on? /categories
? Does the service require authentication? Yes
# 生成任务服务
feathers generate service
? What kind of service is it? Mongoose
? What is the name of the service? tasks
? Which path should the service be registered on? /tasks
? Does the service require authentication? Yes
# 生成评论服务
feathers generate service
? What kind of service is it? Mongoose
? What is the name of the service? comments
? Which path should the service be registered on? /comments
? Does the service require authentication? Yes
2. 任务服务增强
javascript
// src/services/tasks/tasks.class.js
const { Service } = require('feathers-mongoose');
class Tasks extends Service {
constructor(options, app) {
super(options, app);
this.app = app;
}
async find(params) {
const { user } = params;
const { query = {} } = params;
// 构建查询条件
const searchQuery = {
$or: [
{ userId: user._id },
{ 'sharedWith.user': user._id },
{ assignedTo: user._id }
]
};
// 状态过滤
if (query.status) {
searchQuery.status = query.status;
}
// 优先级过滤
if (query.priority) {
searchQuery.priority = query.priority;
}
// 分类过滤
if (query.categoryId) {
searchQuery.categoryId = query.categoryId;
}
// 标签过滤
if (query.tags) {
searchQuery.tags = { $in: Array.isArray(query.tags) ? query.tags : [query.tags] };
}
// 截止日期过滤
if (query.dueDateFrom || query.dueDateTo) {
searchQuery.dueDate = {};
if (query.dueDateFrom) {
searchQuery.dueDate.$gte = new Date(query.dueDateFrom);
}
if (query.dueDateTo) {
searchQuery.dueDate.$lte = new Date(query.dueDateTo);
}
}
// 文本搜索
if (query.search) {
searchQuery.$text = { $search: query.search };
}
// 过期任务
if (query.overdue === 'true') {
searchQuery.dueDate = { $lt: new Date() };
searchQuery.status = { $ne: 'completed' };
}
// 今日任务
if (query.today === 'true') {
const today = new Date();
const startOfDay = new Date(today.setHours(0, 0, 0, 0));
const endOfDay = new Date(today.setHours(23, 59, 59, 999));
searchQuery.dueDate = {
$gte: startOfDay,
$lte: endOfDay
};
}
params.query = {
...searchQuery,
$limit: query.$limit,
$skip: query.$skip,
$sort: query.$sort || { createdAt: -1 }
};
return super.find(params);
}
async get(id, params) {
const task = await super.get(id, params);
const { user } = params;
// 检查权限
if (!task.canUserAccess(user._id)) {
throw new Error('无权限访问此任务');
}
return task;
}
async create(data, params) {
const { user } = params;
// 设置创建者
data.userId = user._id;
// 如果没有指定分类,使用默认分类
if (!data.categoryId) {
const defaultCategory = await this.app.service('categories').find({
query: { userId: user._id, isDefault: true }
});
if (defaultCategory.total > 0) {
data.categoryId = defaultCategory.data[0]._id;
}
}
const task = await super.create(data, params);
// 更新用户统计
await this.updateUserStats(user._id);
return task;
}
async patch(id, data, params) {
const { user } = params;
const task = await this.get(id, params);
// 检查写权限
if (!task.canUserAccess(user._id, 'write')) {
throw new Error('无权限修改此任务');
}
// 如果状态变为完成,设置完成时间
if (data.status === 'completed' && task.status !== 'completed') {
data.completedAt = new Date();
}
// 如果状态从完成变为其他,清除完成时间
if (data.status && data.status !== 'completed' && task.status === 'completed') {
data.completedAt = null;
}
const updatedTask = await super.patch(id, data, params);
// 更新用户统计
await this.updateUserStats(user._id);
return updatedTask;
}
async remove(id, params) {
const { user } = params;
const task = await this.get(id, params);
// 只有创建者可以删除任务
if (task.userId.toString() !== user._id.toString()) {
throw new Error('只有任务创建者可以删除任务');
}
const result = await super.remove(id, params);
// 删除相关评论
await this.app.service('comments').remove(null, {
query: { taskId: id }
});
// 更新用户统计
await this.updateUserStats(user._id);
return result;
}
// 更新用户统计信息
async updateUserStats(userId) {
const tasks = await super.find({
query: { userId },
paginate: false
});
const totalTasks = tasks.length;
const completedTasks = tasks.filter(task => task.status === 'completed').length;
await this.app.service('users').patch(userId, {
'stats.totalTasks': totalTasks,
'stats.completedTasks': completedTasks
});
}
// 批量操作
async bulkUpdate(data, params) {
const { user } = params;
const { taskIds, updates } = data;
const results = [];
for (const taskId of taskIds) {
try {
const task = await this.patch(taskId, updates, params);
results.push({ id: taskId, success: true, task });
} catch (error) {
results.push({ id: taskId, success: false, error: error.message });
}
}
return results;
}
}
module.exports = function (options) {
return new Tasks(options);
};
3. 任务钩子配置
javascript
// src/services/tasks/tasks.hooks.js
const { authenticate } = require('@feathersjs/authentication').hooks;
const {
hashPassword,
protect
} = require('@feathersjs/authentication-local').hooks;
// 验证任务数据
const validateTask = () => {
return async context => {
const { data } = context;
if (!data.title || data.title.trim().length === 0) {
throw new Error('任务标题不能为空');
}
if (data.title.length > 200) {
throw new Error('任务标题不能超过200个字符');
}
if (data.description && data.description.length > 1000) {
throw new Error('任务描述不能超过1000个字符');
}
if (data.dueDate && new Date(data.dueDate) < new Date()) {
// 允许设置过去的日期,但给出警告
context.params.warnings = context.params.warnings || [];
context.params.warnings.push('截止日期已过期');
}
// 清理数据
data.title = data.title.trim();
if (data.description) {
data.description = data.description.trim();
}
return context;
};
};
// 处理标签
const processTags = () => {
return async context => {
const { data } = context;
if (data.tags) {
// 清理和去重标签
data.tags = [...new Set(
data.tags
.map(tag => tag.trim().toLowerCase())
.filter(tag => tag.length > 0 && tag.length <= 30)
)];
}
return context;
};
};
// 验证分类权限
const validateCategory = () => {
return async context => {
const { data, params } = context;
if (data.categoryId) {
const category = await context.app.service('categories').get(data.categoryId);
if (category.userId.toString() !== params.user._id.toString()) {
throw new Error('无权限使用此分类');
}
}
return context;
};
};
// 填充关联数据
const populateData = () => {
return async context => {
const { result } = context;
if (result.data) {
// 批量填充
for (const task of result.data) {
await populateTask(task, context.app);
}
} else {
// 单个填充
await populateTask(result, context.app);
}
return context;
};
};
async function populateTask(task, app) {
// 填充用户信息
if (task.userId) {
task.user = await app.service('users').get(task.userId);
task.user = task.user.toSafeObject();
}
// 填充分类信息
if (task.categoryId) {
try {
task.category = await app.service('categories').get(task.categoryId);
} catch (error) {
// 分类可能已被删除
task.category = null;
}
}
// 填充分配用户信息
if (task.assignedTo && task.assignedTo.length > 0) {
task.assignedUsers = [];
for (const userId of task.assignedTo) {
try {
const user = await app.service('users').get(userId);
task.assignedUsers.push(user.toSafeObject());
} catch (error) {
// 用户可能已被删除
}
}
}
}
module.exports = {
before: {
all: [authenticate('jwt')],
find: [],
get: [],
create: [validateTask(), processTags(), validateCategory()],
update: [validateTask(), processTags(), validateCategory()],
patch: [processTags(), validateCategory()],
remove: []
},
after: {
all: [populateData()],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: []
},
error: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: []
}
};
实时功能配置
1. 频道配置
javascript
// src/channels.js
module.exports = function(app) {
if(typeof app.channel !== 'function') {
return;
}
app.on('connection', connection => {
app.channel('anonymous').join(connection);
});
app.on('login', (authResult, { connection }) => {
const { user } = authResult;
if(connection) {
app.channel('anonymous').leave(connection);
app.channel('authenticated').join(connection);
app.channel(`user-\${user._id}`).join(connection);
}
});
// 任务事件发布
app.service('tasks').publish((data, hook) => {
const { method } = hook;
// 获取所有相关用户
const userIds = new Set();
userIds.add(data.userId.toString());
if (data.assignedTo) {
data.assignedTo.forEach(id => userIds.add(id.toString()));
}
if (data.sharedWith) {
data.sharedWith.forEach(share => userIds.add(share.user.toString()));
}
// 发送给相关用户
const channels = Array.from(userIds).map(id => app.channel(`user-\${id}`));
return channels;
});
// 评论事件发布
app.service('comments').publish('created', async (data, hook) => {
// 获取任务信息
const task = await app.service('tasks').get(data.taskId);
// 发送给任务相关用户
const userIds = new Set();
userIds.add(task.userId.toString());
if (task.assignedTo) {
task.assignedTo.forEach(id => userIds.add(id.toString()));
}
if (task.sharedWith) {
task.sharedWith.forEach(share => userIds.add(share.user.toString()));
}
const channels = Array.from(userIds).map(id => app.channel(`user-\${id}`));
return channels;
});
// 分类事件只发送给创建者
app.service('categories').publish((data, hook) => {
return app.channel(`user-\${data.userId}`);
});
};
API 测试
1. 创建测试数据
javascript
// scripts/seed-data.js
const app = require('../src/app');
async function seedData() {
try {
// 创建测试用户
const user = await app.service('users').create({
email: 'test@example.com',
password: 'password123',
username: 'testuser',
firstName: 'Test',
lastName: 'User'
});
console.log('用户创建成功:', user._id);
// 登录获取 token
const auth = await app.service('authentication').create({
strategy: 'local',
email: 'test@example.com',
password: 'password123'
});
console.log('登录成功,Token:', auth.accessToken);
// 创建默认分类
const category = await app.service('categories').create({
name: '默认分类',
description: '系统默认分类',
isDefault: true
}, {
user: user,
authentication: auth
});
console.log('分类创建成功:', category._id);
// 创建测试任务
const tasks = [
{
title: '学习 Feathers.js',
description: '深入学习 Feathers.js 框架的核心概念',
priority: 'high',
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后
tags: ['学习', '编程', 'javascript'],
categoryId: category._id
},
{
title: '完成项目文档',
description: '编写项目的 API 文档和使用说明',
priority: 'medium',
status: 'in_progress',
tags: ['文档', '项目'],
categoryId: category._id
},
{
title: '代码审查',
description: '审查团队成员提交的代码',
priority: 'low',
status: 'completed',
completedAt: new Date(),
tags: ['代码审查', '团队协作'],
categoryId: category._id
}
];
for (const taskData of tasks) {
const task = await app.service('tasks').create(taskData, {
user: user,
authentication: auth
});
console.log('任务创建成功:', task.title);
}
console.log('测试数据创建完成!');
process.exit(0);
} catch (error) {
console.error('创建测试数据失败:', error);
process.exit(1);
}
}
seedData();
2. 运行测试
bash
# 运行种子数据脚本
node scripts/seed-data.js
# 测试 API
curl -X GET http://localhost:3030/tasks \
-H "Authorization: Bearer YOUR_TOKEN"
总结
通过这个实战项目,我们构建了一个功能完整的待办事项 API,包含:
✅ 完整的数据模型设计
- 用户扩展模型
- 任务分类管理
- 复杂的任务模型
- 评论系统
✅ 强大的服务功能
- 权限控制
- 高级查询和过滤
- 批量操作
- 数据统计
✅ 实时协作功能
- 任务实时同步
- 评论实时通知
- 多用户协作
✅ 生产级特性
- 数据验证
- 错误处理
- 性能优化
- 安全控制
这个项目展示了 Feathers.js 在实际开发中的强大能力,特别是:
- 统一的 API 设计让前端开发变得简单
- 强大的钩子系统让业务逻辑组织得很清晰
- 实时功能开箱即用,无需复杂配置
- 灵活的查询系统支持复杂的业务需求
下一篇文章,我们将深入学习 Feathers.js 的服务系统,包括不同类型的服务和高级用法。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!