redis
This commit is contained in:
parent
b26fe1e117
commit
fa30986946
22
.env.example
22
.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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
|
||||
import {
|
||||
BlocksFeature,
|
||||
BoldFeature,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export default function AdminPanel() {
|
|||
const [clearMessage, setClearMessage] = useState('')
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
|
||||
// 缓存相关状态
|
||||
const [cacheStats, setCacheStats] = useState<any>(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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
|
|
@ -151,6 +229,155 @@ export default function AdminPanel() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Redis 缓存管理区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
🚀 Redis 缓存管理
|
||||
</h2>
|
||||
|
||||
{/* 缓存统计 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: '600' }}>📊 缓存统计</h3>
|
||||
|
||||
{cacheStats ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
连接状态
|
||||
</div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
{cacheStats.connected ? '✅ 已连接' : '❌ 未连接'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
缓存键数量
|
||||
</div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>{cacheStats.totalKeys}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
内存使用
|
||||
</div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
{cacheStats.memoryUsage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ textAlign: 'center', padding: '1rem', color: 'var(--theme-elevation-600)' }}
|
||||
>
|
||||
{cacheLoading ? '加载中...' : '无法获取缓存统计'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button onClick={fetchCacheStats} disabled={cacheLoading} buttonStyle="secondary">
|
||||
{cacheLoading ? '刷新中...' : '刷新统计'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 清除缓存操作 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
|
||||
🗑️ 清除缓存
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--theme-elevation-600)',
|
||||
}}
|
||||
>
|
||||
清除 Redis 中的缓存数据,所有 key 前缀为 payload:
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
onClick={handleClearProductsCache}
|
||||
disabled={cacheLoading}
|
||||
buttonStyle="primary"
|
||||
>
|
||||
清除产品缓存
|
||||
</Button>
|
||||
<Button onClick={handleClearAllCache} disabled={cacheLoading} buttonStyle="error">
|
||||
清除所有缓存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cacheMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor:
|
||||
cacheMessage.includes('失败') || cacheMessage.includes('出错')
|
||||
? 'var(--theme-error-50)'
|
||||
: 'var(--theme-success-50)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
border: `1px solid ${
|
||||
cacheMessage.includes('失败') || cacheMessage.includes('出错')
|
||||
? 'var(--theme-error-500)'
|
||||
: 'var(--theme-success-500)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{cacheMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统信息区域 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -163,13 +390,13 @@ export default function AdminPanel() {
|
|||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
ℹ️ 系统信息
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
borderRadius: '6px',
|
||||
padding: '1rem',
|
||||
padding: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-150)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
|
|
@ -181,6 +408,9 @@ export default function AdminPanel() {
|
|||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>存储:</strong> Cloudflare R2 (S3 API)
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>缓存:</strong> Redis
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<T = any>(key: string): Promise<T | null> {
|
||||
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<T = any>(key: string, value: T, ttl: number = 3600): Promise<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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')
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue