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
|
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",
|
"payload": "3.75.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"redis": "^5.10.0",
|
||||||
"sharp": "0.34.2"
|
"sharp": "0.34.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.1
|
specifier: 19.2.1
|
||||||
version: 19.2.1(react@19.2.1)
|
version: 19.2.1(react@19.2.1)
|
||||||
|
redis:
|
||||||
|
specifier: ^5.10.0
|
||||||
|
version: 5.10.0
|
||||||
sharp:
|
sharp:
|
||||||
specifier: 0.34.2
|
specifier: 0.34.2
|
||||||
version: 0.34.2
|
version: 0.34.2
|
||||||
|
|
@ -1640,6 +1643,34 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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':
|
'@rolldown/pluginutils@1.0.0-beta.11':
|
||||||
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
|
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
|
||||||
|
|
||||||
|
|
@ -2550,6 +2581,10 @@ packages:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
@ -4133,6 +4168,10 @@ packages:
|
||||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
engines: {node: '>= 12.13.0'}
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
|
redis@5.10.0:
|
||||||
|
resolution: {integrity: sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -6750,6 +6789,26 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.56.1
|
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': {}
|
'@rolldown/pluginutils@1.0.0-beta.11': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||||
|
|
@ -7759,6 +7818,8 @@ snapshots:
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
@ -9634,6 +9695,14 @@ snapshots:
|
||||||
|
|
||||||
real-require@0.2.0: {}
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
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 type { CollectionConfig } from 'payload'
|
||||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||||
|
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
|
||||||
import {
|
import {
|
||||||
BlocksFeature,
|
BlocksFeature,
|
||||||
BoldFeature,
|
BoldFeature,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||||
|
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
|
||||||
import {
|
import {
|
||||||
BlocksFeature,
|
BlocksFeature,
|
||||||
BoldFeature,
|
BoldFeature,
|
||||||
|
|
@ -331,7 +332,7 @@ export const Articles: CollectionConfig = {
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
afterChange: [logAfterChange],
|
afterChange: [logAfterChange, cacheAfterChange],
|
||||||
afterDelete: [logAfterDelete],
|
afterDelete: [logAfterDelete, cacheAfterDelete],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||||
|
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
|
||||||
import {
|
import {
|
||||||
AlignFeature,
|
AlignFeature,
|
||||||
BlocksFeature,
|
BlocksFeature,
|
||||||
|
|
@ -202,8 +203,8 @@ export const Products: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [logAfterChange],
|
afterChange: [logAfterChange, cacheAfterChange],
|
||||||
afterDelete: [logAfterDelete],
|
afterDelete: [logAfterDelete, cacheAfterDelete],
|
||||||
},
|
},
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ export default function AdminPanel() {
|
||||||
const [clearMessage, setClearMessage] = useState('')
|
const [clearMessage, setClearMessage] = useState('')
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||||
|
|
||||||
|
// 缓存相关状态
|
||||||
|
const [cacheStats, setCacheStats] = useState<any>(null)
|
||||||
|
const [cacheLoading, setCacheLoading] = useState(false)
|
||||||
|
const [cacheMessage, setCacheMessage] = useState('')
|
||||||
|
|
||||||
const handleClearData = () => {
|
const handleClearData = () => {
|
||||||
setShowClearConfirm(true)
|
setShowClearConfirm(true)
|
||||||
setClearMessage('')
|
setClearMessage('')
|
||||||
|
|
@ -46,6 +51,79 @@ export default function AdminPanel() {
|
||||||
setClearMessage('')
|
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 (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
|
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
|
@ -151,6 +229,155 @@ export default function AdminPanel() {
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -163,13 +390,13 @@ export default function AdminPanel() {
|
||||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||||
ℹ️ 系统信息
|
ℹ️ 系统信息
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--theme-elevation-0)',
|
backgroundColor: 'var(--theme-elevation-0)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '1rem',
|
padding: '1.5rem',
|
||||||
border: '1px solid var(--theme-elevation-150)',
|
border: '1px solid var(--theme-elevation-150)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: '0.5rem' }}>
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
|
|
@ -181,6 +408,9 @@ export default function AdminPanel() {
|
||||||
<div style={{ marginBottom: '0.5rem' }}>
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
<strong>存储:</strong> Cloudflare R2 (S3 API)
|
<strong>存储:</strong> Cloudflare R2 (S3 API)
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<strong>缓存:</strong> Redis
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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