diff --git a/blog.config.js b/blog.config.js index 97b66b9e4dd..cafd06ca610 100644 --- a/blog.config.js +++ b/blog.config.js @@ -63,8 +63,9 @@ const BLOG = { process.env.NEXT_PUBLIC_GREETING_WORDS || 'Hi,我是一个程序员, Hi,我是一个打工人,Hi,我是一个干饭人,欢迎来到我的博客🎉', - // uuid重定向至 slug - UUID_REDIRECT: process.env.UUID_REDIRECT || false + // uuid重定向至 slug(不支持Notion配置!) + UUID_REDIRECT: process.env.UUID_REDIRECT || false, + REDIRECT_CACHE_KEY: process.env.REDIRECT_CACHE_KEY || 'uuid_slug_map' } module.exports = BLOG diff --git a/conf/dev.config.js b/conf/dev.config.js index c34a8c6430a..90ca0742c9b 100644 --- a/conf/dev.config.js +++ b/conf/dev.config.js @@ -8,9 +8,13 @@ module.exports = { BACKGROUND_LIGHT: '#eeeeee', // use hex value, don't forget '#' e.g #fffefc BACKGROUND_DARK: '#000000', // use hex value, don't forget '#' - // Redis 缓存数据库地址 + // Redis 缓存数据库地址 (警告:缓存时间使用了NEXT_REVALIDATE_SECOND,且无法从Notion获取) REDIS_URL: process.env.REDIS_URL || '', + // UpStash Redis 缓存数据库地址(支持RESTful API调用,中间件可用) + UPSTASH_REDIS_URL: process.env.UPSTASH_REDIS_URL || '', + UPSTASH_REDIS_TOKEN: process.env.UPSTASH_REDIS_TOKEN || '', + ENABLE_CACHE: process.env.ENABLE_CACHE || process.env.npm_lifecycle_event === 'build' || diff --git a/lib/cache/cache_manager.js b/lib/cache/cache_manager.js index f0c412abd5f..ad9586a3b5f 100644 --- a/lib/cache/cache_manager.js +++ b/lib/cache/cache_manager.js @@ -4,7 +4,7 @@ import MemoryCache from './memory_cache' import RedisCache from './redis_cache' // 配置是否开启Vercel环境中的缓存,因为Vercel中现有两种缓存方式在无服务环境下基本都是无意义的,纯粹的浪费资源 -const enableCacheInVercel = +export const isNotVercelProduction = process.env.npm_lifecycle_event === 'build' || process.env.npm_lifecycle_event === 'export' || !BLOG['isProd'] @@ -77,7 +77,7 @@ export async function getDataFromCache(key, force) { * @returns */ export async function setDataToCache(key, data, customCacheTime) { - if (!enableCacheInVercel || !data) { + if (!data) { return } // console.trace('[API-->>缓存写入]:', key) diff --git a/lib/cache/local_file_cache.js b/lib/cache/local_file_cache.js index c411d6e59d2..6422db0e007 100644 --- a/lib/cache/local_file_cache.js +++ b/lib/cache/local_file_cache.js @@ -1,4 +1,5 @@ import fs from 'fs' +import { isNotVercelProduction } from '@/lib/cache/cache_manager' const path = require('path') // 文件缓存持续10秒 @@ -6,7 +7,10 @@ const cacheInvalidSeconds = 1000000000 * 1000 // 文件名 const jsonFile = path.resolve('./data.json') -export async function getCache (key) { +export async function getCache(key) { + if (!isNotVercelProduction) { + return + } const exist = await fs.existsSync(jsonFile) if (!exist) return null const data = await fs.readFileSync(jsonFile) @@ -19,7 +23,9 @@ export async function getCache (key) { return null } // 缓存超过有效期就作废 - const cacheValidTime = new Date(parseInt(json[key + '_expire_time']) + cacheInvalidSeconds) + const cacheValidTime = new Date( + parseInt(json[key + '_expire_time']) + cacheInvalidSeconds + ) const currentTime = new Date() if (!cacheValidTime || cacheValidTime < currentTime) { return null @@ -33,7 +39,10 @@ export async function getCache (key) { * @param data * @returns {Promise} */ -export async function setCache (key, data) { +export async function setCache(key, data) { + if (!isNotVercelProduction) { + return + } const exist = await fs.existsSync(jsonFile) const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {} json[key] = data @@ -41,7 +50,10 @@ export async function setCache (key, data) { fs.writeFileSync(jsonFile, JSON.stringify(json)) } -export async function delCache (key) { +export async function delCache(key) { + if (!isNotVercelProduction) { + return + } const exist = await fs.existsSync(jsonFile) const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {} delete json.key @@ -53,6 +65,9 @@ export async function delCache (key) { * 清理缓存 */ export async function cleanCache() { + if (!isNotVercelProduction) { + return + } const json = {} fs.writeFileSync(jsonFile, JSON.stringify(json)) } diff --git a/lib/cache/memory_cache.js b/lib/cache/memory_cache.js index 001666c3fe0..183353b50dd 100644 --- a/lib/cache/memory_cache.js +++ b/lib/cache/memory_cache.js @@ -1,17 +1,27 @@ import cache from 'memory-cache' import BLOG from 'blog.config' +import { isNotVercelProduction } from '@/lib/cache/cache_manager' const cacheTime = BLOG.isProd ? 10 * 60 : 120 * 60 // 120 minutes for dev,10 minutes for prod export async function getCache(key, options) { + if (!isNotVercelProduction) { + return + } return await cache.get(key) } export async function setCache(key, data, customCacheTime) { + if (!isNotVercelProduction) { + return + } await cache.put(key, data, (customCacheTime || cacheTime) * 1000) } export async function delCache(key) { + if (!isNotVercelProduction) { + return + } await cache.del(key) } diff --git a/lib/cache/redis_cache.js b/lib/cache/redis_cache.js index b35f472ad54..4212e176004 100644 --- a/lib/cache/redis_cache.js +++ b/lib/cache/redis_cache.js @@ -2,9 +2,9 @@ import BLOG from '@/blog.config' import { siteConfig } from '@/lib/config' import Redis from 'ioredis' -export const redisClient = BLOG.REDIS_URL ? new Redis(BLOG.REDIS_URL) : {} +export const redisClient = BLOG.REDIS_URL ? new Redis(BLOG.REDIS_URL) : null -const cacheTime = Math.trunc( +export const redisCacheTime = Math.trunc( siteConfig('NEXT_REVALIDATE_SECOND', BLOG.NEXT_REVALIDATE_SECOND) * 1.5 ) @@ -23,7 +23,7 @@ export async function setCache(key, data, customCacheTime) { key, JSON.stringify(data), 'EX', - customCacheTime || cacheTime + customCacheTime || redisCacheTime ) } catch (e) { console.error('redisClient写入失败 ' + e) diff --git a/lib/cache/upstash_redis_cache.js b/lib/cache/upstash_redis_cache.js new file mode 100644 index 00000000000..7ec8537a7e5 --- /dev/null +++ b/lib/cache/upstash_redis_cache.js @@ -0,0 +1,46 @@ +import BLOG from '@/blog.config' +import { Redis } from '@upstash/redis' + +export const upstashRedisClient = + BLOG.UPSTASH_REDIS_URL && BLOG.UPSTASH_REDIS_TOKEN + ? new Redis({ + url: BLOG.UPSTASH_REDIS_URL, + token: BLOG.UPSTASH_REDIS_TOKEN + }) + : null + +export const upstashRedisCacheTime = Math.trunc( + BLOG.NEXT_REVALIDATE_SECOND * 1.5 +) + +export async function getCache(key) { + try { + const data = await upstashRedisClient.get(key) + return data ? JSON.parse(data) : null + } catch (e) { + console.error('upstash 读取失败 ' + e) + } +} + +export async function setCache(key, data, customCacheTime) { + try { + await upstashRedisClient.set( + key, + JSON.stringify(data), + 'EX', + customCacheTime || upstashRedisCacheTime + ) + } catch (e) { + console.error('upstash 写入失败 ' + e) + } +} + +export async function delCache(key) { + try { + await upstashRedisClient.del(key) + } catch (e) { + console.error('upstash 删除失败 ' + e) + } +} + +export default { getCache, setCache, delCache } diff --git a/lib/config.js b/lib/config.js index 54b043a7d57..4244a04eb62 100644 --- a/lib/config.js +++ b/lib/config.js @@ -43,7 +43,6 @@ export const siteConfig = (key, defaultVal = null, extendConfig = {}) => { case 'AI_SUMMARY_KEY': case 'AI_SUMMARY_CACHE_TIME': case 'AI_SUMMARY_WORD_LIMIT': - case 'UUID_REDIRECT': // LINK比较特殊, if (key === 'LINK') { if (!extendConfig || Object.keys(extendConfig).length === 0) { diff --git a/lib/redirect.js b/lib/redirect.js index 7a05dfee057..d452af7e2ed 100644 --- a/lib/redirect.js +++ b/lib/redirect.js @@ -1,15 +1,32 @@ import fs from 'fs' +import { redisClient } from '@/lib/cache/redis_cache' +import { upstashRedisClient } from '@/lib/cache/upstash_redis_cache' +import BLOG from '@/blog.config' -export function generateRedirectJson({ allPages }) { +export async function generateRedirectJson({ allPages }) { let uuidSlugMap = {} allPages.forEach(page => { if (page.type === 'Post' && page.status === 'Published') { uuidSlugMap[page.id] = page.slug } }) - try { - fs.writeFileSync('./public/redirect.json', JSON.stringify(uuidSlugMap)) - } catch (error) { - console.warn('无法写入文件', error) + if (upstashRedisClient) { + try { + await upstashRedisClient.hset(BLOG.REDIRECT_CACHE_KEY, uuidSlugMap) + } catch (e) { + console.warn('写入 upstashRedis 失败', e) + } + } else if (redisClient) { + try { + await redisClient.hset(BLOG.REDIRECT_CACHE_KEY, uuidSlugMap) + } catch (e) { + console.warn('写入Redis失败', e) + } + } else { + try { + fs.writeFileSync('./public/redirect.json', JSON.stringify(uuidSlugMap)) + } catch (error) { + console.warn('无法写入文件', error) + } } } diff --git a/lib/utils/index.js b/lib/utils/index.js index 787dc90ddf7..e12f2f85fc3 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -104,6 +104,17 @@ export function checkStartWithHttp(str) { } } +// 检查一个字符串是否UUID https://ihateregex.io/expr/uuid/ +export function checkStrIsUuid(str) { + if (!str) { + return false + } + // 使用正则表达式进行匹配 + const regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/ + return regex.test(str) +} + + // 检查一个字符串是否notionid : 32位,仅由数字英文构成 export function checkStrIsNotionId(str) { if (!str) { diff --git a/middleware.ts b/middleware.ts index 550c6f8b08a..7d20e98a7d4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,13 @@ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' import { NextRequest, NextResponse } from 'next/server' -import { checkStrIsNotionId, getLastPartOfUrl } from '@/lib/utils' +import { + checkStrIsNotionId, + checkStrIsUuid, + getLastPartOfUrl +} from '@/lib/utils' import { idToUuid } from 'notion-utils' import BLOG from './blog.config' +import { upstashRedisClient } from '@/lib/cache/upstash_redis_cache' /** * Clerk 身份验证中间件 @@ -36,26 +41,59 @@ const isTenantAdminRoute = createRouteMatcher([ const noAuthMiddleware = async (req: NextRequest, ev: any) => { // 如果没有配置 Clerk 相关环境变量,返回一个默认响应或者继续处理请求 if (BLOG['UUID_REDIRECT']) { - let redirectJson: Record = {} - try { - const response = await fetch(`${req.nextUrl.origin}/redirect.json`) - if (response.ok) { - redirectJson = (await response.json()) as Record - } - } catch (err) { - console.error('Error fetching static file:', err) - } let lastPart = getLastPartOfUrl(req.nextUrl.pathname) as string if (checkStrIsNotionId(lastPart)) { lastPart = idToUuid(lastPart) } - if (lastPart && redirectJson[lastPart]) { - const redirectToUrl = req.nextUrl.clone() - redirectToUrl.pathname = '/' + redirectJson[lastPart] - console.log( - `redirect from ${req.nextUrl.pathname} to ${redirectToUrl.pathname}` - ) - return NextResponse.redirect(redirectToUrl, 308) + if (checkStrIsUuid(lastPart)) { + let redirectJson: Record = {} + if (upstashRedisClient) { + const redisResult = (await upstashRedisClient.hget( + BLOG.REDIRECT_CACHE_KEY, + lastPart + )) as string + redirectJson = { + [lastPart]: redisResult + } + } else if (BLOG.REDIS_URL) { + try { + const redisResponse = await fetch( + `${req.nextUrl.origin}/api/redirect`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + lastPart: lastPart + }) + } + ) + const redisResult = await redisResponse?.json() + redirectJson = { + [lastPart]: redisResult?.data + } + } catch (e) { + console.warn('读取Redis失败', e) + } + } else { + try { + const response = await fetch(`${req.nextUrl.origin}/redirect.json`) + if (response.ok) { + redirectJson = (await response.json()) as Record + } + } catch (err) { + console.error('Error fetching static file:', err) + } + } + if (redirectJson[lastPart]) { + const redirectToUrl = req.nextUrl.clone() + redirectToUrl.pathname = '/' + redirectJson[lastPart] + console.log( + `redirect from ${req.nextUrl.pathname} to ${redirectToUrl.pathname}` + ) + return NextResponse.redirect(redirectToUrl, 308) + } } } return NextResponse.next() diff --git a/package.json b/package.json index f9d7ec20a4c..142904aa8f2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@clerk/nextjs": "^5.1.5", "@headlessui/react": "^1.7.15", "@next/bundle-analyzer": "^12.1.1", + "@upstash/redis": "^1.34.3", "@vercel/analytics": "^1.0.0", "algoliasearch": "^4.18.0", "axios": "^1.7.2", diff --git a/pages/api/redirect.js b/pages/api/redirect.js new file mode 100644 index 00000000000..5ed28e1e779 --- /dev/null +++ b/pages/api/redirect.js @@ -0,0 +1,17 @@ +import { redisCacheTime, redisClient } from '@/lib/cache/redis_cache' +import BLOG from '@/blog.config' + +export default async function handler(req, res) { + const { lastPart } = req.body + try { + const result = + (await redisClient.hget(BLOG.REDIRECT_CACHE_KEY, lastPart)) || null + res.setHeader( + 'Cache-Control', + `public, max-age=${redisCacheTime}, stale-while-revalidate=${redisCacheTime / 6}` + ) + res.status(200).json({ status: 'success', data: result }) + } catch (error) { + res.status(400).json({ status: 'error', message: 'failed!', error }) + } +} diff --git a/pages/index.js b/pages/index.js index b66b56104d2..1ada9472a87 100644 --- a/pages/index.js +++ b/pages/index.js @@ -61,7 +61,7 @@ export async function getStaticProps(req) { generateRss(props) // 生成 generateSitemapXml(props) - if (siteConfig('UUID_REDIRECT', false, props?.NOTION_CONFIG)) { + if (BLOG['UUID_REDIRECT']) { // 生成重定向 JSON generateRedirectJson(props) } diff --git a/yarn.lock b/yarn.lock index 74fd3138b00..becbd788080 100644 --- a/yarn.lock +++ b/yarn.lock @@ -706,6 +706,13 @@ resolved "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@upstash/redis@^1.34.3": + version "1.34.3" + resolved "https://mirrors.cloud.tencent.com/npm/@upstash/redis/-/redis-1.34.3.tgz#df0338f4983bba5141878e851be4fced494b44a0" + integrity sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ== + dependencies: + crypto-js "^4.2.0" + "@vercel/analytics@^1.0.0": version "1.4.1" resolved "https://registry.npmmirror.com/@vercel/analytics/-/analytics-1.4.1.tgz#a28a93133d68b6e3d86884a52fa7893f5ecaa381" @@ -1320,7 +1327,7 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@4.2.0: +crypto-js@4.2.0, crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== @@ -3985,6 +3992,7 @@ streamsearch@^1.1.0: integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==