Skip to content

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 的服务系统,包括不同类型的服务和高级用法。


相关文章推荐:

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