diff --git a/.env.example b/.env.example index 99e5a63..4f23125 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,22 @@ -DATABASE_URL=mongodb://127.0.0.1/your-database-name +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/database + +# Payload PAYLOAD_SECRET=YOUR_SECRET_HERE + +# Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# 公开 API 密钥(用于外部调用 /api/public/* 端点) +PUBLIC_API_KEY=your-secret-api-key-here + +# Cloudflare R2 配置 +CLOUDFLARE_R2_BUCKET=your-bucket +CLOUDFLARE_R2_ACCESS_KEY_ID=your-access-key +CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-secret-key +CLOUDFLARE_R2_REGION=auto +CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com +CLOUDFLARE_R2_PUBLIC_URL=https://your-public-domain.com diff --git a/package.json b/package.json index d6c9662..29f469a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "payload": "3.75.0", "react": "19.2.1", "react-dom": "19.2.1", + "redis": "^5.10.0", "sharp": "0.34.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f239e5..b1914ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: react-dom: specifier: 19.2.1 version: 19.2.1(react@19.2.1) + redis: + specifier: ^5.10.0 + version: 5.10.0 sharp: specifier: 0.34.2 version: 0.34.2 @@ -1640,6 +1643,34 @@ packages: engines: {node: '>=18'} hasBin: true + '@redis/bloom@5.10.0': + resolution: {integrity: sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/client@5.10.0': + resolution: {integrity: sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==} + engines: {node: '>= 18'} + + '@redis/json@5.10.0': + resolution: {integrity: sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/search@5.10.0': + resolution: {integrity: sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/time-series@5.10.0': + resolution: {integrity: sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.10.0 + '@rolldown/pluginutils@1.0.0-beta.11': resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} @@ -2550,6 +2581,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4133,6 +4168,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis@5.10.0: + resolution: {integrity: sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==} + engines: {node: '>= 18'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6750,6 +6789,26 @@ snapshots: dependencies: playwright: 1.56.1 + '@redis/bloom@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/client@5.10.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/search@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/time-series@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + '@rolldown/pluginutils@1.0.0-beta.11': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -7759,6 +7818,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9634,6 +9695,14 @@ snapshots: real-require@0.2.0: {} + redis@5.10.0: + dependencies: + '@redis/bloom': 5.10.0(@redis/client@5.10.0) + '@redis/client': 5.10.0 + '@redis/json': 5.10.0(@redis/client@5.10.0) + '@redis/search': 5.10.0(@redis/client@5.10.0) + '@redis/time-series': 5.10.0(@redis/client@5.10.0) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts new file mode 100644 index 0000000..772f7c2 --- /dev/null +++ b/src/app/api/cache/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCacheStats, clearAllCache, deleteCachePattern, connectRedis } from '@/lib/redis' +import { getPayload } from 'payload' +import config from '@payload-config' + +/** + * GET /api/cache + * 获取缓存统计信息 + */ +export async function GET(req: NextRequest) { + try { + // 验证用户权限 + const payload = await getPayload({ config }) + const headers = req.headers + const token = headers.get('authorization')?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + // 验证用户是否为管理员 + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !user.roles?.includes('admin')) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + + // 获取统计信息 + const stats = await getCacheStats() + + return NextResponse.json({ + success: true, + stats, + }) + } catch (error) { + console.error('Cache stats error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get cache stats', + }, + { status: 500 }, + ) + } +} + +/** + * DELETE /api/cache + * 清除缓存 + * Query params: + * - pattern: 匹配模式(可选),例如 "products:*" + */ +export async function DELETE(req: NextRequest) { + try { + // 验证用户权限 + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !user.roles?.includes('admin')) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + + // 确保 Redis 已连接 + await connectRedis() + + // 获取查询参数 + const searchParams = req.nextUrl.searchParams + const pattern = searchParams.get('pattern') + + let deletedCount = 0 + let message = '' + + if (pattern) { + // 删除匹配模式的缓存 + deletedCount = await deleteCachePattern(pattern) + message = `已清除 ${deletedCount} 个匹配 "${pattern}" 的缓存键` + } else { + // 清除所有缓存 + deletedCount = await clearAllCache() + message = `已清除所有缓存,共 ${deletedCount} 个键` + } + + return NextResponse.json({ + success: true, + message, + deletedCount, + }) + } catch (error) { + console.error('Cache clear error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear cache', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/public/products/[id]/route.ts b/src/app/api/public/products/[id]/route.ts new file mode 100644 index 0000000..e371ad7 --- /dev/null +++ b/src/app/api/public/products/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { getCache, setCache } from '@/lib/redis' + +/** + * GET /api/public/products/[id] + * 获取单个产品详情(带缓存) + */ +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params + + // 生成缓存 key + const cacheKey = `products:detail:${id}` + + // 尝试从缓存获取 + const cached = await getCache(cacheKey) + if (cached) { + return NextResponse.json({ + success: true, + data: cached, + cached: true, + }) + } + + // 从数据库获取 + const payload = await getPayload({ config }) + const result = await payload.findByID({ + collection: 'products', + id, + depth: 2, + }) + + // 只有已发布的产品才返回 + if (result.status !== 'published') { + return NextResponse.json( + { + success: false, + error: 'Product not found or not published', + }, + { status: 404 }, + ) + } + + // 缓存结果(1 小时) + await setCache(cacheKey, result, 3600) + + return NextResponse.json({ + success: true, + data: result, + cached: false, + }) + } catch (error) { + console.error('Product detail API error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch product', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/public/products/route.ts b/src/app/api/public/products/route.ts new file mode 100644 index 0000000..fbe89d0 --- /dev/null +++ b/src/app/api/public/products/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { getCache, setCache } from '@/lib/redis' + +/** + * GET /api/public/products + * 获取产品列表(带缓存) + */ +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams + const page = parseInt(searchParams.get('page') || '1', 10) + const limit = parseInt(searchParams.get('limit') || '10', 10) + const status = searchParams.get('status') || 'published' + + // 生成缓存 key + const cacheKey = `products:list:page=${page}:limit=${limit}:status=${status}` + + // 尝试从缓存获取 + const cached = await getCache(cacheKey) + if (cached) { + return NextResponse.json({ + success: true, + data: cached, + cached: true, + }) + } + + // 从数据库获取 + const payload = await getPayload({ config }) + const result = await payload.find({ + collection: 'products', + where: { + status: { equals: status }, + }, + page, + limit, + depth: 1, + }) + + // 缓存结果(1 小时) + await setCache(cacheKey, result, 3600) + + return NextResponse.json({ + success: true, + data: result, + cached: false, + }) + } catch (error) { + console.error('Products API error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch products', + }, + { status: 500 }, + ) + } +} diff --git a/src/collections/Announcements.ts b/src/collections/Announcements.ts index 8e20f46..9e633c5 100644 --- a/src/collections/Announcements.ts +++ b/src/collections/Announcements.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { logAfterChange, logAfterDelete } from '../hooks/logAction' +import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation' import { BlocksFeature, BoldFeature, diff --git a/src/collections/Articles.ts b/src/collections/Articles.ts index 2e11987..f84d2f9 100644 --- a/src/collections/Articles.ts +++ b/src/collections/Articles.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { logAfterChange, logAfterDelete } from '../hooks/logAction' +import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation' import { BlocksFeature, BoldFeature, @@ -331,7 +332,7 @@ export const Articles: CollectionConfig = { return data }, ], - afterChange: [logAfterChange], - afterDelete: [logAfterDelete], + afterChange: [logAfterChange, cacheAfterChange], + afterDelete: [logAfterDelete, cacheAfterDelete], }, } diff --git a/src/collections/Products.ts b/src/collections/Products.ts index 41f04a7..6d105ef 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { logAfterChange, logAfterDelete } from '../hooks/logAction' +import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation' import { AlignFeature, BlocksFeature, @@ -202,8 +203,8 @@ export const Products: CollectionConfig = { }, ], hooks: { - afterChange: [logAfterChange], - afterDelete: [logAfterDelete], + afterChange: [logAfterChange, cacheAfterChange], + afterDelete: [logAfterDelete, cacheAfterDelete], }, timestamps: true, } diff --git a/src/components/views/AdminPanel.tsx b/src/components/views/AdminPanel.tsx index 49756a6..0b5749f 100644 --- a/src/components/views/AdminPanel.tsx +++ b/src/components/views/AdminPanel.tsx @@ -12,6 +12,11 @@ export default function AdminPanel() { const [clearMessage, setClearMessage] = useState('') const [showClearConfirm, setShowClearConfirm] = useState(false) + // 缓存相关状态 + const [cacheStats, setCacheStats] = useState(null) + const [cacheLoading, setCacheLoading] = useState(false) + const [cacheMessage, setCacheMessage] = useState('') + const handleClearData = () => { setShowClearConfirm(true) setClearMessage('') @@ -46,6 +51,79 @@ export default function AdminPanel() { setClearMessage('') } + // 获取缓存状态 + const fetchCacheStats = async () => { + setCacheLoading(true) + try { + const response = await fetch('/api/cache') + const data = await response.json() + if (data.success) { + setCacheStats(data.stats) + } + } catch (error) { + console.error('Failed to fetch cache stats:', error) + } finally { + setCacheLoading(false) + } + } + + // 清除所有缓存 + const handleClearAllCache = async () => { + if (!confirm('确定要清除所有 Redis 缓存吗?')) return + + setCacheLoading(true) + setCacheMessage('') + + try { + const response = await fetch('/api/cache', { + method: 'DELETE', + }) + + const data = await response.json() + + if (data.success) { + setCacheMessage(data.message) + fetchCacheStats() // 刷新统计 + } else { + setCacheMessage(`清除失败: ${data.error}`) + } + } catch (error) { + setCacheMessage(`清除出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setCacheLoading(false) + } + } + + // 清除产品缓存 + const handleClearProductsCache = async () => { + setCacheLoading(true) + setCacheMessage('') + + try { + const response = await fetch('/api/cache?pattern=products:*', { + method: 'DELETE', + }) + + const data = await response.json() + + if (data.success) { + setCacheMessage(data.message) + fetchCacheStats() // 刷新统计 + } else { + setCacheMessage(`清除失败: ${data.error}`) + } + } catch (error) { + setCacheMessage(`清除出错: ${error instanceof Error ? error.message : '未知错误'}`) + } finally { + setCacheLoading(false) + } + } + + // 组件加载时获取缓存状态 + React.useEffect(() => { + fetchCacheStats() + }, []) + return (

