跳到主要内容

Feathers.js 第一个实战项目 - 构建待办事项API

· 阅读需 13 分钟
一介布衣
全栈开发者

发布时间:2024-05-15
作者:一介布衣
标签:Feathers.js, 实战项目, 待办事项, API开发

前言

前面三篇文章我们学习了 Feathers.js 的基础知识和核心概念,今天咱们来做一个完整的实战项目 - 构建一个功能丰富的待办事项 API。说实话,待办事项虽然看起来简单,但要做好还是有很多细节需要考虑的。

我记得刚开始学编程的时候,总觉得 Todo 应用太简单了,没什么技术含量。后来做了几个项目才发现,一个好的 Todo 应用需要考虑用户权限、数据验证、实时同步、性能优化等很多方面。用 Feathers.js 来实现,正好可以体验它在实际项目中的强大功能。

今天我们要构建的不是一个简单的 Todo,而是一个功能完整的任务管理系统,包含用户管理、任务分类、实时协作等功能。

项目需求分析

功能需求

用户管理

  • 用户注册和登录
  • 用户资料管理
  • 密码修改

任务管理

  • 创建、编辑、删除任务
  • 任务状态管理(待办、进行中、已完成)
  • 任务优先级设置
  • 任务截止时间
  • 任务分类和标签

协作功能

  • 任务分享
  • 实时同步
  • 任务评论

高级功能

  • 任务搜索和过滤
  • 数据统计
  • 导入导出

技术架构

  • 后端框架:Feathers.js
  • 数据库:MongoDB
  • 认证:JWT
  • 实时通信:Socket.io
  • API文档:自动生成

项目初始化

1. 创建项目

# 创建新项目
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. 安装额外依赖

cd todo-api
npm install

# 安装额外的工具包
npm install moment lodash validator
npm install --save-dev @faker-js/faker

3. 启动 MongoDB

# 使用 Docker 启动 MongoDB
docker run -d -p 27017:27017 --name mongodb mongo:latest

# 或者使用本地安装的 MongoDB
mongod

4. 启动项目

npm run dev

数据模型设计

1. 用户模型扩展

虽然 Feathers.js 已经生成了基础的用户模型,我们需要扩展一些字段:

// 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. 任务分类模型

// 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. 任务模型

// 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. 任务评论模型

// 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. 生成服务

# 生成分类服务
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. 任务服务增强

// 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. 任务钩子配置

// 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. 频道配置

// 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. 创建测试数据

// 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. 运行测试

# 运行种子数据脚本
node scripts/seed-data.js

# 测试 API
curl -X GET http://localhost:3030/tasks \
-H "Authorization: Bearer YOUR_TOKEN"

总结

通过这个实战项目,我们构建了一个功能完整的待办事项 API,包含:

完整的数据模型设计

  • 用户扩展模型
  • 任务分类管理
  • 复杂的任务模型
  • 评论系统

强大的服务功能

  • 权限控制
  • 高级查询和过滤
  • 批量操作
  • 数据统计

实时协作功能

  • 任务实时同步
  • 评论实时通知
  • 多用户协作

生产级特性

  • 数据验证
  • 错误处理
  • 性能优化
  • 安全控制

这个项目展示了 Feathers.js 在实际开发中的强大能力,特别是:

  • 统一的 API 设计让前端开发变得简单
  • 强大的钩子系统让业务逻辑组织得很清晰
  • 实时功能开箱即用,无需复杂配置
  • 灵活的查询系统支持复杂的业务需求

下一篇文章,我们将深入学习 Feathers.js 的服务系统,包括不同类型的服务和高级用法。


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!