🎯 為什麼開發 bag-strapi-plugin?
問題的起源
在使用 Strapi 5 開發多個項目後,我發現每個項目都需要重複實現一些通用功能:
- 用户認證系統 - JWT Token 管理、密碼加密、登錄註冊
- API 安全保護 - 簽名驗證、限流防刷、加密傳輸
- 驗證碼功能 - 圖形驗證碼、短信驗證碼
- 菜單管理 - 後台菜單的數據庫設計和 CRUD
- 加密工具 - AES、RSA、Hash 等加密算法的封裝
每次都要從頭寫這些功能,不僅耗時,而且容易出現安全漏洞。於是我決定開發一個插件,將這些通用功能封裝起來,實現一次開發、多處複用
背景
我一直在使用Strapi、Egg.js 、ThinkPhp 在開發端管理系統,很少在寫後端插件,今天藉助Ai的勢力開發一個插件
技術
選擇是Strapi5,官方文檔寫的有插件開發,但是寫的也沒有具體的例子(哈哈哈哈哈哈,主要是自己英文菜哈),言歸正傳,我們開始
在線預覽地址、
Github源碼地址
搭建項目
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 的優勢體現
- 智能補全 - 在輸入
bcrypt.時,Cursor 自動提示hash()和compare()方法 - 錯誤處理 - Cursor 自動添加了完善的 try-catch 和錯誤提示
- 安全最佳實踐 - 自動使用 bcrypt 加密,移除返回數據中的密碼字段
- 代碼風格 - 生成的代碼符合 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
我的操作:
- 打開 Cursor,按
Cmd+K(或Ctrl+K)打開聊天 - 輸入提示詞:
創建一個 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. 代碼重構
場景: 優化重複的錯誤處理代碼
操作:
- 選中重複的代碼塊
- 右鍵 → "Ask Cursor"
- 輸入:
將這些重複的 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. 文檔生成
場景: 為加密工具生成文檔
操作:
- 選中
crypto-utils.js文件 - 按
Cmd+L打開側邊欄聊天 - 輸入:
為這個文件生成完整的 README 文檔,包括所有函數的使用示例
Cursor 會自動生成包含所有 API 説明、參數説明、返回值説明和使用示例的完整文檔。
4. Bug 修復
場景: 限流中間件偶爾會報錯
操作:
- 在出錯的文件中按
Cmd+K - 輸入:
這個限流中間件在高併發時會出錯,幫我找出問題並修復
Cursor 的分析:
問題分析:
1. RateLimiter 實例在每次請求時都重新創建,效率低下
2. Redis 連接沒有複用,可能導致連接池耗盡
3. 缺少對 Redis 連接失敗的容錯處理
建議修復:
1. 將 RateLimiter 實例緩存起來
2. 複用 Redis 連接
3. 添加降級策略,Redis 失敗時使用內存存儲
5. 測試用例生成
操作:
- 選中
auth.js控制器 - 輸入:
為這個認證控制器生成完整的單元測試
生成的測試代碼:
// 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 的心得總結
優勢
- 極大提高開發效率 - 編寫代碼速度提升 3-5 倍
- 減少低級錯誤 - AI 自動處理邊界情況和錯誤處理
- 學習新技術更快 - 可以直接問 Cursor 關於 Strapi 5 的新特性
- 代碼質量提升 - AI 生成的代碼通常符合最佳實踐
- 文檔編寫輕鬆 - 自動生成註釋和文檔
最佳實踐
- 清晰的提示詞 - 提供具體的需求和上下文
- 分步開發 - 將大功能拆分成小任務,逐個完成
- 人工審查 - AI 生成的代碼需要人工審查和測試
- 保持迭代 - 通過對話不斷優化代碼
- 建立代碼規範 - 讓 Cursor 學習你的代碼風格
注意事項
- 不要盲目信任 - AI 生成的代碼可能有 bug
- 理解原理 - 要理解代碼的工作原理,不能只複製粘貼
- 安全審查 - 涉及安全的代碼必須仔細審查
- 版本兼容 - 確認生成的代碼與項目版本兼容
- 性能優化 - 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 插件的經歷,我深刻體會到:
- AI 輔助編程已經成為現實 - Cursor 大幅提升了開發效率
- 代碼質量可以更高 - AI 幫助我們避免很多低級錯誤
- 學習新技術更快 - 通過與 AI 對話快速掌握 Strapi 5
- 文檔編寫不再痛苦 - AI 可以生成高質量的文檔
- 專注於業務邏輯 - 將重複性工作交給 AI