動態

詳情 返回 返回

使用Cursor開發Strapi5插件bag-strapi-plugin - 動態 詳情

🎯 為什麼開發 bag-strapi-plugin?

問題的起源

在使用 Strapi 5 開發多個項目後,我發現每個項目都需要重複實現一些通用功能:

  1. 用户認證系統 - JWT Token 管理、密碼加密、登錄註冊
  2. API 安全保護 - 簽名驗證、限流防刷、加密傳輸
  3. 驗證碼功能 - 圖形驗證碼、短信驗證碼
  4. 菜單管理 - 後台菜單的數據庫設計和 CRUD
  5. 加密工具 - AES、RSA、Hash 等加密算法的封裝

每次都要從頭寫這些功能,不僅耗時,而且容易出現安全漏洞。於是我決定開發一個插件,將這些通用功能封裝起來,實現一次開發、多處複用

背景

我一直在使用Strapi、Egg.js 、ThinkPhp 在開發端管理系統,很少在寫後端插件,今天藉助Ai的勢力開發一個插件

技術

選擇是Strapi5,官方文檔寫的有插件開發,但是寫的也沒有具體的例子(哈哈哈哈哈哈,主要是自己英文菜哈),言歸正傳,我們開始

在線預覽地址、
Github源碼地址

image.png

搭建項目

yalc 必須全局安裝,然後使用官方提供的命令安裝插件開發文件結構

npm install -g yalc

創建插件

npx @strapi/sdk-plugin init my-strapi-plugin

🏗️ 項目架構設計

插件目錄結構

bag-strapi-plugin/
├── admin/              # 前端管理界面
│   └── src/
│       ├── components/ # 自定義組件(登錄頁等)
│       ├── pages/      # 管理頁面
│       └── index.js    # 前端入口
├── server/             # 後端邏輯
│   └── src/
│       ├── bootstrap/  # 啓動鈎子
│       ├── controllers/# 控制器
│       ├── services/   # 服務層
│       ├── routes/     # 路由定義
│       ├── middlewares/# 中間件
│       ├── content-types/ # 內容類型(菜單、用户表)
│       └── utils/      # 工具函數
├── docs/               # VitePress 文檔
└── package.json

核心功能模塊

// 插件提供的全局對象
strapi.crypto       // 加密工具庫
strapi.auth         // 認證系統
strapi.captcha      // 驗證碼系統
strapi.rateLimit    // 限流系統
strapi.menuService  // 菜單服務

💻 核心功能實現

1. 🔐 JWT 認證系統

使用 Cursor 快速搭建認證框架

在開發認證系統時,我使用 Cursor 的對話功能快速生成了基礎代碼:

我的提示詞:

幫我創建一個 Strapi 5 插件的認證控制器,需要實現:
1. 用户註冊(支持用户名、郵箱、密碼)
2. 用户登錄(支持用户名或郵箱登錄)
3. Token 刷新
4. 獲取當前用户信息
5. 修改密碼
使用 bcrypt 加密密碼,使用 jsonwebtoken 生成 JWT

生成的核心代碼:

// server/src/controllers/auth.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

