feat: 添加成人内容过滤功能

- 新增用户设置系统支持内容过滤开关
- 扩展类型定义支持成人内容标记
- 实现用户设置API端点(GET/PATCH/PUT)
- 增强搜索API支持内容分组和过滤
- 创建AdultContentFilter UI组件
- 添加用户设置页面和认证检查
- 更新配置示例和README文档
- 实现LocalStorage和Redis存储后端
- 默认启用过滤确保安全性
This commit is contained in:
katelya
2025-09-04 21:11:02 +08:00
parent c9429efba6
commit 86ebbb2cf6
12 changed files with 658 additions and 17 deletions
+49
View File
@@ -39,6 +39,7 @@
- **📖 播放历史**:自动记录观看历史,快速找回看过的内容
- **👥 多用户支持**:独立的用户系统,每个用户独享个人数据
- **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash
- **🔒 内容过滤**:智能成人内容过滤系统,默认开启安全保护
### 🚀 部署特性
@@ -586,6 +587,54 @@ docker run -d \
## 📱 高级功能使用指南
### 🔒 成人内容过滤
**功能介绍**
- 智能识别和过滤成人内容资源站
- 用户可自主选择开启或关闭过滤功能
- 默认开启过滤,确保安全浏览体验
- 支持资源分组显示,避免误触
**使用方法**
1. **访问用户设置**
- 登录后访问 `/settings` 页面
- 或在用户菜单中点击「用户设置」
2. **配置过滤选项**
- 在「内容过滤」部分找到「成人内容过滤」开关
- **开启**:完全隐藏成人内容资源站和搜索结果
- **关闭**:成人内容在搜索结果中单独分组显示
3. **搜索结果展示**
- **过滤开启时**:只显示常规内容
- **过滤关闭时**:显示两个标签页「常规结果」和「成人内容」
**配置文件格式**
```json
// config.json 中的资源站配置
{
"api_site": {
"regular_site": {
"api": "https://example.com/api.php/provide/vod",
"name": "常规影视站",
"is_adult": false // 或省略此字段,默认为 false
},
"adult_site": {
"api": "https://adult.example.com/api.php/provide/vod",
"name": "成人内容站",
"is_adult": true // 标记为成人内容
}
}
}
```
**安全提示**
- 默认情况下,所有新用户和未登录用户的成人内容过滤均为开启状态
- 关闭过滤功能需要用户主动操作,确保使用意图明确
- 建议管理员在配置资源站时准确标记 `is_adult` 字段
### 🎯 跳过片头片尾
**功能介绍**
+8 -1
View File
@@ -4,7 +4,14 @@
"example_test": {
"api": "https://example.com/api.php/provide/vod",
"name": "示例视频源",
"detail": "https://example.com"
"detail": "https://example.com",
"is_adult": false
},
"adult_example": {
"api": "https://adult-example.com/api.php/provide/vod",
"name": "成人内容源(仅供示例)",
"detail": "https://adult-example.com",
"is_adult": true
}
}
}
+43 -10
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getAdultApiSites, getAvailableApiSites, getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
import { getStorage } from '@/lib/db';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
@@ -14,11 +15,16 @@ export async function OPTIONS() {
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const includeAdult = searchParams.get('include_adult') === 'true';
const userName = searchParams.get('user'); // 用于获取用户设置
if (!query) {
const cacheTime = await getCacheTime();
const response = NextResponse.json(
{ results: [] },
{
regular_results: [],
adult_results: []
},
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
@@ -30,16 +36,36 @@ export async function GET(request: Request) {
return addCorsHeaders(response);
}
const apiSites = await getAvailableApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
const cacheTime = await getCacheTime();
// 获取用户设置以确定是否需要过滤成人内容
let shouldFilterAdult = true; // 默认过滤成人内容
if (userName) {
const storage = getStorage();
const userSettings = await storage.getUserSettings(userName);
shouldFilterAdult = userSettings?.filter_adult_content !== false;
}
// 获取常规资源站
const regularSites = await getAvailableApiSites(true); // 总是过滤成人内容
const regularSearchPromises = regularSites.map((site) => searchFromApi(site, query));
const regularResults = (await Promise.all(regularSearchPromises)).flat();
let adultResults: unknown[] = [];
// 如果用户设置允许且明确请求包含成人内容,则搜索成人资源站
if (!shouldFilterAdult && includeAdult) {
const adultSites = await getAdultApiSites();
const adultSearchPromises = adultSites.map((site) => searchFromApi(site, query));
adultResults = (await Promise.all(adultSearchPromises)).flat();
}
const cacheTime = await getCacheTime();
const response = NextResponse.json(
{ results: flattenedResults },
{
regular_results: regularResults,
adult_results: adultResults
},
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
@@ -50,7 +76,14 @@ export async function GET(request: Request) {
);
return addCorsHeaders(response);
} catch (error) {
const response = NextResponse.json({ error: '搜索失败' }, { status: 500 });
const response = NextResponse.json(
{
regular_results: [],
adult_results: [],
error: '搜索失败'
},
{ status: 500 }
);
return addCorsHeaders(response);
}
}
+128
View File
@@ -0,0 +1,128 @@
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { getStorage } from '@/lib/db';
import { UserSettings } from '@/lib/types';
// 获取用户设置
export async function GET(_request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1]; // 假设格式为 "Bearer username"
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const storage = getStorage();
const settings = await storage.getUserSettings(userName);
return NextResponse.json({
settings: settings || {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting user settings:', error);
return NextResponse.json({ error: '获取用户设置失败' }, { status: 500 });
}
}
// 更新用户设置
export async function PATCH(request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1];
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const body = await request.json();
const { settings } = body as { settings: Partial<UserSettings> };
if (!settings) {
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
}
const storage = getStorage();
// 验证用户存在
const userExists = await storage.checkUserExist(userName);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await storage.updateUserSettings(userName, settings);
return NextResponse.json({
success: true,
message: '设置更新成功'
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error updating user settings:', error);
return NextResponse.json({ error: '更新用户设置失败' }, { status: 500 });
}
}
// 重置用户设置
export async function PUT(request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1];
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const body = await request.json();
const { settings } = body as { settings: UserSettings };
if (!settings) {
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
}
const storage = getStorage();
// 验证用户存在
const userExists = await storage.checkUserExist(userName);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await storage.setUserSettings(userName, settings);
return NextResponse.json({
success: true,
message: '设置已重置'
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error resetting user settings:', error);
return NextResponse.json({ error: '重置用户设置失败' }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Settings, User } from 'lucide-react';
import AdultContentFilter from '@/components/AdultContentFilter';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
export default function UserSettingsPage() {
const router = useRouter();
const [authInfo, setAuthInfo] = useState<{ userName: string } | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const auth = getAuthInfoFromBrowserCookie();
if (!auth || !auth.username) {
// 如果用户未登录,重定向到登录页面
router.push('/login');
return;
}
setAuthInfo({ userName: auth.username });
setIsLoading(false);
}, [router]);
const handleFilterUpdate = (_enabled: boolean) => {
// 可以在这里添加一些全局状态更新或通知逻辑
// console.log('成人内容过滤状态已更新:', enabled);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!authInfo) {
return null;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-4xl mx-auto p-6">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<button
onClick={() => router.back()}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<Settings className="w-8 h-8 mr-3 text-blue-600" />
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
</p>
</div>
</div>
<div className="flex items-center space-x-3 px-4 py-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<User className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{authInfo.userName}
</span>
</div>
</div>
{/* 设置区域 */}
<div className="space-y-6">
{/* 内容过滤设置 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
</h2>
<AdultContentFilter
userName={authInfo.userName}
onUpdate={handleFilterUpdate}
/>
</div>
{/* 其他设置部分预留 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
...
</p>
</div>
</div>
</div>
{/* 底部信息 */}
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
<p></p>
</div>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useEffect, useState } from 'react';
import { Shield, ShieldOff } from 'lucide-react';
interface AdultContentFilterProps {
userName: string;
onUpdate?: (enabled: boolean) => void;
}
const AdultContentFilter: React.FC<AdultContentFilterProps> = ({
userName,
onUpdate
}) => {
const [isEnabled, setIsEnabled] = useState(true); // 默认开启过滤
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 获取用户设置
useEffect(() => {
const fetchUserSettings = async () => {
if (!userName) return;
try {
const response = await fetch('/api/user/settings', {
headers: {
'Authorization': `Bearer ${userName}`,
},
});
if (response.ok) {
const data = await response.json();
setIsEnabled(data.settings.filter_adult_content);
} else {
setError('获取用户设置失败');
}
} catch (err) {
setError('网络连接失败');
// eslint-disable-next-line no-console
console.error('Failed to fetch user settings:', err);
}
};
fetchUserSettings();
}, [userName]);
// 更新用户设置
const handleToggle = async () => {
if (!userName || isLoading) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/user/settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userName}`,
},
body: JSON.stringify({
settings: {
filter_adult_content: !isEnabled,
},
}),
});
if (response.ok) {
const newState = !isEnabled;
setIsEnabled(newState);
onUpdate?.(newState);
} else {
const errorData = await response.json();
setError(errorData.error || '更新设置失败');
}
} catch (err) {
setError('网络连接失败');
// eslint-disable-next-line no-console
console.error('Failed to update user settings:', err);
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900">
{isEnabled ? (
<Shield className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : (
<ShieldOff className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{isEnabled
? '已开启过滤,将自动隐藏所有标记为"成人"的资源站及其内容'
: '已关闭过滤,成人内容将在搜索结果中单独分组显示'
}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleToggle}
disabled={isLoading || !userName}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
${isEnabled
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${isEnabled ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
{isLoading && (
<div className="w-5 h-5">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
)}
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md">
<div className="flex items-start">
<div className="flex-shrink-0">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-amber-800 dark:text-amber-200">
</h4>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
使访18
</p>
</div>
</div>
</div>
</div>
);
};
export default AdultContentFilter;
+1
View File
@@ -22,6 +22,7 @@ export interface AdminConfig {
detail?: string;
from: 'config' | 'custom';
disabled?: boolean;
is_adult?: boolean; // 新增:是否为成人内容资源站
}[];
}
+23 -2
View File
@@ -378,9 +378,30 @@ export async function getCacheTime(): Promise<number> {
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
}
export async function getAvailableApiSites(): Promise<ApiSite[]> {
export async function getAvailableApiSites(filterAdult = false): Promise<ApiSite[]> {
const config = await getConfig();
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
let sites = config.SourceConfig.filter((s) => !s.disabled);
// 如果需要过滤成人内容,则排除标记为成人内容的资源站
if (filterAdult) {
sites = sites.filter((s) => !s.is_adult);
}
return sites.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
}));
}
// 获取成人内容资源站
export async function getAdultApiSites(): Promise<ApiSite[]> {
const config = await getConfig();
const adultSites = config.SourceConfig
.filter((s) => !s.disabled && s.is_adult);
return adultSites.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
+52 -2
View File
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types';
/**
* LocalStorage 存储实现
@@ -290,6 +290,56 @@ export class LocalStorage implements IStorage {
}
}
// ---------- 用户设置 ----------
async getUserSettings(userName: string): Promise<UserSettings | null> {
if (typeof window === 'undefined') return null;
try {
const storageKey = this.getStorageKey('settings', userName);
const data = localStorage.getItem(storageKey);
if (data) {
return JSON.parse(data);
}
// 如果用户设置不存在,返回默认设置
const defaultSettings: UserSettings = {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
};
return defaultSettings;
} catch (error) {
console.error('Error getting user settings:', error);
return null;
}
}
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
if (typeof window === 'undefined') return;
try {
const storageKey = this.getStorageKey('settings', userName);
localStorage.setItem(storageKey, JSON.stringify(settings));
} catch (error) {
console.error('Error setting user settings:', error);
}
}
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
if (typeof window === 'undefined') return;
try {
const currentSettings = await this.getUserSettings(userName);
const updatedSettings = { ...currentSettings, ...settings };
await this.setUserSettings(userName, updatedSettings as UserSettings);
} catch (error) {
console.error('Error updating user settings:', error);
}
}
// ---------- 管理员功能 ----------
async getAllUsers(): Promise<string[]> {
if (typeof window === 'undefined') return [];
@@ -365,7 +415,7 @@ export class LocalStorage implements IStorage {
localStorage.removeItem(userKey);
// 删除用户相关的所有数据
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig'];
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig', 'settings'];
for (const prefix of prefixes) {
const dataPrefix = this.getStorageKey(prefix, userName);
+45 -1
View File
@@ -3,7 +3,7 @@
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -223,6 +223,50 @@ export class RedisStorage implements IStorage {
if (favoriteKeys.length > 0) {
await withRetry(() => this.client.del(favoriteKeys));
}
// 删除用户设置
await withRetry(() => this.client.del(this.userSettingsKey(userName)));
}
// ---------- 用户设置 ----------
private userSettingsKey(user: string) {
return `u:${user}:settings`; // u:username:settings
}
async getUserSettings(userName: string): Promise<UserSettings | null> {
const data = await withRetry(() =>
this.client.get(this.userSettingsKey(userName))
);
if (data) {
return JSON.parse(ensureString(data));
}
// 如果用户设置不存在,返回默认设置
const defaultSettings: UserSettings = {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
};
return defaultSettings;
}
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
await withRetry(() =>
this.client.set(
this.userSettingsKey(userName),
JSON.stringify(settings)
)
);
}
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
const currentSettings = await this.getUserSettings(userName);
const updatedSettings = { ...currentSettings, ...settings };
await this.setUserSettings(userName, updatedSettings as UserSettings);
}
// ---------- 搜索历史 ----------
+37
View File
@@ -70,6 +70,11 @@ export interface IStorage {
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
deleteUser(userName: string): Promise<void>;
// 用户设置相关
getUserSettings(userName: string): Promise<UserSettings | null>;
setUserSettings(userName: string, settings: UserSettings): Promise<void>;
updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void>;
// 搜索历史相关
getSearchHistory(userName: string): Promise<string[]>;
addSearchHistory(userName: string, keyword: string): Promise<void>;
@@ -119,6 +124,38 @@ export interface DoubanResult {
list: DoubanItem[];
}
// 资源站配置
export interface ApiSite {
api: string;
name: string;
detail?: string;
type?: number;
playMode?: 'parse' | 'direct';
is_adult?: boolean; // 新增:是否为成人内容资源站
}
// 配置文件结构
export interface Config {
cache_time: number;
api_site: { [key: string]: ApiSite };
}
// 用户设置
export interface UserSettings {
filter_adult_content: boolean; // 是否过滤成人内容,默认为 true
theme: 'light' | 'dark' | 'auto';
language: string;
auto_play: boolean;
video_quality: string;
[key: string]: string | boolean | number; // 允许其他设置
}
// 搜索结果(支持成人内容分组)
export interface GroupedSearchResults {
regular_results: SearchResult[];
adult_results?: SearchResult[];
}
// Runtime配置类型
export interface RuntimeConfig {
STORAGE_TYPE?: string;
+1 -1
View File
@@ -3,7 +3,7 @@
import { Redis } from '@upstash/redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;