@@ -151,6 +229,155 @@ export default function AdminPanel() {

+ {/* Redis 缓存管理区域 */} +
+

+ 🚀 Redis 缓存管理 +

+ + {/* 缓存统计 */} +
+

📊 缓存统计

+ + {cacheStats ? ( +
+
+
+ 连接状态 +
+
+ {cacheStats.connected ? '✅ 已连接' : '❌ 未连接'} +
+
+
+
+ 缓存键数量 +
+
{cacheStats.totalKeys}
+
+
+
+ 内存使用 +
+
+ {cacheStats.memoryUsage} +
+
+
+ ) : ( +
+ {cacheLoading ? '加载中...' : '无法获取缓存统计'} +
+ )} + +
+ +
+
+ + {/* 清除缓存操作 */} +
+

+ 🗑️ 清除缓存 +

+

+ 清除 Redis 中的缓存数据,所有 key 前缀为 payload: +

+ +
+ + +
+ + {cacheMessage && ( +
+ {cacheMessage} +
+ )} +
+
+ {/* 系统信息区域 */}
ℹ️ 系统信息 -
@@ -181,6 +408,9 @@ export default function AdminPanel() {
存储: Cloudflare R2 (S3 API)
+
+ 缓存: Redis +
diff --git a/src/hooks/cacheInvalidation.ts b/src/hooks/cacheInvalidation.ts new file mode 100644 index 0000000..a11af59 --- /dev/null +++ b/src/hooks/cacheInvalidation.ts @@ -0,0 +1,46 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' +import { deleteCachePattern } from '../lib/redis' + +/** + * afterChange hook - 数据变更后清除相关缓存 + */ +export const cacheAfterChange: CollectionAfterChangeHook = async ({ doc, req, collection }) => { + const collectionSlug = collection?.slug + + if (!collectionSlug) return doc + + try { + // 清除该 collection 的所有缓存 + const deletedCount = await deleteCachePattern(`${collectionSlug}:*`) + + console.log( + `[Cache] Cleared ${deletedCount} cache keys for collection: ${collectionSlug} after change`, + ) + } catch (error) { + console.error(`[Cache] Failed to clear cache for ${collectionSlug}:`, error) + } + + return doc +} + +/** + * afterDelete hook - 数据删除后清除相关缓存 + */ +export const cacheAfterDelete: CollectionAfterDeleteHook = async ({ doc, req, id, collection }) => { + const collectionSlug = collection?.slug + + if (!collectionSlug) return doc + + try { + // 清除该 collection 的所有缓存 + const deletedCount = await deleteCachePattern(`${collectionSlug}:*`) + + console.log( + `[Cache] Cleared ${deletedCount} cache keys for collection: ${collectionSlug} after delete`, + ) + } catch (error) { + console.error(`[Cache] Failed to clear cache for ${collectionSlug}:`, error) + } + + return doc +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..004a3d2 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,156 @@ +import { createClient } from 'redis' + +const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379' + +// 创建 Redis 客户端 +const redisClient = createClient({ + url: redisUrl, +}) + +redisClient.on('error', (err) => console.error('Redis Client Error:', err)) +redisClient.on('connect', () => console.log('Redis Client Connected')) + +// 连接 Redis +let isConnecting = false +let isConnected = false + +export async function connectRedis() { + if (isConnected) return redisClient + if (isConnecting) { + // 等待连接完成 + while (isConnecting) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return redisClient + } + + isConnecting = true + try { + await redisClient.connect() + isConnected = true + console.log('Redis connected successfully') + } catch (error) { + console.error('Failed to connect to Redis:', error) + throw error + } finally { + isConnecting = false + } + + return redisClient +} + +// 缓存 key 前缀 +const PREFIX = 'payload:' + +/** + * 获取缓存 + */ +export async function getCache(key: string): Promise { + try { + const client = await connectRedis() + const data = await client.get(`${PREFIX}${key}`) + if (!data) return null + return JSON.parse(data) as T + } catch (error) { + console.error('Redis GET error:', error) + return null + } +} + +/** + * 设置缓存 + * @param key 缓存键 + * @param value 缓存值 + * @param ttl 过期时间(秒),默认 1 小时 + */ +export async function setCache(key: string, value: T, ttl: number = 3600): Promise { + try { + const client = await connectRedis() + await client.setEx(`${PREFIX}${key}`, ttl, JSON.stringify(value)) + } catch (error) { + console.error('Redis SET error:', error) + } +} + +/** + * 删除缓存 + */ +export async function deleteCache(key: string): Promise { + try { + const client = await connectRedis() + await client.del(`${PREFIX}${key}`) + } catch (error) { + console.error('Redis DEL error:', error) + } +} + +/** + * 删除匹配模式的所有缓存 + */ +export async function deleteCachePattern(pattern: string): Promise { + try { + const client = await connectRedis() + const keys = await client.keys(`${PREFIX}${pattern}`) + if (keys.length === 0) return 0 + + await client.del(keys) + return keys.length + } catch (error) { + console.error('Redis DELETE PATTERN error:', error) + return 0 + } +} + +/** + * 清除所有缓存 + */ +export async function clearAllCache(): Promise { + return deleteCachePattern('*') +} + +/** + * 获取缓存统计信息 + */ +export async function getCacheStats() { + try { + const client = await connectRedis() + + // 获取所有 payload 前缀的 key + const keys = await client.keys(`${PREFIX}*`) + + // 获取内存使用情况 + const info = await client.info('memory') + const memoryMatch = info.match(/used_memory_human:(.+)/) + const memoryUsage = memoryMatch ? memoryMatch[1].trim() : 'N/A' + + return { + connected: isConnected, + totalKeys: keys.length, + memoryUsage, + prefix: PREFIX, + } + } catch (error) { + console.error('Redis STATS error:', error) + return { + connected: false, + totalKeys: 0, + memoryUsage: 'N/A', + prefix: PREFIX, + } + } +} + +/** + * 获取 Redis 客户端实例 + */ +export function getRedisClient() { + return redisClient +} + +// 优雅退出时断开连接 +process.on('beforeExit', async () => { + if (isConnected) { + await redisClient.quit() + console.log('Redis connection closed') + } +})