module.exports = {
  // 用户註冊
  async register(ctx) {
    try {
      const { username, email, password, nickname } = ctx.request.body;

      // 驗證必填字段
      if (!username || !email || !password) {
        return ctx.badRequest('用户名、郵箱和密碼不能為空');
      }

      // 檢查用户是否已存在
      const existingUser = await strapi.db.query('plugin::bag-strapi-plugin.bag-user').findOne({
        where: {
          $or: [{ username }, { email }]
        }
      });

      if (existingUser) {
        return ctx.badRequest('用户名或郵箱已存在');
      }

      // 加密密碼
      const hashedPassword = await bcrypt.hash(password, 10);

      // 創建用户
      const user = await strapi.db.query('plugin::bag-strapi-plugin.bag-user').create({
        data: {
          username,
          email,
          password: hashedPassword,
          nickname: nickname || username,
          isActive: true,
        }
      });

      // 生成 JWT Token
      const config = strapi.plugin('bag-strapi-plugin').config('auth') || {};
      const jwtSecret = config.jwt?.secret || process.env.JWT_SECRET || 'default-secret';
      const expiresIn = config.jwt?.expiresIn || '7d';

      const token = jwt.sign(
        { id: user.id, username: user.username },
        jwtSecret,
        { expiresIn }
      );

      // 移除密碼字段
      delete user.password;

      ctx.send({
        success: true,
        message: '註冊成功',
        data: {
          user,
          token,
        }
      });
    } catch (error) {
      strapi.log.error('註冊失敗:', error);
      ctx.internalServerError('註冊失敗');
    }
  },

  // 用户登錄
  async login(ctx) {
    try {
      const { identifier, password } = ctx.request.body;

      if (!identifier || !password) {
        return ctx.badRequest('用户名/郵箱和密碼不能為空');
      }

      // 查找用户
      const user = await strapi.db.query('plugin::bag-strapi-plugin.bag-user').findOne({
        where: {
          $or: [
            { username: identifier },
            { email: identifier }
          ]
        }
      });

      if (!user) {
        return ctx.badRequest('用户不存在');
      }

      // 驗證密碼
      const isPasswordValid = await bcrypt.compare(password, user.password);
      if (!isPasswordValid) {
        return ctx.badRequest('密碼錯誤');
      }

      // 檢查賬户狀態
      if (!user.isActive) {
        return ctx.badRequest('賬户已被禁用');
      }

      // 生成 Token
      const config = strapi.plugin('bag-strapi-plugin').config('auth') || {};
      const jwtSecret = config.jwt?.secret || process.env.JWT_SECRET || 'default-secret';
      const expiresIn = config.jwt?.expiresIn || '7d';

      const token = jwt.sign(
        { id: user.id, username: user.username },
        jwtSecret,
        { expiresIn }
      );

      // 移除密碼字段
      delete user.password;

      ctx.send({
        success: true,
        message: '登錄成功',
        data: {
          user,
          token,
        }
      });
    } catch (error) {
      strapi.log.error('登錄失敗:', error);
      ctx.internalServerError('登錄失敗');
    }
  },
};

Cursor 的優勢體現

  1. 智能補全 - 在輸入 bcrypt. 時,Cursor 自動提示 hash()compare() 方法
  2. 錯誤處理 - Cursor 自動添加了完善的 try-catch 和錯誤提示
  3. 安全最佳實踐 - 自動使用 bcrypt 加密,移除返回數據中的密碼字段
  4. 代碼風格 - 生成的代碼符合 Strapi 的最佳實踐

2. 🖼️ 驗證碼系統

驗證碼系統支持四種類型:圖形驗證碼、數學運算驗證碼、郵件驗證碼、短信驗證碼。

// server/src/controllers/captcha.js
const svgCaptcha = require('svg-captcha');

module.exports = {
  // 生成圖形驗證碼
  async generateImageCaptcha(ctx) {
    try {
      const config = strapi.plugin('bag-strapi-plugin').config('captcha') || {};
      const captchaLength = config.length || 4;

      // 生成驗證碼
      const captcha = svgCaptcha.create({
        size: captchaLength,
        noise: 2,
        color: true,
        background: '#f0f0f0',
      });

      // 生成唯一 ID
      const captchaId = strapi.crypto.random.uuid();

      // 存儲驗證碼(5分鐘過期)
      const expireTime = config.expireTime || 5 * 60 * 1000;
      await strapi.cache.set(
        `captcha:${captchaId}`,
        captcha.text.toLowerCase(),
        expireTime
      );

      ctx.send({
        success: true,
        data: {
          captchaId,
          captchaImage: captcha.data, // SVG 圖片
        }
      });
    } catch (error) {
      strapi.log.error('生成驗證碼失敗:', error);
      ctx.internalServerError('生成驗證碼失敗');
    }
  },

  // 驗證驗證碼
  async verifyCaptcha(ctx) {
    try {
      const { captchaId, captchaCode } = ctx.request.body;

      if (!captchaId || !captchaCode) {
        return ctx.badRequest('驗證碼ID和驗證碼不能為空');
      }

      // 獲取存儲的驗證碼
      const storedCode = await strapi.cache.get(`captcha:${captchaId}`);

      if (!storedCode) {
        return ctx.badRequest('驗證碼已過期或不存在');
      }

      // 驗證驗證碼
      const isValid = storedCode === captchaCode.toLowerCase();

      // 驗證後刪除
      await strapi.cache.del(`captcha:${captchaId}`);

      ctx.send({
        success: true,
        data: {
          isValid,
        }
      });
    } catch (error) {
      strapi.log.error('驗證失敗:', error);
      ctx.internalServerError('驗證失敗');
    }
  },
};

3. ⚡ API 限流系統

使用 rate-limiter-flexible 實現強大的限流功能:

