mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-06-11 11:33:10 +08:00
feat: support upstash redis
This commit is contained in:
26
README.md
26
README.md
@@ -57,7 +57,7 @@
|
|||||||
| 语言 | TypeScript 4 |
|
| 语言 | TypeScript 4 |
|
||||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||||
| 代码质量 | ESLint · Prettier · Jest |
|
| 代码质量 | ESLint · Prettier · Jest |
|
||||||
| 部署 | Docker · Vercel · CloudFlare pages |
|
| 部署 | Docker · Vercel · CloudFlare pages |
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -182,17 +182,19 @@ networks:
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 说明 | 可选值 | 默认值 |
|
| 变量 | 说明 | 可选值 | 默认值 |
|
||||||
| --------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
|
| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
|
||||||
| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
|
| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
|
||||||
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage |
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
|
||||||
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
|
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
|
||||||
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false |
|
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||||
| NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
|
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false |
|
||||||
|
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||||
|
| NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
const isD1Storage =
|
const isD1Storage =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||||
|
const isUpstashStorage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.UserConfig) {
|
if (config?.UserConfig) {
|
||||||
@@ -292,7 +295,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label
|
<label
|
||||||
className={`text-gray-700 dark:text-gray-300 ${
|
className={`text-gray-700 dark:text-gray-300 ${
|
||||||
isD1Storage ? 'opacity-50' : ''
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
允许新用户注册
|
允许新用户注册
|
||||||
@@ -301,18 +304,28 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
|||||||
(D1 环境下不可修改)
|
(D1 环境下不可修改)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下不可修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!isD1Storage &&
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
toggleAllowRegister(!userSettings.enableRegistration)
|
toggleAllowRegister(!userSettings.enableRegistration)
|
||||||
}
|
}
|
||||||
disabled={isD1Storage}
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
userSettings.enableRegistration
|
userSettings.enableRegistration
|
||||||
? 'bg-green-600'
|
? 'bg-green-600'
|
||||||
: 'bg-gray-200 dark:bg-gray-700'
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
} ${isD1Storage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
} ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
@@ -953,10 +966,13 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
// 保存状态
|
// 保存状态
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// 检测存储类型是否为 d1
|
// 检测存储类型是否为 d1 或 upstash
|
||||||
const isD1Storage =
|
const isD1Storage =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||||
|
const isUpstashStorage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.SiteConfig) {
|
if (config?.SiteConfig) {
|
||||||
@@ -1004,7 +1020,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
isD1Storage ? 'opacity-50' : ''
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
站点名称
|
站点名称
|
||||||
@@ -1013,17 +1029,25 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
(D1 环境下不可修改)
|
(D1 环境下不可修改)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下不可修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
value={siteSettings.SiteName}
|
value={siteSettings.SiteName}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!isD1Storage &&
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
||||||
}
|
}
|
||||||
disabled={isD1Storage}
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
isD1Storage ? 'opacity-50 cursor-not-allowed' : ''
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1032,7 +1056,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
isD1Storage ? 'opacity-50' : ''
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
站点公告
|
站点公告
|
||||||
@@ -1041,20 +1065,28 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
(D1 环境下不可修改)
|
(D1 环境下不可修改)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下不可修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={siteSettings.Announcement}
|
value={siteSettings.Announcement}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!isD1Storage &&
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
setSiteSettings((prev) => ({
|
setSiteSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Announcement: e.target.value,
|
Announcement: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={isD1Storage}
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
rows={3}
|
rows={3}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
isD1Storage ? 'opacity-50 cursor-not-allowed' : ''
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1101,7 +1133,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
isD1Storage ? 'opacity-50' : ''
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
图片代理前缀
|
图片代理前缀
|
||||||
@@ -1110,6 +1142,11 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
(D1 环境下不可修改)
|
(D1 环境下不可修改)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下不可修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
@@ -1117,14 +1154,17 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
value={siteSettings.ImageProxy}
|
value={siteSettings.ImageProxy}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!isD1Storage &&
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
setSiteSettings((prev) => ({
|
setSiteSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
ImageProxy: e.target.value,
|
ImageProxy: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={isD1Storage}
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
isD1Storage ? 'opacity-50 cursor-not-allowed' : ''
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
@@ -1136,9 +1176,9 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
|||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || isD1Storage}
|
disabled={saving || isD1Storage || isUpstashStorage}
|
||||||
className={`px-4 py-2 ${
|
className={`px-4 py-2 ${
|
||||||
saving || isD1Storage
|
saving || isD1Storage || isUpstashStorage
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
: 'bg-green-600 hover:bg-green-700'
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
} text-white rounded-lg transition-colors`}
|
} text-white rounded-lg transition-colors`}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const inter = Inter({ subsets: ['latin'] });
|
|||||||
// 动态生成 metadata,支持配置更新后的标题变化
|
// 动态生成 metadata,支持配置更新后的标题变化
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
let siteName = process.env.SITE_NAME || 'MoonTV';
|
let siteName = process.env.SITE_NAME || 'MoonTV';
|
||||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
|
if (
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||||
|
) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
siteName = config.SiteConfig.SiteName;
|
siteName = config.SiteConfig.SiteName;
|
||||||
}
|
}
|
||||||
@@ -41,7 +44,10 @@ export default async function RootLayout({
|
|||||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||||
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||||
let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
||||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
|
if (
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||||
|
) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
siteName = config.SiteConfig.SiteName;
|
siteName = config.SiteConfig.SiteName;
|
||||||
announcement = config.SiteConfig.Announcement;
|
announcement = config.SiteConfig.Announcement;
|
||||||
|
|||||||
@@ -1259,7 +1259,10 @@ function PlayPageClient() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
now - lastSaveTimeRef.current >
|
now - lastSaveTimeRef.current >
|
||||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' ? 10000 : 5000)
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' ||
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash'
|
||||||
|
? 10000
|
||||||
|
: 5000)
|
||||||
) {
|
) {
|
||||||
saveCurrentPlayProgress();
|
saveCurrentPlayProgress();
|
||||||
lastSaveTimeRef.current = now;
|
lastSaveTimeRef.current = now;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 功能:
|
* 功能:
|
||||||
* 1. 获取全部播放记录(getAllPlayRecords)。
|
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||||
* 2. 保存播放记录(savePlayRecord)。
|
* 2. 保存播放记录(savePlayRecord)。
|
||||||
* 3. D1 存储模式下的混合缓存策略,提升用户体验。
|
* 3. 数据库存储模式下的混合缓存策略,提升用户体验。
|
||||||
*
|
*
|
||||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +69,12 @@ const STORAGE_TYPE = (() => {
|
|||||||
const raw =
|
const raw =
|
||||||
(typeof window !== 'undefined' &&
|
(typeof window !== 'undefined' &&
|
||||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
||||||
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
|
(process.env.STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'redis'
|
||||||
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
|
| undefined) ||
|
||||||
'localstorage';
|
'localstorage';
|
||||||
return raw;
|
return raw;
|
||||||
})();
|
})();
|
||||||
@@ -379,7 +384,7 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1 存储模式:使用混合缓存策略
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 优先从缓存获取数据
|
// 优先从缓存获取数据
|
||||||
const cachedData = cacheManager.getCachedPlayRecords();
|
const cachedData = cacheManager.getCachedPlayRecords();
|
||||||
@@ -432,7 +437,7 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存播放记录。
|
* 保存播放记录。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function savePlayRecord(
|
export async function savePlayRecord(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -441,7 +446,7 @@ export async function savePlayRecord(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
@@ -498,7 +503,7 @@ export async function savePlayRecord(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除播放记录。
|
* 删除播放记录。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deletePlayRecord(
|
export async function deletePlayRecord(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -506,7 +511,7 @@ export async function deletePlayRecord(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
@@ -561,7 +566,7 @@ export async function deletePlayRecord(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取搜索历史。
|
* 获取搜索历史。
|
||||||
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
*/
|
*/
|
||||||
export async function getSearchHistory(): Promise<string[]> {
|
export async function getSearchHistory(): Promise<string[]> {
|
||||||
// 服务器端渲染阶段直接返回空
|
// 服务器端渲染阶段直接返回空
|
||||||
@@ -569,7 +574,7 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1 存储模式:使用混合缓存策略
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 优先从缓存获取数据
|
// 优先从缓存获取数据
|
||||||
const cachedData = cacheManager.getCachedSearchHistory();
|
const cachedData = cacheManager.getCachedSearchHistory();
|
||||||
@@ -622,13 +627,13 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 将关键字添加到搜索历史。
|
* 将关键字添加到搜索历史。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function addSearchHistory(keyword: string): Promise<void> {
|
export async function addSearchHistory(keyword: string): Promise<void> {
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
@@ -685,10 +690,10 @@ export async function addSearchHistory(keyword: string): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空搜索历史。
|
* 清空搜索历史。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function clearSearchHistory(): Promise<void> {
|
export async function clearSearchHistory(): Promise<void> {
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
cacheManager.cacheSearchHistory([]);
|
cacheManager.cacheSearchHistory([]);
|
||||||
@@ -724,13 +729,13 @@ export async function clearSearchHistory(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除单条搜索历史。
|
* 删除单条搜索历史。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
@@ -780,7 +785,7 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取全部收藏。
|
* 获取全部收藏。
|
||||||
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
*/
|
*/
|
||||||
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||||
// 服务器端渲染阶段直接返回空
|
// 服务器端渲染阶段直接返回空
|
||||||
@@ -788,7 +793,7 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1 存储模式:使用混合缓存策略
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 优先从缓存获取数据
|
// 优先从缓存获取数据
|
||||||
const cachedData = cacheManager.getCachedFavorites();
|
const cachedData = cacheManager.getCachedFavorites();
|
||||||
@@ -841,7 +846,7 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存收藏。
|
* 保存收藏。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function saveFavorite(
|
export async function saveFavorite(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -850,7 +855,7 @@ export async function saveFavorite(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
@@ -904,7 +909,7 @@ export async function saveFavorite(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除收藏。
|
* 删除收藏。
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function deleteFavorite(
|
export async function deleteFavorite(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -912,7 +917,7 @@ export async function deleteFavorite(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
@@ -962,7 +967,7 @@ export async function deleteFavorite(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否已收藏。
|
* 判断是否已收藏。
|
||||||
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
*/
|
*/
|
||||||
export async function isFavorited(
|
export async function isFavorited(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -970,7 +975,7 @@ export async function isFavorited(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const key = generateStorageKey(source, id);
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
// D1 存储模式:使用混合缓存策略
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
const cachedFavorites = cacheManager.getCachedFavorites();
|
const cachedFavorites = cacheManager.getCachedFavorites();
|
||||||
|
|
||||||
@@ -1016,10 +1021,10 @@ export async function isFavorited(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空全部播放记录
|
* 清空全部播放记录
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function clearAllPlayRecords(): Promise<void> {
|
export async function clearAllPlayRecords(): Promise<void> {
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
cacheManager.cachePlayRecords({});
|
cacheManager.cachePlayRecords({});
|
||||||
@@ -1057,10 +1062,10 @@ export async function clearAllPlayRecords(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空全部收藏
|
* 清空全部收藏
|
||||||
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
*/
|
*/
|
||||||
export async function clearAllFavorites(): Promise<void> {
|
export async function clearAllFavorites(): Promise<void> {
|
||||||
// D1 存储模式:乐观更新策略
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
if (STORAGE_TYPE !== 'localstorage') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
// 立即更新缓存
|
// 立即更新缓存
|
||||||
cacheManager.cacheFavorites({});
|
cacheManager.cacheFavorites({});
|
||||||
@@ -1103,7 +1108,7 @@ export async function clearAllFavorites(): Promise<void> {
|
|||||||
* 用于用户登出时清理缓存
|
* 用于用户登出时清理缓存
|
||||||
*/
|
*/
|
||||||
export function clearUserCache(): void {
|
export function clearUserCache(): void {
|
||||||
if (STORAGE_TYPE === 'd1') {
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
cacheManager.clearUserCache();
|
cacheManager.clearUserCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1118,7 @@ export function clearUserCache(): void {
|
|||||||
* 强制从服务器重新获取数据并更新缓存
|
* 强制从服务器重新获取数据并更新缓存
|
||||||
*/
|
*/
|
||||||
export async function refreshAllCache(): Promise<void> {
|
export async function refreshAllCache(): Promise<void> {
|
||||||
if (STORAGE_TYPE !== 'd1') return;
|
if (STORAGE_TYPE === 'localstorage') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 并行刷新所有数据
|
// 并行刷新所有数据
|
||||||
@@ -1164,7 +1169,7 @@ export function getCacheStatus(): {
|
|||||||
hasSearchHistory: boolean;
|
hasSearchHistory: boolean;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
} {
|
} {
|
||||||
if (STORAGE_TYPE !== 'd1') {
|
if (STORAGE_TYPE === 'localstorage') {
|
||||||
return {
|
return {
|
||||||
hasPlayRecords: false,
|
hasPlayRecords: false,
|
||||||
hasFavorites: false,
|
hasFavorites: false,
|
||||||
@@ -1224,7 +1229,7 @@ export function subscribeToDataUpdates<T>(
|
|||||||
* 适合在应用启动时调用,提升后续访问速度
|
* 适合在应用启动时调用,提升后续访问速度
|
||||||
*/
|
*/
|
||||||
export async function preloadUserData(): Promise<void> {
|
export async function preloadUserData(): Promise<void> {
|
||||||
if (STORAGE_TYPE !== 'd1') return;
|
if (STORAGE_TYPE === 'localstorage') return;
|
||||||
|
|
||||||
// 检查是否已有有效缓存,避免重复请求
|
// 检查是否已有有效缓存,避免重复请求
|
||||||
const status = getCacheStatus();
|
const status = getCacheStatus();
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { AdminConfig } from './admin.types';
|
|||||||
import { D1Storage } from './d1.db';
|
import { D1Storage } from './d1.db';
|
||||||
import { RedisStorage } from './redis.db';
|
import { RedisStorage } from './redis.db';
|
||||||
import { Favorite, IStorage, PlayRecord } from './types';
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
import { UpstashRedisStorage } from './upstash.db';
|
||||||
|
|
||||||
// storage type 常量: 'localstorage' | 'redis' | 'd1',默认 'localstorage'
|
// storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage'
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
| 'localstorage'
|
| 'localstorage'
|
||||||
| 'redis'
|
| 'redis'
|
||||||
| 'd1'
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
| undefined) || 'localstorage';
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
// 创建存储实例
|
// 创建存储实例
|
||||||
@@ -18,6 +20,8 @@ function createStorage(): IStorage {
|
|||||||
switch (STORAGE_TYPE) {
|
switch (STORAGE_TYPE) {
|
||||||
case 'redis':
|
case 'redis':
|
||||||
return new RedisStorage();
|
return new RedisStorage();
|
||||||
|
case 'upstash':
|
||||||
|
return new UpstashRedisStorage();
|
||||||
case 'd1':
|
case 'd1':
|
||||||
return new D1Storage();
|
return new D1Storage();
|
||||||
case 'localstorage':
|
case 'localstorage':
|
||||||
|
|||||||
294
src/lib/upstash.db.ts
Normal file
294
src/lib/upstash.db.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { Redis } from '@upstash/redis';
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
|
||||||
|
// 搜索历史最大条数
|
||||||
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// 添加Upstash Redis操作重试包装器
|
||||||
|
async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (err: any) {
|
||||||
|
const isLastAttempt = i === maxRetries - 1;
|
||||||
|
const isConnectionError =
|
||||||
|
err.message?.includes('Connection') ||
|
||||||
|
err.message?.includes('ECONNREFUSED') ||
|
||||||
|
err.message?.includes('ENOTFOUND') ||
|
||||||
|
err.code === 'ECONNRESET' ||
|
||||||
|
err.code === 'EPIPE' ||
|
||||||
|
err.name === 'UpstashError';
|
||||||
|
|
||||||
|
if (isConnectionError && !isLastAttempt) {
|
||||||
|
console.log(
|
||||||
|
`Upstash Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||||
|
);
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
|
||||||
|
// 等待一段时间后重试
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpstashRedisStorage implements IStorage {
|
||||||
|
private client: Redis;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = getUpstashRedisClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 播放记录 ----------
|
||||||
|
private prKey(user: string, key: string) {
|
||||||
|
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<PlayRecord | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.prKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (val as PlayRecord) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() => this.client.set(this.prKey(userName, key), record));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(
|
||||||
|
userName: string
|
||||||
|
): Promise<Record<string, PlayRecord>> {
|
||||||
|
const pattern = `u:${userName}:pr:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
|
||||||
|
const result: Record<string, PlayRecord> = {};
|
||||||
|
for (const fullKey of keys) {
|
||||||
|
const value = await withRetry(() => this.client.get(fullKey));
|
||||||
|
if (value) {
|
||||||
|
// 截取 source+id 部分
|
||||||
|
const keyPart = fullKey.replace(`u:${userName}:pr:`, '');
|
||||||
|
result[keyPart] = value as PlayRecord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 收藏 ----------
|
||||||
|
private favKey(user: string, key: string) {
|
||||||
|
return `u:${user}:fav:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.favKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (val as Favorite) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFavorite(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.favKey(userName, key), favorite)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||||
|
const pattern = `u:${userName}:fav:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
|
||||||
|
const result: Record<string, Favorite> = {};
|
||||||
|
for (const fullKey of keys) {
|
||||||
|
const value = await withRetry(() => this.client.get(fullKey));
|
||||||
|
if (value) {
|
||||||
|
const keyPart = fullKey.replace(`u:${userName}:fav:`, '');
|
||||||
|
result[keyPart] = value as Favorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户注册 / 登录 ----------
|
||||||
|
private userPwdKey(user: string) {
|
||||||
|
return `u:${user}:pwd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
|
const stored = await withRetry(() =>
|
||||||
|
this.client.get(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
if (stored === null) return false;
|
||||||
|
return stored === password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
|
// 使用 EXISTS 判断 key 是否存在
|
||||||
|
const exists = await withRetry(() =>
|
||||||
|
this.client.exists(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户密码
|
||||||
|
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.userPwdKey(userName), newPassword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户及其所有数据
|
||||||
|
async deleteUser(userName: string): Promise<void> {
|
||||||
|
// 删除用户密码
|
||||||
|
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||||
|
|
||||||
|
// 删除搜索历史
|
||||||
|
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||||
|
|
||||||
|
// 删除播放记录
|
||||||
|
const playRecordPattern = `u:${userName}:pr:*`;
|
||||||
|
const playRecordKeys = await withRetry(() =>
|
||||||
|
this.client.keys(playRecordPattern)
|
||||||
|
);
|
||||||
|
if (playRecordKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(...playRecordKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除收藏夹
|
||||||
|
const favoritePattern = `u:${userName}:fav:*`;
|
||||||
|
const favoriteKeys = await withRetry(() =>
|
||||||
|
this.client.keys(favoritePattern)
|
||||||
|
);
|
||||||
|
if (favoriteKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(...favoriteKeys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 搜索历史 ----------
|
||||||
|
private shKey(user: string) {
|
||||||
|
return `u:${user}:sh`; // u:username:sh
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
|
const result = await withRetry(() =>
|
||||||
|
this.client.lrange(this.shKey(userName), 0, -1)
|
||||||
|
);
|
||||||
|
return result as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
// 先去重
|
||||||
|
await withRetry(() => this.client.lrem(key, 0, keyword));
|
||||||
|
// 插入到最前
|
||||||
|
await withRetry(() => this.client.lpush(key, keyword));
|
||||||
|
// 限制最大长度
|
||||||
|
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
if (keyword) {
|
||||||
|
await withRetry(() => this.client.lrem(key, 0, keyword));
|
||||||
|
} else {
|
||||||
|
await withRetry(() => this.client.del(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 获取全部用户 ----------
|
||||||
|
async getAllUsers(): Promise<string[]> {
|
||||||
|
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||||
|
return keys
|
||||||
|
.map((k) => {
|
||||||
|
const match = k.match(/^u:(.+?):pwd$/);
|
||||||
|
return match ? match[1] : undefined;
|
||||||
|
})
|
||||||
|
.filter((u): u is string => typeof u === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理员配置 ----------
|
||||||
|
private adminConfigKey() {
|
||||||
|
return 'admin:config';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
|
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||||
|
return val ? (val as AdminConfig) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
|
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例 Upstash Redis 客户端
|
||||||
|
function getUpstashRedisClient(): Redis {
|
||||||
|
const globalKey = Symbol.for('__MOONTV_UPSTASH_REDIS_CLIENT__');
|
||||||
|
let client: Redis | undefined = (global as any)[globalKey];
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
const upstashUrl = process.env.UPSTASH_URL;
|
||||||
|
const upstashToken = process.env.UPSTASH_TOKEN;
|
||||||
|
|
||||||
|
if (!upstashUrl || !upstashToken) {
|
||||||
|
throw new Error(
|
||||||
|
'UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN env variables must be set'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Upstash Redis 客户端
|
||||||
|
client = new Redis({
|
||||||
|
url: upstashUrl,
|
||||||
|
token: upstashToken,
|
||||||
|
// 可选配置
|
||||||
|
retry: {
|
||||||
|
retries: 3,
|
||||||
|
backoff: (retryCount: number) =>
|
||||||
|
Math.min(1000 * Math.pow(2, retryCount), 30000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Upstash Redis client created successfully');
|
||||||
|
|
||||||
|
(global as any)[globalKey] = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user