// server/src/middlewares/rate-limit.js
const { RateLimiterMemory, RateLimiterRedis } = require('rate-limiter-flexible');

module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    try {
      const rateLimitConfig = strapi.plugin('bag-strapi-plugin').config('rateLimit') || {};

      // 如果未啓用限流,直接放行
      if (!rateLimitConfig.enabled) {
        return await next();
      }

      // 創建限流器
      const limiterOptions = {
        points: rateLimitConfig.points || 100, // 請求數
        duration: rateLimitConfig.duration || 60, // 時間窗口(秒)
        blockDuration: rateLimitConfig.blockDuration || 60, // 阻止時長
      };

      let rateLimiter;
      if (rateLimitConfig.storage === 'redis') {
        // 使用 Redis 存儲
        const Redis = require('ioredis');
        const redisClient = new Redis(rateLimitConfig.redis);
        rateLimiter = new RateLimiterRedis({
          storeClient: redisClient,
          ...limiterOptions,
        });
      } else {
        // 使用內存存儲
        rateLimiter = new RateLimiterMemory(limiterOptions);
      }

      // 獲取請求標識(IP 或用户 ID)
      const key = ctx.state.user?.id || ctx.request.ip;

      // 消費一個點數
      await rateLimiter.consume(key);

      // 放行請求
      await next();
    } catch (error) {
      if (error.remainingPoints !== undefined) {
        // 觸發限流
        ctx.status = 429;
        ctx.set('Retry-After', String(Math.round(error.msBeforeNext / 1000)));
        ctx.body = {
          success: false,
          message: '請求過於頻繁,請稍後再試',
          retryAfter: Math.round(error.msBeforeNext / 1000),
        };
      } else {
        throw error;
      }
    }
  };
};

4. 🔒 加密工具庫

這是插件的核心功能之一,提供了全局可用的加密工具:

// server/src/utils/crypto-utils.js
import crypto from 'crypto';

/**
 * AES-256-GCM 加密
 */
function aesEncrypt(plaintext, secretKey) {
  try {
    const key = crypto.scryptSync(secretKey, 'salt', 32);
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
    
    let encrypted = cipher.update(plaintext, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    
    const authTag = cipher.getAuthTag();
    
    return {
      encrypted: encrypted,
      iv: iv.toString('hex'),
      authTag: authTag.toString('hex')
    };
  } catch (error) {
    throw new Error(`AES 加密失敗: ${error.message}`);
  }
}

/**
 * AES-256-GCM 解密
 */
function aesDecrypt(encrypted, secretKey, iv, authTag) {
  try {
    const key = crypto.scryptSync(secretKey, 'salt', 32);
    const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
    decipher.setAuthTag(Buffer.from(authTag, 'hex'));
    
    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    
    return decrypted;
  } catch (error) {
    throw new Error(`AES 解密失敗: ${error.message}`);
  }
}

/**
 * RSA 密鑰對生成
 */
function generateRSAKeyPair(modulusLength = 2048) {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: modulusLength,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem'
    }
  });
  
  return { publicKey, privateKey };
}

/**
 * RSA 加密
 */
function rsaEncrypt(plaintext, publicKey) {
  const buffer = Buffer.from(plaintext, 'utf8');
  const encrypted = crypto.publicEncrypt(
    {
      key: publicKey,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    buffer
  );
  
  return encrypted.toString('base64');
}

/**
 * RSA 解密
 */
function rsaDecrypt(encrypted, privateKey) {
  const buffer = Buffer.from(encrypted, 'base64');
  const decrypted = crypto.privateDecrypt(
    {
      key: privateKey,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    buffer
  );
  
  return decrypted.toString('utf8');
}

// 導出工具庫
export default {
  aes: {
    encrypt: aesEncrypt,
    decrypt: aesDecrypt,
  },
  rsa: {
    generateKeyPair: generateRSAKeyPair,
    encrypt: rsaEncrypt,
    decrypt: rsaDecrypt,
  },
  hash: {
    sha256: (data) => crypto.createHash('sha256').update(data).digest('hex'),
    sha512: (data) => crypto.createHash('sha512').update(data).digest('hex'),
    md5: (data) => crypto.createHash('md5').update(data).digest('hex'),
    hmac: (data, secret) => crypto.createHmac('sha256', secret).update(data).digest('hex'),
  },
  random: {
    uuid: () => crypto.randomUUID(),
    bytes: (length) => crypto.randomBytes(length).toString('hex'),
    number: (min, max) => crypto.randomInt(min, max + 1),
  }
};

在 Strapi 中全局註冊:

// server/src/bootstrap.js
import cryptoUtils from './utils/crypto-utils';

module.exports = ({ strapi }) => {
  // 註冊全局加密工具
  strapi.crypto = cryptoUtils;
  
  // 添加配置輔助方法
  strapi.crypto.config = {
    getAesKey: () => {
      const config = strapi.plugin('bag-strapi-plugin').config('crypto') || {};
      return config.aesKey || process.env.CRYPTO_AES_KEY;
    },
    getHmacSecret: () => {
      const config = strapi.plugin('bag-strapi-plugin').config('crypto') || {};
      return config.hmacSecret || process.env.CRYPTO_HMAC_SECRET;
    },
  };

  strapi.log.info('✅ bag-strapi-plugin 加密工具已初始化');
};

使用示例:

// 在任何控制器或服務中使用
module.exports = {
  async encryptData(ctx) {
    const { data } = ctx.request.body;
    
    // AES 加密
    const aesKey = strapi.crypto.config.getAesKey();
    const encrypted = strapi.crypto.aes.encrypt(data, aesKey);
    
    // RSA 加密
    const { publicKey, privateKey } = strapi.crypto.rsa.generateKeyPair();
    const rsaEncrypted = strapi.crypto.rsa.encrypt(data, publicKey);
    
    // Hash
    const hash = strapi.crypto.hash.sha256(data);
    
    ctx.send({
      aes: encrypted,
      rsa: rsaEncrypted,
      hash: hash,
    });
  }
};

5. 📋 菜單數據庫表

插件會自動創建菜單數據庫表,包含 16 個完整字段:

// server/src/content-types/bag-menu-schema.json
{
  "kind": "collectionType",
  "collectionName": "bag_plugin_menus",
  "info": {
    "singularName": "bag-menu",
    "pluralName": "bag-menus",
    "displayName": "Bag Menu",
    "description": "菜單管理表"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {
    "content-manager": {
      "visible": true
    },
    "content-type-builder": {
      "visible": true
    }
  },
  "attributes": {
    "name": {
      "type": "string",
      "required": true,
      "unique": false
    },
    "path": {
      "type": "string",
      "required": true
    },
    "component": {
      "type": "string"
    },
    "icon": {
      "type": "string"
    },
    "parentId": {
      "type": "integer",
      "default": 0
    },
    "sort": {
      "type": "integer",
      "default": 0
    },
    "isHidden": {
      "type": "boolean",
      "default": false
    },
    "permissions": {
      "type": "json"
    },
    "meta": {
      "type": "json"
    },
    "locale": {
      "type": "string",
      "default": "zh"
    }
  }
}

菜單 CRUD 服務:

// server/src/services/menu.js
module.exports = ({ strapi }) => ({
  // 獲取所有菜單
  async findAll(params = {}) {
    return await strapi.db.query('plugin::bag-strapi-plugin.bag-menu').findMany({
      where: params.where || {},
      orderBy: { sort: 'asc' },
    });
  },

  // 獲取樹形菜單
  async getMenuTree(parentId = 0) {
    const menus = await this.findAll({
      where: { parentId },
    });

    // 遞歸獲取子菜單
    for (let menu of menus) {
      menu.children = await this.getMenuTree(menu.id);
    }

    return menus;
  },

  // 創建菜單
  async create(data) {
    return await strapi.db.query('plugin::bag-strapi-plugin.bag-menu').create({
      data,
    });
  },

  // 更新菜單
  async update(id, data) {
    return await strapi.db.query('plugin::bag-strapi-plugin.bag-menu').update({
      where: { id },
      data,
    });
  },

  // 刪除菜單
  async delete(id) {
    return await strapi.db.query('plugin::bag-strapi-plugin.bag-menu').delete({
      where: { id },
    });
  },
});

6. ✍️ 簽名驗證中間件

支持三種驗證模式:簡單簽名、加密簽名、一次性簽名。

// server/src/middlewares/sign-verify.js
module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    try {
      const signConfig = strapi.plugin('bag-strapi-plugin').config('signVerify') || {};

      // 未啓用簽名驗證
      if (!signConfig.enabled) {
        return await next();
      }

      // 檢查白名單
      const whitelist = signConfig.whitelist || [];
      if (whitelist.some(pattern => ctx.request.url.match(pattern))) {
        return await next();
      }

      // 獲取簽名
      const sign = ctx.request.headers['sign'] || ctx.request.query.sign;

      if (!sign) {
        return ctx.unauthorized('缺少簽名');
      }

      // 驗證模式
      const mode = signConfig.mode || 'simple';
      let isValid = false;

      if (mode === 'simple') {
        // 簡單簽名驗證
        const validSigns = signConfig.validSigns || [];
        isValid = validSigns.includes(sign);
      } else if (mode === 'encrypted') {
        // 加密簽名驗證
        try {
          const aesKey = strapi.crypto.config.getAesKey();
          const decrypted = strapi.crypto.aes.decrypt(
            sign.encrypted,
            aesKey,
            sign.iv,
            sign.authTag
          );
          isValid = decrypted.includes('bag');
        } catch (error) {
          isValid = false;
        }
      } else if (mode === 'once') {
        // 一次性簽名驗證
        const storedSign = await strapi.cache.get(`sign:${sign}`);
        if (storedSign) {
          await strapi.cache.del(`sign:${sign}`);
          isValid = true;
        }
      }

      if (!isValid) {
        return ctx.unauthorized('簽名驗證失敗');
      }

      await next();
    } catch (error) {
      strapi.log.error('簽名驗證失敗:', error);
      ctx.internalServerError('簽名驗證失敗');
    }
  };
};

🎨 使用 Cursor 的開發技巧

1. 智能代碼生成

場景: 需要創建用户表的 Schema

我的操作:

  1. 打開 Cursor,按 Cmd+K(或 Ctrl+K)打開聊天
  2. 輸入提示詞:
創建一個 Strapi 5 插件的用户內容類型,包含以下字段:
- username: 用户名(唯一)
- email: 郵箱(唯一)
- password: 密碼
- nickname: 暱稱
- avatar: 頭像 URL
- phone: 手機號
- isActive: 是否激活
- lastLoginAt: 最後登錄時間
- createdAt: 創建時間
- updatedAt: 更新時間

Cursor 生成的代碼:

{
  "kind": "collectionType",
  "collectionName": "bag_users",
  "info": {
    "singularName": "bag-user",
    "pluralName": "bag-users",
    "displayName": "Bag User"
  },
  "options": {
    "draftAndPublish": false,
    "timestamps": true
  },
  "attributes": {
    "username": {
      "type": "string",
      "required": true,
      "unique": true,
      "minLength": 3,
      "maxLength": 30
    },
    "email": {
      "type": "email",
      "required": true,
      "unique": true
    },
    "password": {
      "type": "string",
      "required": true,
      "private": true
    },
    "nickname": {
      "type": "string"
    },
    "avatar": {
      "type": "string"
    },
    "phone": {
      "type": "string",
      "unique": true
    },
    "isActive": {
      "type": "boolean",
      "default": true
    },
    "lastLoginAt": {
      "type": "datetime"
    }
  }
}

2. 代碼重構

場景: 優化重複的錯誤處理代碼

操作:

  1. 選中重複的代碼塊
  2. 右鍵 → "Ask Cursor"
  3. 輸入:將這些重複的 try-catch 封裝成一個通用的錯誤處理函數

Cursor 的建議:

// utils/error-handler.js
const handleAsyncError = (fn) => {
  return async (ctx, next) => {
    try {
      await fn(ctx, next);
    } catch (error) {
      strapi.log.error('請求失敗:', error);
      
      if (error.name === 'ValidationError') {
        return ctx.badRequest(error.message);
      }
      
      if (error.name === 'UnauthorizedError') {
        return ctx.unauthorized(error.message);
      }
      
      ctx.internalServerError('服務器內部錯誤');
    }
  };
};

// 使用
module.exports = {
  register: handleAsyncError(async (ctx) => {
    const { username, email, password } = ctx.request.body;
    // ... 業務邏輯
  }),
};

3. 文檔生成

場景: 為加密工具生成文檔

操作:

  1. 選中 crypto-utils.js 文件
  2. Cmd+L 打開側邊欄聊天
  3. 輸入:為這個文件生成完整的 README 文檔,包括所有函數的使用示例

Cursor 會自動生成包含所有 API 説明、參數説明、返回值説明和使用示例的完整文檔。

4. Bug 修復

場景: 限流中間件偶爾會報錯

操作:

  1. 在出錯的文件中按 Cmd+K
  2. 輸入:這個限流中間件在高併發時會出錯,幫我找出問題並修復

Cursor 的分析:

問題分析:
1. RateLimiter 實例在每次請求時都重新創建,效率低下
2. Redis 連接沒有複用,可能導致連接池耗盡
3. 缺少對 Redis 連接失敗的容錯處理

建議修復:
1. 將 RateLimiter 實例緩存起來
2. 複用 Redis 連接
3. 添加降級策略,Redis 失敗時使用內存存儲

5. 測試用例生成

操作:

  1. 選中 auth.js 控制器
  2. 輸入:為這個認證控制器生成完整的單元測試

生成的測試代碼:

// server/tests/auth.test.js
const request = require('supertest');

describe('Auth Controller', () => {
  let app;
  let testUser;

  beforeAll(async () => {
    app = await setupStrapi();
  });

  afterAll(async () => {
    await cleanupStrapi(app);
  });

  describe('POST /auth/register', () => {
    it('應該成功註冊新用户', async () => {
      const response = await request(app)
        .post('/bag-strapi-plugin/auth/register')
        .send({
          username: 'testuser',
          email: 'test@example.com',
          password: 'password123',
        });

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.user.username).toBe('testuser');
      expect(response.body.data.token).toBeDefined();

      testUser = response.body.data.user;
    });

    it('應該拒絕重複的用户名', async () => {
      const response = await request(app)
        .post('/bag-strapi-plugin/auth/register')
        .send({
          username: 'testuser',
          email: 'another@example.com',
          password: 'password123',
        });

      expect(response.status).toBe(400);
      expect(response.body.success).toBe(false);
    });
  });

  describe('POST /auth/login', () => {
    it('應該成功登錄', async () => {
      const response = await request(app)
        .post('/bag-strapi-plugin/auth/login')
        .send({
          identifier: 'testuser',
          password: 'password123',
        });

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.token).toBeDefined();
    });

    it('應該拒絕錯誤的密碼', async () => {
      const response = await request(app)
        .post('/bag-strapi-plugin/auth/login')
        .send({
          identifier: 'testuser',
          password: 'wrongpassword',
        });

      expect(response.status).toBe(400);
      expect(response.body.success).toBe(false);
    });
  });
});

🐛 開發過程中的踩坑經驗

1. Strapi 5 插件路由註冊

問題: 路由無法訪問,總是返回 404

原因: Strapi 5 的路由註冊方式與 v4 不同

解決方案:

// ❌ 錯誤的方式(Strapi v4)
module.exports = {
  routes: [
    {
      method: 'GET',
      path: '/test',
      handler: 'controller.test',
    }
  ]
};

// ✅ 正確的方式(Strapi 5)
module.exports = {
  type: 'content-api', // 或 'admin'
  routes: [
    {
      method: 'GET',
      path: '/test',
      handler: 'controller.test',
      config: {
        policies: [],
        middlewares: [],
      },
    }
  ]
};

2. 中間件執行順序

問題: 簽名驗證中間件總是在 JWT 驗證之後執行

原因: 沒有正確配置中間件的加載順序

解決方案:

// config/middlewares.js
module.exports = [
  'strapi::logger',
  'strapi::errors',
  'strapi::security',
  'strapi::cors',
  // 先加載簽名驗證
  'plugin::bag-strapi-plugin.sign-verify',
  // 再加載 JWT
  'plugin::bag-strapi-plugin.jwt-auth',
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

3. 加密工具全局註冊

問題: 在控制器中調用 strapi.crypto 時提示 undefined

原因: 全局對象需要在 bootstrap 中註冊,且要等待 Strapi 完全啓動

解決方案:

// server/src/bootstrap.js
module.exports = async ({ strapi }) => {
  // ✅ 使用 async/await 確保初始化完成
  await Promise.resolve();
  
  // 註冊全局對象
  strapi.crypto = cryptoUtils;
  
  strapi.log.info('✅ 加密工具已註冊');
};

4. 數據庫表創建時機

問題: 菜單表有時候不會自動創建

原因: Content-Type 需要在正確的生命週期鈎子中註冊

解決方案:

// server/src/register.js
module.exports = ({ strapi }) => {
  // 在 register 階段註冊 Content-Type
  strapi.contentTypes = {
    ...strapi.contentTypes,
    'plugin::bag-strapi-plugin.bag-menu': require('./content-types/bag-menu'),
    'plugin::bag-strapi-plugin.bag-user': require('./content-types/bag-user'),
  };
};

5. Redis 連接池問題

問題: 高併發時限流中間件報 "Too many connections" 錯誤

原因: 每次請求都創建新的 Redis 連接

解決方案:

// 創建單例 Redis 連接
let redisClient = null;

const getRedisClient = () => {
  if (!redisClient) {
    const Redis = require('ioredis');
    const config = strapi.plugin('bag-strapi-plugin').config('rateLimit.redis');
    redisClient = new Redis({
      ...config,
      maxRetriesPerRequest: 3,
      enableOfflineQueue: false,
      lazyConnect: true,
    });
  }
  return redisClient;
};

6. 環境變量加載問題

問題: 在插件中無法讀取 .env 文件中的環境變量

原因: Strapi 5 需要顯式配置環境變量的加載

解決方案:

// 在 config/plugins.js 中使用 env() 輔助函數
module.exports = ({ env }) => ({
  'bag-strapi-plugin': {
    enabled: true,
    config: {
      auth: {
        jwt: {
          // ✅ 使用 env() 函數,支持默認值
          secret: env('JWT_SECRET', 'default-secret'),
          expiresIn: env('JWT_EXPIRES_IN', '7d'),
        },
      },
    },
  },
});

📦 插件打包與發佈

1. 配置 package.json

{
  "name": "bag-strapi-plugin",
  "version": "0.0.4",
  "description": "bag-strapi-plugin provide a commonly used plugin for management",
  "strapi": {
    "kind": "plugin",
    "name": "bag-strapi-plugin",
    "displayName": "Bag Plugin",
    "description": "通用功能插件"
  },
  "keywords": [
    "strapi",
    "strapi-plugin",
    "authentication",
    "rate-limit",
    "encryption",
    "menu"
  ],
  "scripts": {
    "build": "strapi-plugin build",
    "watch": "strapi-plugin watch",
    "verify": "strapi-plugin verify"
  },
  "files": [
    "dist",
    "README.md",
    "docs/*.md"
  ],
  "dependencies": {
    "@strapi/design-system": "^2.0.0-rc.30",
    "@strapi/icons": "2.0.0-rc.30",
    "bcrypt": "^5.1.1",
    "jsonwebtoken": "^9.0.2",
    "rate-limiter-flexible": "^5.0.3",
    "svg-captcha": "^1.4.0"
  },
  "peerDependencies": {
    "@strapi/strapi": "^5.28.0"
  }
}

2. 使用 yalc 本地測試

# 在插件項目中
npm run build
yalc publish

# 在 Strapi 項目中
yalc add bag-strapi-plugin
npm install

# 啓動測試
npm run develop

3. 發佈到 npm

# 登錄 npm
npm login

# 發佈
npm publish

# 如果是 scoped package
npm publish --access public

4. 版本管理

# 補丁版本(bug 修復)
npm version patch

# 次版本(新功能)
npm version minor

# 主版本(破壞性更新)
npm version major

# 發佈新版本
git push --tags
npm publish

📚 文檔編寫

使用 VitePress 構建文檔站點

# 安裝 VitePress
pnpm add -D vitepress

# 初始化文檔目錄
mkdir docs

配置文件 .vitepress/config.mjs

import { defineConfig } from 'vitepress'

export default defineConfig({
  title: "bag-strapi-plugin",
  description: "Strapi 通用功能插件",
  themeConfig: {
    nav: [
      { text: '指南', link: '/guide/introduction' },
      { text: 'API', link: '/api/overview' },
      { text: 'GitHub', link: 'https://github.com/hangjob/bag-strapi-plugin' }
    ],
    sidebar: {
      '/guide/': [
        {
          text: '開始',
          items: [
            { text: '簡介', link: '/guide/introduction' },
            { text: '快速開始', link: '/guide/quick-start' },
            { text: '安裝', link: '/guide/installation' },
            { text: '配置', link: '/guide/configuration' },
          ]
        },
        {
          text: '功能',
          items: [
            { text: 'JWT 認證', link: '/features/auth' },
            { text: '驗證碼', link: '/features/captcha' },
            { text: 'API 限流', link: '/features/rate-limit' },
            { text: '加密工具', link: '/features/crypto' },
            { text: '菜單管理', link: '/features/menu' },
          ]
        }
      ],
      '/api/': [
        {
          text: 'API 文檔',
          items: [
            { text: '概述', link: '/api/overview' },
            { text: '認證 API', link: '/api/auth' },
            { text: '驗證碼 API', link: '/api/captcha' },
            { text: '加密 API', link: '/api/crypto' },
          ]
        }
      ]
    },
    socialLinks: [
      { icon: 'github', link: 'https://github.com/hangjob/bag-strapi-plugin' }
    ]
  }
})

部署文檔:

# 構建文檔
npm run docs:build

# 預覽
npm run docs:preview

# 部署到 GitHub Pages
# 在 .github/workflows/deploy.yml
name: Deploy Docs

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm install
      - run: npm run docs:build
      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: docs_web

🎯 使用 Cursor 的心得總結

優勢

  1. 極大提高開發效率 - 編寫代碼速度提升 3-5 倍
  2. 減少低級錯誤 - AI 自動處理邊界情況和錯誤處理
  3. 學習新技術更快 - 可以直接問 Cursor 關於 Strapi 5 的新特性
  4. 代碼質量提升 - AI 生成的代碼通常符合最佳實踐
  5. 文檔編寫輕鬆 - 自動生成註釋和文檔

最佳實踐

  1. 清晰的提示詞 - 提供具體的需求和上下文
  2. 分步開發 - 將大功能拆分成小任務,逐個完成
  3. 人工審查 - AI 生成的代碼需要人工審查和測試
  4. 保持迭代 - 通過對話不斷優化代碼
  5. 建立代碼規範 - 讓 Cursor 學習你的代碼風格

注意事項

  1. 不要盲目信任 - AI 生成的代碼可能有 bug
  2. 理解原理 - 要理解代碼的工作原理,不能只複製粘貼
  3. 安全審查 - 涉及安全的代碼必須仔細審查
  4. 版本兼容 - 確認生成的代碼與項目版本兼容
  5. 性能優化 - AI 可能不會考慮性能問題,需要人工優化

🚀 插件使用示例

在 Strapi 項目中安裝

npm install bag-strapi-plugin

配置插件

// config/plugins.js
module.exports = ({ env }) => ({
  'bag-strapi-plugin': {
    enabled: true,
    config: {
      // JWT 認證
      auth: {
        enableCaptcha: true,
        jwt: {
          secret: env('JWT_SECRET'),
          expiresIn: '7d',
        },
      },
      
      // API 限流
      rateLimit: {
        enabled: true,
        points: 100,
        duration: 60,
      },
      
      // 加密工具
      crypto: {
        aesKey: env('CRYPTO_AES_KEY'),
        hmacSecret: env('CRYPTO_HMAC_SECRET'),
      },
    },
  },
});

前端集成

// 用户註冊
const register = async (userData) => {
  const response = await fetch('/bag-strapi-plugin/auth/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData),
  });
  return response.json();
};

// 用户登錄
const login = async (identifier, password) => {
  const response = await fetch('/bag-strapi-plugin/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier, password }),
  });
  const result = await response.json();
  if (result.success) {
    localStorage.setItem('token', result.data.token);
  }
  return result;
};

// 獲取當前用户
const getCurrentUser = async () => {
  const token = localStorage.getItem('token');
  const response = await fetch('/bag-strapi-plugin/auth/me', {
    headers: { 'Authorization': `Bearer ${token}` },
  });
  return response.json();
};

// 獲取驗證碼
const getCaptcha = async () => {
  const response = await fetch('/bag-strapi-plugin/captcha/image');
  return response.json();
};

📊 項目數據

  • 代碼量: 約 5000+ 行
  • 開發時間: 使用 Cursor 僅用 2 周(傳統開發預計需要 1-2 個月)
  • 文檔數量: 25+ 個 Markdown 文檔
  • 功能模塊: 6 大核心模塊
  • npm 下載量: 持續增長中

🎓 總結與展望

開發收穫

通過這次使用 Cursor 開發 Strapi 5 插件的經歷,我深刻體會到:

  1. AI 輔助編程已經成為現實 - Cursor 大幅提升了開發效率
  2. 代碼質量可以更高 - AI 幫助我們避免很多低級錯誤
  3. 學習新技術更快 - 通過與 AI 對話快速掌握 Strapi 5
  4. 文檔編寫不再痛苦 - AI 可以生成高質量的文檔
  5. 專注於業務邏輯 - 將重複性工作交給 AI
user avatar dirackeeko 頭像 littlelyon 頭像 chongdianqishi 頭像 razyliang 頭像 anchen_5c17815319fb5 頭像 hard_heart_603dd717240e2 頭像 xiaoxxuejishu 頭像 zzd41 頭像 zhulongxu 頭像 ccVue 頭像 jdcdevloper 頭像 youyoufei 頭像
點贊 66 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.