feat: 新增平台

This commit is contained in:
2025-11-30 19:49:25 +08:00
parent c3e56a954d
commit fbd2c491b2
41 changed files with 4293 additions and 76 deletions

View File

@@ -1,31 +1,10 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__list_pages",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot",
"mcp__chrome-devtools__fill",
"mcp__chrome-devtools__click",
"mcp__chrome-devtools__list_console_messages",
"mcp__chrome-devtools__list_network_requests",
"mcp__chrome-devtools__get_console_message",
"mcp__chrome-devtools__get_network_request",
"Bash(python:*)",
"mcp__chrome-devtools__fill_form",
"mcp__chrome-devtools__handle_dialog",
"Bash(grep:*)",
"mcp__sequential-thinking__sequentialthinking",
"mcp__chrome-devtools__wait_for",
"Bash(curl:*)",
"Bash(mysql:*)",
"Bash(dir:*)",
"WebSearch",
"Bash(findstr:*)",
"Bash(cmd /c \"del NUL\")",
"Bash(find:*)",
"Bash(for file in *.html)",
"Bash(do sed -i 's/ui-components.css?v=2/ui-components.css?v=3/g' \"$file\")",
"Bash(done)"
"Bash(python -m py_compile:*)",
"Bash(curl -s \"https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx\")",
"Bash(curl -s \"https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/\")",
"Bash(curl:*)"
],
"deny": [],
"ask": []

View File

@@ -173,6 +173,22 @@ redis-server.exe redis.windows.conf
- 站点配置Logo、标题、公告等存储在 `site_configs` 表中
- 首次运行前必须执行 `init_admin.py``init_data.py` 初始化数据
## 解析请求流程
1. 用户提交视频URL → `routes/parser.py:parse_video()`
2. 限流检查 → `utils/limiter.py:RateLimiter.check_limit()`
3. 平台检测 → `parsers/factory.py:ParserFactory.detect_platform()`
4. 并发检查 → `utils/queue.py:ConcurrencyController.can_process()`
- 可处理:立即执行 `_process_task()`
- 队列满:返回 `task_id`,前端轮询 `/api/task/<task_id>`
5. 解析执行 → 遍历该平台所有启用的API依次尝试直到成功failover机制
6. 结果存储 → Redis1小时过期或内存队列
**API Failover 机制**`routes/parser.py:110-170`
- 按顺序尝试所有启用的API不考虑健康状态
- 任一成功即返回,失败则继续下一个
- 全部失败才返回错误
## 访问地址
- 前台首页:`http://localhost:5000`

4
app.py
View File

@@ -47,11 +47,15 @@ def create_app():
from routes.parser import parser_bp
from routes.admin import admin_bp
from routes.main import main_bp
from routes.api_v1 import api_v1_bp
from routes.apikey import apikey_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(parser_bp, url_prefix='/api')
app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(main_bp)
app.register_blueprint(api_v1_bp) # /api/v1/*
app.register_blueprint(apikey_bp) # /user/apikey/*
# 初始化定时任务(仅在非调试模式或主进程中启动)
# 注意:初始化脚本运行时不启动调度器

View File

@@ -0,0 +1,35 @@
-- API Key 相关表结构
-- 执行方式: mysql -u root -p video_parser < database/api_key_tables.sql
-- 用户 API Key 表
CREATE TABLE IF NOT EXISTS `user_api_keys` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`name` VARCHAR(100) NOT NULL COMMENT 'Key名称',
`api_key` VARCHAR(64) NOT NULL UNIQUE COMMENT 'API Key',
`is_active` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
`daily_limit` INT DEFAULT 100 COMMENT '每日调用限制',
`total_calls` INT DEFAULT 0 COMMENT '总调用次数',
`last_used_at` DATETIME COMMENT '最后使用时间',
`last_used_ip` VARCHAR(45) COMMENT '最后使用IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
INDEX `idx_api_key` (`api_key`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户API Key表';
-- API Key 每日统计表
CREATE TABLE IF NOT EXISTS `api_key_daily_stats` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`api_key_id` INT NOT NULL,
`date` DATE NOT NULL,
`call_count` INT DEFAULT 0 COMMENT '调用次数',
`success_count` INT DEFAULT 0 COMMENT '成功次数',
`fail_count` INT DEFAULT 0 COMMENT '失败次数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`api_key_id`) REFERENCES `user_api_keys`(`id`) ON DELETE CASCADE,
UNIQUE KEY `uk_api_key_date` (`api_key_id`, `date`),
INDEX `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API Key每日统计表';

View File

@@ -0,0 +1,23 @@
-- 新平台解析接口初始化数据
-- 执行方式: mysql -u root -p video_parser < database/new_platforms.sql
-- 快手解析接口
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
('BugPK快手API', 'kuaishou', 'https://api.bugpk.com', '', 1, 1, 1),
('优创快手API', 'kuaishou', 'http://apis.uctb.cn', '', 1, 1, 1);
-- 皮皮虾解析接口
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
('BugPK皮皮虾API', 'pipixia', 'https://api.bugpk.com', '', 1, 1, 1),
('优创皮皮虾API', 'pipixia', 'http://apis.uctb.cn', '', 1, 1, 1);
-- 微博解析接口
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
('优创微博API', 'weibo', 'http://apis.uctb.cn', '', 1, 1, 1),
('妖狐微博API', 'weibo', 'https://api.yaohud.cn', 'SM227DLC0ZgJ6DXJhAx', 1, 1, 1);
-- 健康检查配置
INSERT INTO `health_check_config` (`platform`, `test_url`, `check_interval`, `is_enabled`) VALUES
('kuaishou', 'https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx', 300, 1),
('pipixia', 'https://h5.pipix.com/s/kLUS2a4iJ_M/', 300, 1),
('weibo', 'https://video.weibo.com/show?fid=1034:5233299304415254', 300, 1);

37
database/redeem_codes.sql Normal file
View File

@@ -0,0 +1,37 @@
-- 兑换码功能数据库表
-- 执行方式: mysql -u root -p video_parser < database/redeem_codes.sql
-- 兑换码表
CREATE TABLE IF NOT EXISTS `redeem_codes` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`code` VARCHAR(32) NOT NULL UNIQUE COMMENT '兑换码',
`batch_id` VARCHAR(32) COMMENT '批次ID',
`target_group_id` INT NOT NULL COMMENT '目标用户组ID',
`duration_days` INT DEFAULT 30 COMMENT '有效期天数',
`is_used` TINYINT(1) DEFAULT 0 COMMENT '是否已使用',
`used_by` INT COMMENT '使用者用户ID',
`used_at` DATETIME COMMENT '使用时间',
`expires_at` DATETIME COMMENT '兑换码过期时间',
`remark` VARCHAR(255) COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`target_group_id`) REFERENCES `user_groups`(`id`),
FOREIGN KEY (`used_by`) REFERENCES `users`(`id`),
INDEX `idx_code` (`code`),
INDEX `idx_batch_id` (`batch_id`),
INDEX `idx_is_used` (`is_used`),
INDEX `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='兑换码表';
-- 用户组到期时间表
CREATE TABLE IF NOT EXISTS `user_group_expiry` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL UNIQUE COMMENT '用户ID',
`group_id` INT NOT NULL COMMENT '用户组ID',
`expires_at` DATETIME NOT NULL COMMENT '到期时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`),
FOREIGN KEY (`group_id`) REFERENCES `user_groups`(`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组到期时间表';

View File

@@ -159,3 +159,73 @@ class HealthCheckLog(db.Model):
response_time = db.Column(db.Integer)
error_message = db.Column(db.Text)
checked_at = db.Column(db.DateTime, default=datetime.utcnow)
class UserApiKey(db.Model):
"""用户 API Key"""
__tablename__ = 'user_api_keys'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
name = db.Column(db.String(100), nullable=False) # Key 名称,方便用户区分
api_key = db.Column(db.String(64), unique=True, nullable=False) # API Key
is_active = db.Column(db.Boolean, default=True)
daily_limit = db.Column(db.Integer, default=100) # 每日调用限制
total_calls = db.Column(db.Integer, default=0) # 总调用次数
last_used_at = db.Column(db.DateTime) # 最后使用时间
last_used_ip = db.Column(db.String(45)) # 最后使用IP
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User', backref='api_keys')
class ApiKeyDailyStat(db.Model):
"""API Key 每日统计"""
__tablename__ = 'api_key_daily_stats'
id = db.Column(db.Integer, primary_key=True)
api_key_id = db.Column(db.Integer, db.ForeignKey('user_api_keys.id'), nullable=False)
date = db.Column(db.Date, nullable=False)
call_count = db.Column(db.Integer, default=0)
success_count = db.Column(db.Integer, default=0)
fail_count = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
api_key = db.relationship('UserApiKey', backref='daily_stats')
class RedeemCode(db.Model):
"""兑换码"""
__tablename__ = 'redeem_codes'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(32), unique=True, nullable=False) # 兑换码
batch_id = db.Column(db.String(32)) # 批次ID用于批量管理
target_group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False) # 兑换后的用户组
duration_days = db.Column(db.Integer, default=30) # 有效期天数
is_used = db.Column(db.Boolean, default=False) # 是否已使用
used_by = db.Column(db.Integer, db.ForeignKey('users.id')) # 使用者
used_at = db.Column(db.DateTime) # 使用时间
expires_at = db.Column(db.DateTime) # 兑换码过期时间
remark = db.Column(db.String(255)) # 备注
created_at = db.Column(db.DateTime, default=datetime.utcnow)
target_group = db.relationship('UserGroup', backref='redeem_codes')
user = db.relationship('User', backref='redeemed_codes')
class UserGroupExpiry(db.Model):
"""用户组到期时间记录"""
__tablename__ = 'user_group_expiry'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, unique=True)
group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False)
expires_at = db.Column(db.DateTime, nullable=False) # 到期时间
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User', backref='group_expiry')
group = db.relationship('UserGroup')

View File

@@ -44,11 +44,12 @@ class BaseParser(ABC):
except requests.RequestException as e:
raise Exception(f"请求失败: {str(e)}")
def _normalize_response(self, cover: str, video_url: str, title: str, description: str) -> Dict:
def _normalize_response(self, cover: str, video_url: str, title: str, description: str, author: str = "") -> Dict:
"""标准化返回数据"""
return {
"cover": cover or "",
"video_url": video_url or "",
"title": title or "",
"description": description or ""
"description": description or "",
"author": author or ""
}

View File

@@ -32,8 +32,9 @@ class BilibiliMirParser(BaseParser):
video_url = video_data.get("url", "") or video_data.get("video_url", "")
title = video_data.get("title", "")
description = video_data.get("desc", "") or video_data.get("description", "")
author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "")
return self._normalize_response(cover, video_url, title, description)
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
@@ -70,8 +71,9 @@ class BilibiliBugPKParser(BaseParser):
video_url = video_data.get("url", "") or video_data.get("video_url", "")
title = video_data.get("title", "")
description = video_data.get("desc", "") or video_data.get("description", "")
author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "")
return self._normalize_response(cover, video_url, title, description)
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
@@ -105,6 +107,7 @@ class BilibiliYaohuParser(BaseParser):
cover = basic.get("cover", "")
title = basic.get("title", "")
description = basic.get("description", "")
author = basic.get("author", "") or basic.get("owner", {}).get("name", "")
# 提取视频URL - 优先使用data.video_url其次使用videos[0].url
video_url = video_data.get("video_url", "")
@@ -113,7 +116,7 @@ class BilibiliYaohuParser(BaseParser):
if isinstance(videos, list) and videos:
video_url = videos[0].get("url", "")
return self._normalize_response(cover, video_url, title, description)
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: 不支持的类型 {data.get('parse_type')}")
except Exception as e:

View File

@@ -57,15 +57,15 @@ class DouyinParser(BaseParser):
# 提取标题(描述)
title = video_info.get("desc", "")
# 提取作者信息作为简介
author = video_info.get("author", {})
author_name = author.get("nickname", "")
author_signature = author.get("signature", "")
description = f"作者: {author_name}"
if author_signature:
description += f" | {author_signature}"
# 提取作者信息
author_info = video_info.get("author", {})
author_name = author_info.get("nickname", "")
author_signature = author_info.get("signature", "")
return self._normalize_response(cover, video_url, title, description)
# 简介使用作者签名
description = author_signature or ""
return self._normalize_response(cover, video_url, title, description, author_name)
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")

View File

@@ -1,12 +1,48 @@
from parsers.douyin import DouyinParser
from parsers.tiktok import TikTokParser
from parsers.bilibili import BilibiliMirParser, BilibiliBugPKParser, BilibiliYaohuParser
from parsers.kuaishou import KuaishouBugPKParser, KuaishouUctbParser
from parsers.pipixia import PipixiaBugPKParser, PipixiaUctbParser
from parsers.weibo import WeiboUctbParser, WeiboYaohuParser
from models import ParserAPI
import random
import requests
class ParserFactory:
"""解析器工厂类"""
@staticmethod
def expand_short_url(url: str) -> str:
"""展开短链接获取真实URL"""
short_domains = ['b23.tv', 'v.douyin.com', 't.cn']
# 检查是否是短链接
is_short = any(domain in url.lower() for domain in short_domains)
if not is_short:
return url
try:
# 发送请求,不跟随重定向,获取 Location 头
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.head(url, headers=headers, allow_redirects=False, timeout=10)
if response.status_code in [301, 302, 303, 307, 308]:
location = response.headers.get('Location', '')
if location:
# 如果还是短链接,继续展开
if any(domain in location.lower() for domain in short_domains):
return ParserFactory.expand_short_url(location)
return location
# 如果 HEAD 请求不行,尝试 GET 请求跟随重定向
response = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
return response.url
except Exception:
# 展开失败返回原URL
return url
@staticmethod
def create_parser(api_config: ParserAPI):
"""根据API配置创建解析器实例"""
@@ -28,6 +64,30 @@ class ParserFactory:
return BilibiliYaohuParser(api_url, api_key)
else:
return BilibiliMirParser(api_url, api_key)
elif platform == 'kuaishou':
# 快手解析器
if 'bugpk' in api_url:
return KuaishouBugPKParser(api_url, api_key)
elif 'uctb' in api_url:
return KuaishouUctbParser(api_url, api_key)
else:
return KuaishouBugPKParser(api_url, api_key)
elif platform == 'pipixia':
# 皮皮虾解析器
if 'bugpk' in api_url:
return PipixiaBugPKParser(api_url, api_key)
elif 'uctb' in api_url:
return PipixiaUctbParser(api_url, api_key)
else:
return PipixiaBugPKParser(api_url, api_key)
elif platform == 'weibo':
# 微博解析器
if 'uctb' in api_url:
return WeiboUctbParser(api_url, api_key)
elif 'yaohud' in api_url:
return WeiboYaohuParser(api_url, api_key)
else:
return WeiboUctbParser(api_url, api_key)
else:
raise ValueError(f"不支持的平台: {platform}")
@@ -83,5 +143,11 @@ class ParserFactory:
return 'tiktok'
elif 'bilibili.com' in url_lower or 'b23.tv' in url_lower:
return 'bilibili'
elif 'kuaishou.com' in url_lower or 'gifshow.com' in url_lower:
return 'kuaishou'
elif 'pipix.com' in url_lower or 'pipixia.com' in url_lower or 'h5.pipix.com' in url_lower:
return 'pipixia'
elif 'weibo.com' in url_lower or 'weibo.cn' in url_lower:
return 'weibo'
else:
raise ValueError("无法识别的视频平台")

89
parsers/kuaishou.py Normal file
View File

@@ -0,0 +1,89 @@
from parsers.base import BaseParser
from typing import Dict
from urllib.parse import urlencode
class KuaishouBugPKParser(BaseParser):
"""快手解析器 - BugPK API"""
def parse(self, video_url: str) -> Dict:
"""解析快手视频"""
try:
url = f"{self.api_url}/api/kuaishou?{urlencode({'url': video_url})}"
response = self._make_request(url)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"快手解析失败(BugPK API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"msg": "解析成功",
"data": {
"title": "视频标题",
"cover": "封面URL",
"url": "视频URL"
}
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
video_url = video_data.get("url", "")
title = video_data.get("title", "")
description = "" # BugPK API 不返回简介
author = "" # BugPK API 不返回作者
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")
class KuaishouUctbParser(BaseParser):
"""快手解析器 - 优创 API"""
def parse(self, video_url: str) -> Dict:
"""解析快手视频"""
try:
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
response = self._make_request(url)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"快手解析失败(优创 API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"data": {
"desc": "视频描述",
"cover": "封面URL",
"playurl": "视频URL"
},
"msg": "请求成功"
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
video_url = video_data.get("playurl", "")
title = video_data.get("desc", "")
description = ""
author = "" # 优创API不返回作者
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")

91
parsers/pipixia.py Normal file
View File

@@ -0,0 +1,91 @@
from parsers.base import BaseParser
from typing import Dict
from urllib.parse import urlencode
class PipixiaBugPKParser(BaseParser):
"""皮皮虾解析器 - BugPK API"""
def parse(self, video_url: str) -> Dict:
"""解析皮皮虾视频"""
try:
url = f"{self.api_url}/api/pipixia?{urlencode({'url': video_url})}"
response = self._make_request(url)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"皮皮虾解析失败(BugPK API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"msg": "解析成功",
"data": {
"author": "作者名",
"avatar": "头像URL",
"title": "标题",
"cover": "封面URL",
"url": "视频URL"
}
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
video_url = video_data.get("url", "")
title = video_data.get("title", "")
description = ""
author = video_data.get("author", "")
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")
class PipixiaUctbParser(BaseParser):
"""皮皮虾解析器 - 优创 API"""
def parse(self, video_url: str) -> Dict:
"""解析皮皮虾视频"""
try:
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
response = self._make_request(url)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"皮皮虾解析失败(优创 API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"data": {
"desc": null,
"cover": "封面URL",
"playurl": "视频URL"
},
"msg": "请求成功"
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
video_url = video_data.get("playurl", "")
title = video_data.get("desc", "") or ""
description = ""
author = "" # 优创API不返回作者
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")

View File

@@ -58,13 +58,14 @@ class TikTokParser(BaseParser):
title = video_info.get("desc", "")
author = video_info.get("author", {})
author_name = author.get("nickname", "")
author_signature = author.get("signature", "")
description = f"Author: {author_name}"
if author_signature:
description += f" | {author_signature}"
# 提取作者信息
author_info = video_info.get("author", {})
author_name = author_info.get("nickname", "")
author_signature = author_info.get("signature", "")
return self._normalize_response(cover, video_url, title, description)
# 简介使用作者签名
description = author_signature or ""
return self._normalize_response(cover, video_url, title, description, author_name)
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")

105
parsers/weibo.py Normal file
View File

@@ -0,0 +1,105 @@
from parsers.base import BaseParser
from typing import Dict
from urllib.parse import urlencode
class WeiboUctbParser(BaseParser):
"""微博解析器 - 优创 API"""
def parse(self, video_url: str) -> Dict:
"""解析微博视频"""
try:
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
response = self._make_request(url)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"微博解析失败(优创 API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"data": {
"desc": "视频描述",
"cover": "封面URL",
"playurl": "视频URL"
},
"msg": "请求成功"
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
video_url = video_data.get("playurl", "")
title = video_data.get("desc", "") or ""
description = ""
author = "" # 优创API不返回作者
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")
class WeiboYaohuParser(BaseParser):
"""微博解析器 - 妖狐 API"""
def parse(self, video_url: str) -> Dict:
"""解析微博视频"""
try:
url = f"{self.api_url}/api/v6/video/weibo?{urlencode({'key': self.api_key, 'url': video_url})}"
response = self._make_request(url, verify=False)
data = response.json()
return self._extract_data(data)
except Exception as e:
raise Exception(f"微博解析失败(妖狐 API): {str(e)}")
def _extract_data(self, data: Dict) -> Dict:
"""提取并标准化数据
实际返回格式:
{
"code": 200,
"msg": "解析成功",
"data": {
"author": "作者名",
"title": "标题",
"cover": "封面URL",
"quality_urls": {
"超清 2K60": "视频URL",
"高清 1080P": "视频URL",
...
}
}
}
"""
try:
if data.get("code") == 200:
video_data = data.get("data", {})
cover = video_data.get("cover", "")
title = video_data.get("title", "")
description = ""
author = video_data.get("author", "")
# 从 quality_urls 中获取最高质量的视频URL
quality_urls = video_data.get("quality_urls", {})
video_url = ""
# 按优先级选择:超清 > 高清1080P > 高清720P > 其他
for quality in ["超清 2K60", "高清 1080P", "高清 720P"]:
if quality in quality_urls:
video_url = quality_urls[quality]
break
# 如果没找到,取第一个可用的
if not video_url and quality_urls:
video_url = list(quality_urls.values())[0]
return self._normalize_response(cover, video_url, title, description, author)
else:
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
except Exception as e:
raise Exception(f"数据提取失败: {str(e)}")

View File

@@ -113,6 +113,8 @@ def users_page():
@admin_required
def get_users():
"""获取用户列表API"""
from models import UserGroupExpiry
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
group_id = request.args.get('group_id', type=int)
@@ -126,16 +128,28 @@ def get_users():
page=page, per_page=per_page, error_out=False
)
users = [{
'id': u.id,
'username': u.username,
'email': u.email,
'group_id': u.group_id,
'group_name': u.group.name if u.group else '',
'total_parse_count': u.total_parse_count,
'is_active': u.is_active,
'created_at': u.created_at.isoformat()
} for u in pagination.items]
users = []
for u in pagination.items:
# 获取套餐到期时间
expiry = UserGroupExpiry.query.filter_by(user_id=u.id).first()
expires_at = None
is_expired = False
if expiry and expiry.expires_at:
expires_at = expiry.expires_at.strftime('%Y-%m-%dT%H:%M')
is_expired = expiry.expires_at < datetime.utcnow()
users.append({
'id': u.id,
'username': u.username,
'email': u.email,
'group_id': u.group_id,
'group_name': u.group.name if u.group else '',
'total_parse_count': u.total_parse_count,
'is_active': u.is_active,
'created_at': u.created_at.isoformat(),
'expires_at': expires_at,
'is_expired': is_expired
})
return jsonify({
'success': True,
@@ -152,6 +166,8 @@ def get_users():
@admin_required
def update_user(user_id):
"""更新用户信息"""
from models import UserGroupExpiry
user = User.query.get_or_404(user_id)
data = request.get_json()
@@ -160,6 +176,29 @@ def update_user(user_id):
if 'is_active' in data:
user.is_active = data['is_active']
# 处理套餐到期时间
if 'expires_at' in data:
expiry = UserGroupExpiry.query.filter_by(user_id=user_id).first()
if data['expires_at']:
# 解析时间字符串
expires_at = datetime.strptime(data['expires_at'], '%Y-%m-%dT%H:%M')
if expiry:
expiry.group_id = user.group_id
expiry.expires_at = expires_at
else:
expiry = UserGroupExpiry(
user_id=user_id,
group_id=user.group_id,
expires_at=expires_at
)
db.session.add(expiry)
else:
# 清空到期时间(游客/普通用户)
if expiry:
db.session.delete(expiry)
db.session.commit()
return jsonify({'success': True, 'message': '更新成功'})
@@ -766,3 +805,230 @@ def change_email():
admin.email = email
db.session.commit()
return jsonify({'success': True, 'message': '邮箱修改成功'})
# ==================== 用户组 API ====================
@admin_bp.route('/api/user-groups', methods=['GET'])
@admin_required
def get_user_groups():
"""获取用户组列表(仅返回可兑换的套餐,排除游客和普通用户)"""
# 排除游客(id=1)和普通用户(id=2)
groups = UserGroup.query.filter(UserGroup.id > 2).all()
return jsonify({
'success': True,
'data': [{'id': g.id, 'name': g.name, 'daily_limit': g.daily_limit} for g in groups]
})
# ==================== 兑换码管理 ====================
@admin_bp.route('/redeem-codes')
@admin_required
def redeem_codes_page():
"""兑换码管理页面"""
from flask import render_template
return render_template('admin_redeem_codes.html')
@admin_bp.route('/api/redeem-codes', methods=['GET'])
@admin_required
def get_redeem_codes():
"""获取兑换码列表"""
from models import RedeemCode
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
batch_id = request.args.get('batch_id', '')
status = request.args.get('status', '') # unused, used, expired
query = RedeemCode.query
if batch_id:
query = query.filter(RedeemCode.batch_id == batch_id)
if status == 'unused':
query = query.filter(RedeemCode.is_used == False)
elif status == 'used':
query = query.filter(RedeemCode.is_used == True)
elif status == 'expired':
query = query.filter(RedeemCode.is_used == False, RedeemCode.expires_at < datetime.utcnow())
pagination = query.order_by(RedeemCode.created_at.desc()).paginate(page=page, per_page=per_page)
codes = []
for code in pagination.items:
codes.append({
'id': code.id,
'code': code.code,
'batch_id': code.batch_id,
'target_group': code.target_group.name if code.target_group else '',
'target_group_id': code.target_group_id,
'duration_days': code.duration_days,
'is_used': code.is_used,
'used_by': code.user.username if code.user else None,
'used_at': code.used_at.strftime('%Y-%m-%d %H:%M') if code.used_at else None,
'expires_at': code.expires_at.strftime('%Y-%m-%d %H:%M') if code.expires_at else None,
'is_expired': code.expires_at and code.expires_at < datetime.utcnow() and not code.is_used,
'remark': code.remark,
'created_at': code.created_at.strftime('%Y-%m-%d %H:%M')
})
return jsonify({
'success': True,
'data': codes,
'pagination': {
'page': page,
'per_page': per_page,
'total': pagination.total,
'pages': pagination.pages
}
})
@admin_bp.route('/api/redeem-codes/generate', methods=['POST'])
@admin_required
def generate_redeem_codes():
"""批量生成兑换码"""
from models import RedeemCode
import secrets
import string
data = request.get_json()
count = data.get('count', 1)
target_group_id = data.get('target_group_id')
duration_days = data.get('duration_days', 30)
expires_days = data.get('expires_days') # 兑换码有效期(天)
remark = data.get('remark', '')
prefix = data.get('prefix', '') # 兑换码前缀
if not target_group_id:
return jsonify({'success': False, 'message': '请选择目标用户组'}), 400
if count < 1 or count > 1000:
return jsonify({'success': False, 'message': '生成数量必须在1-1000之间'}), 400
# 验证用户组存在
group = UserGroup.query.get(target_group_id)
if not group:
return jsonify({'success': False, 'message': '用户组不存在'}), 400
# 生成批次ID
batch_id = datetime.now().strftime('%Y%m%d%H%M%S') + secrets.token_hex(4)
# 计算过期时间
expires_at = None
if expires_days:
expires_at = datetime.utcnow() + timedelta(days=expires_days)
# 生成兑换码
codes = []
chars = string.ascii_uppercase + string.digits
for _ in range(count):
# 生成随机码
random_part = ''.join(secrets.choice(chars) for _ in range(12))
code_str = f"{prefix}{random_part}" if prefix else random_part
code = RedeemCode(
code=code_str,
batch_id=batch_id,
target_group_id=target_group_id,
duration_days=duration_days,
expires_at=expires_at,
remark=remark
)
db.session.add(code)
codes.append(code_str)
db.session.commit()
return jsonify({
'success': True,
'message': f'成功生成 {count} 个兑换码',
'data': {
'batch_id': batch_id,
'codes': codes
}
})
@admin_bp.route('/api/redeem-codes/<int:code_id>', methods=['DELETE'])
@admin_required
def delete_redeem_code(code_id):
"""删除兑换码"""
from models import RedeemCode
code = RedeemCode.query.get(code_id)
if not code:
return jsonify({'success': False, 'message': '兑换码不存在'}), 404
if code.is_used:
return jsonify({'success': False, 'message': '已使用的兑换码不能删除'}), 400
db.session.delete(code)
db.session.commit()
return jsonify({'success': True, 'message': '删除成功'})
@admin_bp.route('/api/redeem-codes/batch/<batch_id>', methods=['DELETE'])
@admin_required
def delete_batch_codes(batch_id):
"""删除整批兑换码"""
from models import RedeemCode
# 只删除未使用的
deleted = RedeemCode.query.filter_by(batch_id=batch_id, is_used=False).delete()
db.session.commit()
return jsonify({'success': True, 'message': f'成功删除 {deleted} 个未使用的兑换码'})
@admin_bp.route('/api/redeem-codes/batches', methods=['GET'])
@admin_required
def get_batch_list():
"""获取批次列表"""
from models import RedeemCode
batches = db.session.query(
RedeemCode.batch_id,
func.count(RedeemCode.id).label('total'),
func.sum(db.case((RedeemCode.is_used == True, 1), else_=0)).label('used'),
func.min(RedeemCode.created_at).label('created_at')
).group_by(RedeemCode.batch_id).order_by(func.min(RedeemCode.created_at).desc()).all()
result = []
for batch in batches:
result.append({
'batch_id': batch.batch_id,
'total': batch.total,
'used': int(batch.used or 0),
'unused': batch.total - int(batch.used or 0),
'created_at': batch.created_at.strftime('%Y-%m-%d %H:%M') if batch.created_at else ''
})
return jsonify({'success': True, 'data': result})
@admin_bp.route('/api/redeem-codes/export/<batch_id>', methods=['GET'])
@admin_required
def export_batch_codes(batch_id):
"""导出批次兑换码"""
from models import RedeemCode
codes = RedeemCode.query.filter_by(batch_id=batch_id).all()
if not codes:
return jsonify({'success': False, 'message': '批次不存在'}), 404
code_list = [code.code for code in codes]
return jsonify({
'success': True,
'data': {
'batch_id': batch_id,
'codes': code_list,
'total': len(code_list)
}
})

212
routes/api_v1.py Normal file
View File

@@ -0,0 +1,212 @@
from flask import Blueprint, request, jsonify
from models import ParserAPI, ParseLog, UserApiKey, ApiKeyDailyStat, db
from parsers.factory import ParserFactory
from utils.security import get_client_ip
from datetime import datetime, date
import time
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
def validate_and_get_key(api_key):
"""验证 API Key 并返回 key 对象"""
if not api_key:
return None, 'API Key 不能为空'
key_obj = UserApiKey.query.filter_by(api_key=api_key).first()
if not key_obj:
return None, 'API Key 无效'
if not key_obj.is_active:
return None, 'API Key 已被禁用'
if not key_obj.user.is_active:
return None, '用户账号已被禁用'
# 检查每日限额
today_stat = ApiKeyDailyStat.query.filter_by(
api_key_id=key_obj.id,
date=date.today()
).first()
if today_stat and today_stat.call_count >= key_obj.daily_limit:
return None, f'已达到每日调用限额({key_obj.daily_limit}次)'
return key_obj, None
def record_api_call(key_obj, ip_address, success=True):
"""记录 API 调用"""
key_obj.total_calls = (key_obj.total_calls or 0) + 1
key_obj.last_used_at = datetime.utcnow()
key_obj.last_used_ip = ip_address
today_stat = ApiKeyDailyStat.query.filter_by(
api_key_id=key_obj.id,
date=date.today()
).first()
if not today_stat:
today_stat = ApiKeyDailyStat(api_key_id=key_obj.id, date=date.today())
db.session.add(today_stat)
today_stat.call_count = (today_stat.call_count or 0) + 1
if success:
today_stat.success_count = (today_stat.success_count or 0) + 1
else:
today_stat.fail_count = (today_stat.fail_count or 0) + 1
@api_v1_bp.route('/parse', methods=['GET'])
def parse_video():
"""
对外解析API - 简化版
请求方式: GET
参数:
key: API Key
url: 视频链接
示例: /api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxx
返回:
{
"code": 200,
"msg": "解析成功",
"data": {
"cover": "封面URL",
"title": "标题",
"description": "简介",
"author": "作者",
"video_url": "无水印视频链接"
}
}
"""
# 获取参数
api_key = request.args.get('key')
video_url = request.args.get('url')
# 验证 API Key
key_obj, error = validate_and_get_key(api_key)
if error:
return jsonify({'code': 401, 'msg': error})
# 验证 URL
if not video_url:
return jsonify({'code': 400, 'msg': '请提供视频链接'})
# 检测平台
try:
platform = ParserFactory.detect_platform(video_url)
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)})
# 展开短链接
video_url = ParserFactory.expand_short_url(video_url)
# 获取客户端IP
ip_address = get_client_ip(request)
start_time = time.time()
# 获取该平台所有可用的API
available_apis = ParserAPI.query.filter_by(
platform=platform.lower(),
is_enabled=True
).all()
if not available_apis:
return jsonify({'code': 503, 'msg': f'没有可用的{platform}解析接口'})
last_error = None
user_id = key_obj.user_id
# 尝试所有可用的APIfailover机制
for api_config in available_apis:
try:
parser = ParserFactory.create_parser(api_config)
result = parser.parse(video_url)
# 验证解析结果video_url 不能为空
if not result.get('video_url'):
raise Exception('未能获取到视频链接')
response_time = int((time.time() - start_time) * 1000)
# 记录成功日志
log = ParseLog(
user_id=user_id,
ip_address=ip_address,
platform=platform,
video_url=video_url,
parser_api_id=api_config.id,
status='success',
response_time=response_time
)
db.session.add(log)
# 更新API统计
api_config.total_calls = (api_config.total_calls or 0) + 1
api_config.success_calls = (api_config.success_calls or 0) + 1
avg_time = api_config.avg_response_time or 0
api_config.avg_response_time = int(
(avg_time * (api_config.total_calls - 1) + response_time) / api_config.total_calls
)
api_config.fail_count = 0
# 记录 API Key 调用
record_api_call(key_obj, ip_address, success=True)
db.session.commit()
return jsonify({
'code': 200,
'msg': '解析成功',
'data': {
'cover': result.get('cover', ''),
'title': result.get('title', ''),
'description': result.get('description', ''),
'author': result.get('author', ''),
'video_url': result.get('video_url', '')
}
})
except Exception as e:
last_error = str(e)
api_config.total_calls = (api_config.total_calls or 0) + 1
api_config.fail_count = (api_config.fail_count or 0) + 1
db.session.commit()
continue
# 所有API都失败
response_time = int((time.time() - start_time) * 1000)
log = ParseLog(
user_id=user_id,
ip_address=ip_address,
platform=platform,
video_url=video_url,
status='failed',
error_message=last_error or '所有接口都失败',
response_time=response_time
)
db.session.add(log)
record_api_call(key_obj, ip_address, success=False)
db.session.commit()
return jsonify({
'code': 500,
'msg': last_error or '解析失败,请稍后重试'
})
@api_v1_bp.route('/platforms', methods=['GET'])
def get_platforms():
"""获取支持的平台列表"""
return jsonify({
'code': 200,
'msg': '获取成功',
'data': [
{'name': 'douyin', 'display_name': '抖音'},
{'name': 'tiktok', 'display_name': 'TikTok'},
{'name': 'bilibili', 'display_name': '哔哩哔哩'},
{'name': 'kuaishou', 'display_name': '快手'},
{'name': 'pipixia', 'display_name': '皮皮虾'},
{'name': 'weibo', 'display_name': '微博'}
]
})

174
routes/apikey.py Normal file
View File

@@ -0,0 +1,174 @@
from flask import Blueprint, request, jsonify, render_template
from flask_login import login_required, current_user
from models import db, UserApiKey, ApiKeyDailyStat
from datetime import datetime, date
import secrets
apikey_bp = Blueprint('apikey', __name__, url_prefix='/user/apikey')
def generate_api_key():
"""生成 API Key"""
return f"sk_{secrets.token_hex(24)}"
@apikey_bp.route('/')
@login_required
def manage_page():
"""API Key 管理页面"""
return render_template('apikey/manage.html')
@apikey_bp.route('/list', methods=['GET'])
@login_required
def list_keys():
"""获取用户的 API Key 列表"""
keys = UserApiKey.query.filter_by(user_id=current_user.id).order_by(UserApiKey.created_at.desc()).all()
result = []
for key in keys:
# 获取今日调用次数
today_stat = ApiKeyDailyStat.query.filter_by(
api_key_id=key.id,
date=date.today()
).first()
result.append({
'id': key.id,
'name': key.name,
'api_key': key.api_key[:12] + '...' + key.api_key[-4:], # 隐藏中间部分
'is_active': key.is_active,
'daily_limit': key.daily_limit,
'total_calls': key.total_calls,
'today_calls': today_stat.call_count if today_stat else 0,
'last_used_at': key.last_used_at.strftime('%Y-%m-%d %H:%M:%S') if key.last_used_at else None,
'created_at': key.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
return jsonify({
'success': True,
'data': result
})
@apikey_bp.route('/create', methods=['POST'])
@login_required
def create_key():
"""创建新的 API Key"""
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({'success': False, 'message': '请输入 Key 名称'}), 400
if len(name) > 100:
return jsonify({'success': False, 'message': 'Key 名称不能超过100个字符'}), 400
# 检查用户已有的 Key 数量限制每个用户最多5个
key_count = UserApiKey.query.filter_by(user_id=current_user.id).count()
if key_count >= 5:
return jsonify({'success': False, 'message': '每个用户最多创建5个 API Key'}), 400
# 生成新的 API Key
api_key = generate_api_key()
# 根据用户等级设置每日限制
daily_limit = 100 # 默认
if current_user.group:
if current_user.group.id == 3: # VIP
daily_limit = 500
elif current_user.group.id == 4: # SVIP
daily_limit = 2000
new_key = UserApiKey(
user_id=current_user.id,
name=name,
api_key=api_key,
daily_limit=daily_limit
)
db.session.add(new_key)
db.session.commit()
return jsonify({
'success': True,
'message': 'API Key 创建成功',
'data': {
'id': new_key.id,
'name': new_key.name,
'api_key': api_key, # 只在创建时返回完整的 Key
'daily_limit': new_key.daily_limit
}
})
@apikey_bp.route('/delete/<int:key_id>', methods=['DELETE'])
@login_required
def delete_key(key_id):
"""删除 API Key"""
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
if not key:
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
db.session.delete(key)
db.session.commit()
return jsonify({
'success': True,
'message': 'API Key 已删除'
})
@apikey_bp.route('/toggle/<int:key_id>', methods=['POST'])
@login_required
def toggle_key(key_id):
"""启用/禁用 API Key"""
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
if not key:
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
key.is_active = not key.is_active
db.session.commit()
return jsonify({
'success': True,
'message': f"API Key 已{'启用' if key.is_active else '禁用'}",
'is_active': key.is_active
})
@apikey_bp.route('/stats/<int:key_id>', methods=['GET'])
@login_required
def get_stats(key_id):
"""获取 API Key 统计数据"""
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
if not key:
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
# 获取最近7天的统计
from datetime import timedelta
stats = ApiKeyDailyStat.query.filter(
ApiKeyDailyStat.api_key_id == key_id,
ApiKeyDailyStat.date >= date.today() - timedelta(days=6)
).order_by(ApiKeyDailyStat.date.asc()).all()
result = []
for stat in stats:
result.append({
'date': stat.date.strftime('%Y-%m-%d'),
'call_count': stat.call_count,
'success_count': stat.success_count,
'fail_count': stat.fail_count
})
return jsonify({
'success': True,
'data': {
'key_name': key.name,
'total_calls': key.total_calls,
'daily_stats': result
}
})

View File

@@ -219,14 +219,22 @@ def profile():
@login_required
def get_profile():
"""获取用户个人中心数据"""
from models import UserGroup, DailyParseStat, ParseLog
from datetime import date
from models import UserGroup, DailyParseStat, ParseLog, UserGroupExpiry
from datetime import date, datetime
# 获取用户组信息
user_group = UserGroup.query.get(current_user.group_id)
daily_limit = user_group.daily_limit if user_group else 10
group_name = user_group.name if user_group else '普通用户'
# 获取套餐到期时间
group_expiry = UserGroupExpiry.query.filter_by(user_id=current_user.id).first()
expires_at = None
is_expired = False
if group_expiry:
expires_at = group_expiry.expires_at.strftime('%Y-%m-%d %H:%M') if group_expiry.expires_at else None
is_expired = group_expiry.expires_at < datetime.utcnow() if group_expiry.expires_at else False
# 获取今日使用次数
today = date.today()
today_stat = DailyParseStat.query.filter_by(
@@ -263,7 +271,9 @@ def get_profile():
'group': {
'id': current_user.group_id,
'name': group_name,
'daily_limit': daily_limit
'daily_limit': daily_limit,
'expires_at': expires_at,
'is_expired': is_expired
},
'usage': {
'daily_limit': daily_limit,
@@ -274,3 +284,72 @@ def get_profile():
'parse_logs': logs_data
}
})
@auth_bp.route('/api/redeem', methods=['POST'])
@login_required
def redeem_code():
"""用户兑换码"""
from models import RedeemCode, UserGroupExpiry
from datetime import datetime, timedelta
data = request.get_json()
code_str = data.get('code', '').strip().upper()
if not code_str:
return jsonify({'success': False, 'message': '请输入兑换码'}), 400
# 查找兑换码
code = RedeemCode.query.filter_by(code=code_str).first()
if not code:
return jsonify({'success': False, 'message': '兑换码不存在'}), 404
if code.is_used:
return jsonify({'success': False, 'message': '兑换码已被使用'}), 400
if code.expires_at and code.expires_at < datetime.utcnow():
return jsonify({'success': False, 'message': '兑换码已过期'}), 400
# 执行兑换
user = current_user
# 更新用户组
old_group_id = user.group_id
user.group_id = code.target_group_id
# 计算到期时间
expires_at = datetime.utcnow() + timedelta(days=code.duration_days)
# 更新或创建到期记录
expiry = UserGroupExpiry.query.filter_by(user_id=user.id).first()
if expiry:
# 如果当前套餐未过期且是同一套餐,则叠加时间
if expiry.group_id == code.target_group_id and expiry.expires_at > datetime.utcnow():
expires_at = expiry.expires_at + timedelta(days=code.duration_days)
expiry.group_id = code.target_group_id
expiry.expires_at = expires_at
else:
expiry = UserGroupExpiry(
user_id=user.id,
group_id=code.target_group_id,
expires_at=expires_at
)
db.session.add(expiry)
# 标记兑换码已使用
code.is_used = True
code.used_by = user.id
code.used_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': f'兑换成功!您已升级为 {code.target_group.name},有效期至 {expires_at.strftime("%Y-%m-%d %H:%M")}',
'data': {
'group_name': code.target_group.name,
'expires_at': expires_at.strftime('%Y-%m-%d %H:%M'),
'duration_days': code.duration_days
}
})

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, redirect
from flask import Blueprint, render_template, redirect, request, Response
import requests
main_bp = Blueprint('main', __name__)
@@ -11,6 +12,12 @@ def index():
config[c.config_key] = c.config_value
return render_template('index.html', config=config)
@main_bp.route('/api-docs')
def api_docs():
"""API 文档页面"""
return render_template('api_docs.html')
@main_bp.route('/favicon.ico')
def favicon():
"""处理 favicon 请求"""
@@ -24,3 +31,106 @@ def favicon():
return redirect(logo_config.config_value)
# 返回空响应避免 404
return '', 204
@main_bp.route('/proxy/download')
def proxy_download():
"""代理下载视频/图片,绕过防盗链,支持 Range 请求"""
url = request.args.get('url')
if not url:
return 'Missing url parameter', 400
# 限制只能代理特定域名
allowed_domains = [
'weibocdn.com', 'sinaimg.cn', 'weibo.com',
'ppxvod.com', 'byteimg.com', 'pipix.com', '365yg.com', 'ixigua.com',
'kwimgs.com', 'kwaicdn.com', 'kuaishou.com', 'gifshow.com', 'yximgs.com',
'douyinvod.com', 'douyin.com', 'tiktokcdn.com', 'bytedance.com',
'bilivideo.com', 'bilibili.com', 'hdslb.com',
'zjcdn.com', 'douyinpic.com'
]
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc.lower()
is_allowed = any(d in domain for d in allowed_domains)
if not is_allowed:
return f'Domain not allowed: {domain}', 403
# 根据域名设置不同的 Referer
referer_map = {
'weibo': 'https://weibo.com/',
'sina': 'https://weibo.com/',
'pipix': 'https://www.pipix.com/',
'365yg': 'https://www.pipix.com/',
'ixigua': 'https://www.ixigua.com/',
'kuaishou': 'https://www.kuaishou.com/',
'kwimgs': 'https://www.kuaishou.com/',
'kwaicdn': 'https://www.kuaishou.com/',
'yximgs': 'https://www.kuaishou.com/',
'douyin': 'https://www.douyin.com/',
'bytedance': 'https://www.douyin.com/',
'zjcdn': 'https://www.douyin.com/',
'douyinpic': 'https://www.douyin.com/',
'bilibili': 'https://www.bilibili.com/',
'hdslb': 'https://www.bilibili.com/',
}
referer = 'https://www.google.com/'
for key, ref in referer_map.items():
if key in domain:
referer = ref
break
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': referer
}
# 转发 Range 请求头,支持视频进度条拖动
range_header = request.headers.get('Range')
if range_header:
headers['Range'] = range_header
resp = requests.get(url, headers=headers, stream=True, timeout=30)
# 获取文件类型
content_type = resp.headers.get('Content-Type', 'application/octet-stream')
# 设置下载文件名
if 'video' in content_type:
filename = f'video_{int(__import__("time").time())}.mp4'
elif 'image' in content_type:
filename = f'image_{int(__import__("time").time())}.jpg'
else:
filename = f'file_{int(__import__("time").time())}'
def generate():
for chunk in resp.iter_content(chunk_size=8192):
yield chunk
# 构建响应头
response_headers = {
'Content-Type': content_type,
'Accept-Ranges': 'bytes'
}
# 转发相关响应头
if resp.headers.get('Content-Length'):
response_headers['Content-Length'] = resp.headers.get('Content-Length')
if resp.headers.get('Content-Range'):
response_headers['Content-Range'] = resp.headers.get('Content-Range')
# 根据是否是 Range 请求返回不同状态码
status_code = resp.status_code
return Response(
generate(),
status=status_code,
headers=response_headers
)
except Exception as e:
return f'Download failed: {str(e)}', 500

View File

@@ -40,6 +40,9 @@ def parse_video():
except ValueError as e:
return jsonify({'success': False, 'message': str(e)}), 400
# 展开短链接
video_url = ParserFactory.expand_short_url(video_url)
# 生成任务ID
task_id = str(uuid.uuid4())
@@ -116,6 +119,10 @@ def _process_task(task_id, video_url, platform, user_id, ip_address):
# 执行解析
result = parser.parse(video_url)
# 验证解析结果video_url 不能为空
if not result.get('video_url'):
raise Exception('未能获取到视频链接')
# 计算响应时间
response_time = int((time.time() - start_time) * 1000)

View File

@@ -136,9 +136,130 @@ h1, h2, h3, h4, h5, h6 {
box-shadow: 0 0 0 3px var(--primary-100);
}
/* Select / Dropdown */
/* Custom Select / Dropdown */
.ui-select-wrapper {
position: relative;
width: 100%;
}
.ui-select-trigger {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 1rem;
border: 1px solid var(--secondary-200);
border-radius: var(--radius-md);
background-color: white;
color: var(--text-main);
font-size: 0.875rem;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.ui-select-trigger::after {
content: '';
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--secondary-500);
transition: transform 0.2s;
}
.ui-select-wrapper.open .ui-select-trigger::after {
transform: translateY(-50%) rotate(180deg);
}
.ui-select-trigger:hover {
border-color: var(--secondary-300);
}
.ui-select-wrapper.open .ui-select-trigger,
.ui-select-trigger:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px var(--primary-100);
}
.ui-select-trigger .placeholder {
color: var(--text-muted);
}
.ui-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: white;
border: 1px solid var(--secondary-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
max-height: 240px;
overflow-y: auto;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.ui-select-wrapper.open .ui-select-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.ui-select-option {
padding: 0.625rem 1rem;
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.ui-select-option:hover {
background: var(--secondary-50);
}
.ui-select-option.selected {
background: var(--primary-50);
color: var(--primary-700);
font-weight: 500;
}
.ui-select-option.selected::before {
content: '✓';
font-size: 0.75rem;
color: var(--primary-600);
}
.ui-select-option:first-child {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.ui-select-option:last-child {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
/* 隐藏原生 select */
.ui-select-wrapper select.ui-select-native {
position: absolute;
opacity: 0;
pointer-events: none;
width: 100%;
height: 100%;
}
/* 兼容旧的 select.ui-input 样式 */
select.ui-input,
.ui-select {
select.ui-select {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 1rem;
border: 1px solid var(--secondary-200);
@@ -158,14 +279,14 @@ select.ui-input,
}
select.ui-input:focus,
.ui-select:focus {
select.ui-select:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px var(--primary-100);
}
select.ui-input:hover,
.ui-select:hover {
select.ui-select:hover {
border-color: var(--secondary-300);
}

View File

@@ -1,5 +1,107 @@
// 现代化UI组件库
const UI = {
// 初始化所有自定义下拉框
initSelects() {
document.querySelectorAll('select.ui-input, select.ui-select').forEach(select => {
if (select.dataset.uiInit) return; // 已初始化
this.createCustomSelect(select);
});
},
// 创建自定义下拉框
createCustomSelect(select) {
select.dataset.uiInit = 'true';
// 创建包装器
const wrapper = document.createElement('div');
wrapper.className = 'ui-select-wrapper';
select.parentNode.insertBefore(wrapper, select);
// 隐藏原生 select
select.classList.add('ui-select-native');
wrapper.appendChild(select);
// 创建触发器
const trigger = document.createElement('div');
trigger.className = 'ui-select-trigger';
trigger.tabIndex = 0;
wrapper.appendChild(trigger);
// 创建下拉菜单
const dropdown = document.createElement('div');
dropdown.className = 'ui-select-dropdown';
wrapper.appendChild(dropdown);
// 更新选项
const updateOptions = () => {
dropdown.innerHTML = '';
const selectedValue = select.value;
let selectedText = '';
Array.from(select.options).forEach(option => {
const optionEl = document.createElement('div');
optionEl.className = 'ui-select-option';
optionEl.dataset.value = option.value;
optionEl.textContent = option.textContent;
if (option.value === selectedValue) {
optionEl.classList.add('selected');
selectedText = option.textContent;
}
optionEl.addEventListener('click', () => {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
wrapper.classList.remove('open');
updateTrigger();
});
dropdown.appendChild(optionEl);
});
return selectedText;
};
// 更新触发器显示
const updateTrigger = () => {
const selectedText = updateOptions();
if (selectedText) {
trigger.innerHTML = `<span>${selectedText}</span>`;
} else {
trigger.innerHTML = `<span class="placeholder">${select.options[0]?.textContent || '请选择'}</span>`;
}
};
// 初始化
updateTrigger();
// 点击触发器
trigger.addEventListener('click', (e) => {
e.stopPropagation();
// 关闭其他下拉框
document.querySelectorAll('.ui-select-wrapper.open').forEach(w => {
if (w !== wrapper) w.classList.remove('open');
});
wrapper.classList.toggle('open');
});
// 键盘支持
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
wrapper.classList.toggle('open');
} else if (e.key === 'Escape') {
wrapper.classList.remove('open');
}
});
// 监听原生 select 变化
select.addEventListener('change', updateTrigger);
// 监听选项变化(用于动态更新)
const observer = new MutationObserver(updateTrigger);
observer.observe(select, { childList: true, subtree: true });
},
// 显示通知消息
notify(message, type = 'info', duration = 3000) {
const container = document.getElementById('notification-container') || this.createNotificationContainer();
@@ -118,3 +220,20 @@ const UI = {
return icons[type] || icons.info;
}
};
// 点击外部关闭下拉框
document.addEventListener('click', (e) => {
if (!e.target.closest('.ui-select-wrapper')) {
document.querySelectorAll('.ui-select-wrapper.open').forEach(w => {
w.classList.remove('open');
});
}
});
// 页面加载完成后自动初始化下拉框
document.addEventListener('DOMContentLoaded', () => {
UI.initSelects();
});
// 提供手动刷新方法(用于动态加载的内容)
UI.refreshSelects = UI.initSelects;

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item active">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>
@@ -63,6 +64,9 @@
<option value="douyin">抖音</option>
<option value="tiktok">TikTok</option>
<option value="bilibili">哔哩哔哩</option>
<option value="kuaishou">快手</option>
<option value="pipixia">皮皮虾</option>
<option value="weibo">微博</option>
</select>
</div>
<div class="form-group">

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item active">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item active">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item active">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>
@@ -75,6 +76,9 @@
<option value="douyin">抖音 (douyin)</option>
<option value="tiktok">TikTok (tiktok)</option>
<option value="bilibili">哔哩哔哩 (bilibili)</option>
<option value="kuaishou">快手 (kuaishou)</option>
<option value="pipixia">皮皮虾 (pipixia)</option>
<option value="weibo">微博 (weibo)</option>
</select>
</div>
<div class="form-group">

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item active">日志审计</a>
</nav>
@@ -39,6 +40,9 @@
<option value="douyin">抖音</option>
<option value="tiktok">TikTok</option>
<option value="bilibili">哔哩哔哩</option>
<option value="kuaishou">快手</option>
<option value="pipixia">皮皮虾</option>
<option value="weibo">微博</option>
</select>
<select id="statusFilter" class="ui-input" style="width: auto; margin-bottom: 0;"
onchange="loadLogs(1)">

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>

View File

@@ -0,0 +1,521 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>兑换码管理 - 管理后台</title>
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-layout">
<header class="admin-header">
<div class="header-container">
<a href="/admin/dashboard" class="brand">
<span style="font-size: 1.5rem;"></span> JieXi Admin
</a>
<nav class="nav-links">
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item active">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>
<div class="user-actions">
<a href="/admin/profile" class="ui-btn ui-btn-secondary ui-btn-sm">账号设置</a>
<button class="ui-btn ui-btn-secondary ui-btn-sm" onclick="logout()">退出登录</button>
</div>
</div>
</header>
<main class="main-container">
<div class="page-header">
<h1 class="page-title">兑换码管理</h1>
<button class="ui-btn ui-btn-primary" onclick="showGenerateModal()">批量生成</button>
</div>
<!-- 统计卡片 -->
<div class="stats-grid" style="margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-label">总数量</div>
<div class="stat-value" id="totalCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">未使用</div>
<div class="stat-value" id="unusedCount" style="color: var(--success);">-</div>
</div>
<div class="stat-card">
<div class="stat-label">已使用</div>
<div class="stat-value" id="usedCount">-</div>
</div>
</div>
<!-- 筛选 -->
<div class="filter-bar" style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<select id="filterStatus" class="ui-select" onchange="loadCodes()">
<option value="">全部状态</option>
<option value="unused">未使用</option>
<option value="used">已使用</option>
<option value="expired">已过期</option>
</select>
<select id="filterBatch" class="ui-select" onchange="loadCodes()">
<option value="">全部批次</option>
</select>
</div>
<!-- 兑换码列表 -->
<div class="ui-card">
<div class="table-container">
<table>
<thead>
<tr>
<th>兑换码</th>
<th>目标套餐</th>
<th>有效期</th>
<th>状态</th>
<th>使用者</th>
<th>使用时间</th>
<th>过期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="codesBody">
<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">加载中...</td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination" style="margin-top: 1rem;"></div>
</div>
</main>
<!-- 生成兑换码弹窗 -->
<div class="modal" id="generateModal">
<div class="modal-content">
<div class="modal-header">
<h3>批量生成兑换码</h3>
<button class="modal-close" onclick="closeModal('generateModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>目标套餐 <span class="required">*</span></label>
<select id="targetGroup" class="ui-input">
<option value="">请选择</option>
</select>
</div>
<div class="form-group">
<label>套餐有效期(天)</label>
<input type="number" id="durationDays" class="ui-input" value="30" min="1">
<small>用户兑换后,套餐的有效天数</small>
</div>
<div class="form-group">
<label>生成数量</label>
<input type="number" id="generateCount" class="ui-input" value="10" min="1" max="1000">
</div>
<div class="form-group">
<label>兑换码前缀(可选)</label>
<input type="text" id="codePrefix" class="ui-input" placeholder="如: VIP">
</div>
<div class="form-group">
<label>兑换码有效期(天,可选)</label>
<input type="number" id="expiresDays" class="ui-input" placeholder="留空表示永不过期">
<small>兑换码本身的过期时间,过期后无法使用</small>
</div>
<div class="form-group">
<label>备注(可选)</label>
<input type="text" id="remark" class="ui-input" placeholder="如: 活动赠送">
</div>
</div>
<div class="modal-footer">
<button class="ui-btn ui-btn-secondary" onclick="closeModal('generateModal')">取消</button>
<button class="ui-btn ui-btn-primary" onclick="generateCodes()">生成</button>
</div>
</div>
</div>
<!-- 生成结果弹窗 -->
<div class="modal" id="resultModal">
<div class="modal-content">
<div class="modal-header">
<h3>生成成功</h3>
<button class="modal-close" onclick="closeModal('resultModal')">&times;</button>
</div>
<div class="modal-body">
<p>批次ID: <strong id="resultBatchId"></strong></p>
<p>生成数量: <strong id="resultCount"></strong></p>
<div class="form-group">
<label>兑换码列表</label>
<textarea id="resultCodes" class="ui-input" rows="10" readonly style="font-family: monospace;"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="ui-btn ui-btn-secondary" onclick="closeModal('resultModal')">关闭</button>
<button class="ui-btn ui-btn-primary" onclick="copyResultCodes()">复制全部</button>
</div>
</div>
</div>
<style>
.ui-select {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: white;
min-width: 150px;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.unused {
background: #dcfce7;
color: #166534;
}
.status-badge.used {
background: #e0e7ff;
color: #3730a3;
}
.status-badge.expired {
background: #fee2e2;
color: #991b1b;
}
.code-text {
font-family: monospace;
font-size: 0.875rem;
background: var(--secondary-100);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--secondary-700);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.75rem;
}
.required {
color: #dc2626;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.page-btn {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
}
.page-btn.active {
background: var(--primary-600);
color: white;
border-color: var(--primary-600);
}
</style>
<script src="/static/js/ui-components.js"></script>
<script>
let currentPage = 1;
let userGroups = [];
async function logout() {
try {
await fetch('/admin/logout', { method: 'POST' });
window.location.href = '/admin/login';
} catch (e) {
UI.notify('退出失败', 'error');
}
}
async function loadUserGroups() {
try {
const response = await fetch('/admin/api/user-groups');
const result = await response.json();
if (result.success) {
userGroups = result.data;
const select = document.getElementById('targetGroup');
select.innerHTML = '<option value="">请选择</option>' +
userGroups.map(g => `<option value="${g.id}">${g.name}</option>`).join('');
}
} catch (e) {
console.error('加载用户组失败', e);
}
}
async function loadBatches() {
try {
const response = await fetch('/admin/api/redeem-codes/batches');
const result = await response.json();
if (result.success) {
const select = document.getElementById('filterBatch');
select.innerHTML = '<option value="">全部批次</option>' +
result.data.map(b => `<option value="${b.batch_id}">${b.batch_id.slice(-12)} (${b.unused}/${b.total})</option>`).join('');
let total = 0, used = 0, unused = 0;
result.data.forEach(b => {
total += b.total;
used += b.used;
unused += b.unused;
});
document.getElementById('totalCount').textContent = total;
document.getElementById('usedCount').textContent = used;
document.getElementById('unusedCount').textContent = unused;
}
} catch (e) {
console.error('加载批次失败', e);
}
}
async function loadCodes() {
const status = document.getElementById('filterStatus').value;
const batchId = document.getElementById('filterBatch').value;
let url = `/admin/api/redeem-codes?page=${currentPage}`;
if (status) url += `&status=${status}`;
if (batchId) url += `&batch_id=${batchId}`;
try {
const response = await fetch(url);
const result = await response.json();
if (result.success) {
renderCodes(result.data);
renderPagination(result.pagination);
}
} catch (e) {
console.error('加载兑换码失败', e);
}
}
function renderCodes(codes) {
const tbody = document.getElementById('codesBody');
if (codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">暂无数据</td></tr>';
return;
}
tbody.innerHTML = codes.map(code => {
let statusClass = 'unused';
let statusText = '未使用';
if (code.is_used) {
statusClass = 'used';
statusText = '已使用';
} else if (code.is_expired) {
statusClass = 'expired';
statusText = '已过期';
}
return `
<tr>
<td><span class="code-text">${code.code}</span></td>
<td>${code.target_group}</td>
<td>${code.duration_days}天</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td>${code.used_by || '-'}</td>
<td>${code.used_at || '-'}</td>
<td>${code.expires_at || '永久'}</td>
<td>
${!code.is_used ? `<button class="ui-btn ui-btn-sm ui-btn-danger" onclick="deleteCode(${code.id})">删除</button>` : '-'}
</td>
</tr>
`;
}).join('');
}
function renderPagination(pagination) {
const container = document.getElementById('pagination');
if (pagination.pages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= pagination.pages; i++) {
html += `<button class="page-btn ${i === pagination.page ? 'active' : ''}" onclick="goToPage(${i})">${i}</button>`;
}
container.innerHTML = html;
}
function goToPage(page) {
currentPage = page;
loadCodes();
}
function showGenerateModal() {
document.getElementById('generateModal').classList.add('show');
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
async function generateCodes() {
const targetGroupId = document.getElementById('targetGroup').value;
const durationDays = document.getElementById('durationDays').value;
const count = document.getElementById('generateCount').value;
const prefix = document.getElementById('codePrefix').value;
const expiresDays = document.getElementById('expiresDays').value;
const remark = document.getElementById('remark').value;
if (!targetGroupId) {
UI.notify('请选择目标套餐', 'warning');
return;
}
try {
const response = await fetch('/admin/api/redeem-codes/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_group_id: parseInt(targetGroupId),
duration_days: parseInt(durationDays),
count: parseInt(count),
prefix: prefix,
expires_days: expiresDays ? parseInt(expiresDays) : null,
remark: remark
})
});
const result = await response.json();
if (result.success) {
closeModal('generateModal');
document.getElementById('resultBatchId').textContent = result.data.batch_id;
document.getElementById('resultCount').textContent = result.data.codes.length;
document.getElementById('resultCodes').value = result.data.codes.join('\n');
document.getElementById('resultModal').classList.add('show');
loadBatches();
loadCodes();
} else {
UI.notify(result.message, 'error');
}
} catch (e) {
UI.notify('生成失败', 'error');
}
}
function copyResultCodes() {
const textarea = document.getElementById('resultCodes');
textarea.select();
document.execCommand('copy');
UI.notify('已复制到剪贴板', 'success');
}
async function deleteCode(id) {
if (!confirm('确定要删除这个兑换码吗?')) return;
try {
const response = await fetch(`/admin/api/redeem-codes/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
UI.notify('删除成功', 'success');
loadBatches();
loadCodes();
} else {
UI.notify(result.message, 'error');
}
} catch (e) {
UI.notify('删除失败', 'error');
}
}
// 初始化
loadUserGroups();
loadBatches();
loadCodes();
</script>
</body>
</html>

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item active">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>

View File

@@ -19,6 +19,7 @@
<a href="/admin/dashboard" class="nav-item">仪表板</a>
<a href="/admin/users" class="nav-item active">用户管理</a>
<a href="/admin/apis" class="nav-item">接口管理</a>
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
<a href="/admin/config" class="nav-item">系统配置</a>
<a href="/admin/logs" class="nav-item">日志审计</a>
</nav>
@@ -105,7 +106,12 @@
<input type="hidden" id="editUserId">
<div class="form-group">
<label>用户分组</label>
<select id="editGroupId" class="ui-input"></select>
<select id="editGroupId" class="ui-input" onchange="onGroupChange()"></select>
</div>
<div class="form-group" id="expiryGroup" style="display: none;">
<label>套餐到期时间</label>
<input type="datetime-local" id="editExpiresAt" class="ui-input">
<small style="color: var(--text-muted); font-size: 0.75rem;">游客和普通用户无需设置到期时间</small>
</div>
<div class="form-group">
<label>账号状态</label>
@@ -234,7 +240,10 @@
<td>#${u.id}</td>
<td><span class="font-medium">${u.username}</span></td>
<td class="text-muted">${u.email}</td>
<td><span class="badge badge-info">${u.group_name}</span></td>
<td>
<span class="badge badge-info">${u.group_name}</span>
${u.group_id > 2 && u.expires_at ? `<br><small class="text-muted">${u.is_expired ? '已过期' : '至 ' + u.expires_at}</small>` : ''}
</td>
<td>${u.total_parse_count}</td>
<td>
<span class="badge ${u.is_active ? 'badge-success' : 'badge-error'}">
@@ -244,7 +253,7 @@
<td class="text-muted text-sm">${new Date(u.created_at).toLocaleString('zh-CN')}</td>
<td>
<button class="ui-btn ui-btn-secondary ui-btn-sm"
onclick="editUser(${u.id}, ${u.group_id}, ${u.is_active})">
onclick="editUser(${u.id}, ${u.group_id}, ${u.is_active}, ${u.expires_at ? `'${u.expires_at}'` : null})">
编辑
</button>
</td>
@@ -268,13 +277,35 @@
}
}
function editUser(id, groupId, isActive) {
function editUser(id, groupId, isActive, expiresAt) {
document.getElementById('editUserId').value = id;
document.getElementById('editGroupId').value = groupId;
document.getElementById('editIsActive').value = isActive;
// 设置到期时间
if (expiresAt) {
document.getElementById('editExpiresAt').value = expiresAt.slice(0, 16);
} else {
document.getElementById('editExpiresAt').value = '';
}
// 根据用户组显示/隐藏到期时间
onGroupChange();
document.getElementById('editModal').classList.add('show');
}
function onGroupChange() {
const groupId = parseInt(document.getElementById('editGroupId').value);
const expiryGroup = document.getElementById('expiryGroup');
// 游客(1)和普通用户(2)不显示到期时间
if (groupId > 2) {
expiryGroup.style.display = 'block';
} else {
expiryGroup.style.display = 'none';
document.getElementById('editExpiresAt').value = '';
}
}
function closeModal() {
document.getElementById('editModal').classList.remove('show');
}
@@ -337,11 +368,19 @@
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('editUserId').value;
const groupId = parseInt(document.getElementById('editGroupId').value);
const expiresAt = document.getElementById('editExpiresAt').value;
const data = {
group_id: parseInt(document.getElementById('editGroupId').value),
group_id: groupId,
is_active: document.getElementById('editIsActive').value === 'true'
};
// VIP/SVIP 需要设置到期时间
if (groupId > 2) {
data.expires_at = expiresAt || null;
}
try {
const response = await fetch(`/admin/api/users/${userId}`, {
method: 'PUT',
@@ -354,6 +393,7 @@
UI.notify('更新成功', 'success');
closeModal();
loadUsers(currentPage);
loadUserStats();
} else {
UI.notify(result.message || '更新失败', 'error');
}

557
templates/api_docs.html Normal file
View File

@@ -0,0 +1,557 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 文档 - 短视频解析平台</title>
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
<style>
body {
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
min-height: 100vh;
padding: 2rem 1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--secondary-900);
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.doc-card {
background: white;
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
margin-bottom: 1.5rem;
}
.doc-card h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--secondary-900);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.doc-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--secondary-800);
margin: 1.25rem 0 0.75rem;
}
.doc-card p {
color: var(--secondary-600);
line-height: 1.7;
margin-bottom: 1rem;
}
.endpoint {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.method {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.method.post {
background: #dbeafe;
color: #1d4ed8;
}
.method.get {
background: #dcfce7;
color: #166534;
}
.url {
font-family: monospace;
font-size: 0.95rem;
color: var(--secondary-800);
background: var(--secondary-50);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
flex: 1;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
line-height: 1.6;
overflow-x: auto;
margin: 1rem 0;
position: relative;
}
.code-block .copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(255,255,255,0.1);
border: none;
color: #94a3b8;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
}
.code-block .copy-btn:hover {
background: rgba(255,255,255,0.2);
color: white;
}
.code-block .comment {
color: #64748b;
}
.code-block .string {
color: #a5d6ff;
}
.code-block .key {
color: #7dd3fc;
}
.code-block .value {
color: #86efac;
}
.param-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.param-table th,
.param-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
}
.param-table th {
background: var(--secondary-50);
font-weight: 600;
color: var(--secondary-700);
}
.param-table code {
background: var(--secondary-100);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
}
.required {
color: #dc2626;
font-size: 0.75rem;
}
.platform-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.platform-item {
background: var(--secondary-50);
padding: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.platform-name {
font-weight: 600;
color: var(--secondary-900);
margin-bottom: 0.5rem;
}
.platform-patterns {
font-size: 0.8rem;
color: var(--text-muted);
font-family: monospace;
}
.tip-box {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius-md);
padding: 1rem;
font-size: 0.875rem;
color: #1e40af;
margin: 1rem 0;
}
.warning-box {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: var(--radius-md);
padding: 1rem;
font-size: 0.875rem;
color: #92400e;
margin: 1rem 0;
}
.error-codes {
margin: 1rem 0;
}
.error-code {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--secondary-100);
}
.error-code:last-child {
border-bottom: none;
}
.error-code code {
background: #fee2e2;
color: #991b1b;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
white-space: nowrap;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.endpoint {
flex-direction: column;
align-items: flex-start;
}
.url {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1 class="page-title">API 文档</h1>
<div class="header-actions">
<a href="/user/apikey" class="ui-btn ui-btn-primary">管理 API Key</a>
<a href="/" class="ui-btn ui-btn-secondary">返回首页</a>
</div>
</div>
<!-- 快速开始 -->
<div class="doc-card">
<h2>快速开始</h2>
<p>短视频解析 API 支持抖音、TikTok、哔哩哔哩等平台的视频解析返回无水印视频链接、封面、标题、作者等信息。</p>
<h3>1. 获取 API Key</h3>
<p>登录后访问 <a href="/user/apikey">API Key 管理</a> 页面创建您的 API Key。</p>
<h3>2. 发起请求</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>https://your-domain.com/api/v1/parse?key=sk_xxx&url=视频链接</pre>
</div>
<h3>3. 获取结果</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>{
<span class="key">"code"</span>: <span class="value">200</span>,
<span class="key">"msg"</span>: <span class="string">"解析成功"</span>,
<span class="key">"data"</span>: {
<span class="key">"cover"</span>: <span class="string">"封面URL"</span>,
<span class="key">"title"</span>: <span class="string">"视频标题"</span>,
<span class="key">"description"</span>: <span class="string">"视频简介"</span>,
<span class="key">"author"</span>: <span class="string">"作者名"</span>,
<span class="key">"video_url"</span>: <span class="string">"无水印视频链接"</span>
}
}</pre>
</div>
</div>
<!-- 认证方式 -->
<div class="doc-card">
<h2>认证方式</h2>
<p>通过 URL 参数 <code>key</code> 传递 API Key</p>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>/api/v1/parse?key=您的API_Key&url=视频链接</pre>
</div>
<div class="warning-box">
请妥善保管您的 API Key不要在客户端代码中暴露。建议通过后端服务调用 API。
</div>
</div>
<!-- 解析接口 -->
<div class="doc-card">
<h2>解析接口</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="url">/api/v1/parse</span>
</div>
<h3>请求参数</h3>
<table class="param-table">
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>key</code></td>
<td>string</td>
<td><span class="required"></span></td>
<td>您的 API Key</td>
</tr>
<tr>
<td><code>url</code></td>
<td>string</td>
<td><span class="required"></span></td>
<td>视频链接(支持短链接和完整链接)</td>
</tr>
</tbody>
</table>
<h3>成功响应</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>{
<span class="key">"code"</span>: <span class="value">200</span>,
<span class="key">"msg"</span>: <span class="string">"解析成功"</span>,
<span class="key">"data"</span>: {
<span class="key">"cover"</span>: <span class="string">"封面图片URL"</span>,
<span class="key">"title"</span>: <span class="string">"视频标题"</span>,
<span class="key">"description"</span>: <span class="string">"视频简介"</span>,
<span class="key">"author"</span>: <span class="string">"作者名称"</span>,
<span class="key">"video_url"</span>: <span class="string">"无水印视频URL"</span>
}
}</pre>
</div>
<h3>错误响应</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>{
<span class="key">"code"</span>: <span class="value">401</span>,
<span class="key">"msg"</span>: <span class="string">"错误描述"</span>
}</pre>
</div>
</div>
<!-- 支持平台 -->
<div class="doc-card">
<h2>支持平台</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="url">/api/v1/platforms</span>
</div>
<p>此接口无需认证,可获取当前支持的平台列表。</p>
<div class="platform-list">
<div class="platform-item">
<div class="platform-name">抖音</div>
<div class="platform-patterns">douyin.com, v.douyin.com</div>
</div>
<div class="platform-item">
<div class="platform-name">TikTok</div>
<div class="platform-patterns">tiktok.com</div>
</div>
<div class="platform-item">
<div class="platform-name">哔哩哔哩</div>
<div class="platform-patterns">bilibili.com, b23.tv</div>
</div>
<div class="platform-item">
<div class="platform-name">快手</div>
<div class="platform-patterns">kuaishou.com</div>
</div>
<div class="platform-item">
<div class="platform-name">皮皮虾</div>
<div class="platform-patterns">pipix.com, h5.pipix.com</div>
</div>
<div class="platform-item">
<div class="platform-name">微博</div>
<div class="platform-patterns">weibo.com, weibo.cn</div>
</div>
</div>
</div>
<!-- 错误码 -->
<div class="doc-card">
<h2>错误码说明</h2>
<div class="error-codes">
<div class="error-code">
<code>UNAUTHORIZED</code>
<span>API Key 无效、已禁用或超出限额</span>
</div>
<div class="error-code">
<code>INVALID_REQUEST</code>
<span>请求格式错误,需要 JSON 格式</span>
</div>
<div class="error-code">
<code>MISSING_URL</code>
<span>缺少 url 参数</span>
</div>
<div class="error-code">
<code>UNSUPPORTED_PLATFORM</code>
<span>不支持的视频平台</span>
</div>
<div class="error-code">
<code>NO_AVAILABLE_API</code>
<span>该平台暂无可用解析接口</span>
</div>
<div class="error-code">
<code>PARSE_FAILED</code>
<span>解析失败,请检查链接或稍后重试</span>
</div>
</div>
</div>
<!-- 代码示例 -->
<div class="doc-card">
<h2>代码示例</h2>
<h3>Python</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>import requests
api_key = "sk_your_api_key_here"
video_url = "https://v.douyin.com/xxxxx"
response = requests.get(
"https://your-domain.com/api/v1/parse",
params={"key": api_key, "url": video_url}
)
result = response.json()
if result["code"] == 200:
print("视频标题:", result["data"]["title"])
print("作者:", result["data"]["author"])
print("视频链接:", result["data"]["video_url"])
else:
print("错误:", result["msg"])</pre>
</div>
<h3>JavaScript</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>const apiKey = 'sk_your_api_key_here';
const videoUrl = 'https://v.douyin.com/xxxxx';
fetch(`https://your-domain.com/api/v1/parse?key=${apiKey}&url=${encodeURIComponent(videoUrl)}`)
.then(res => res.json())
.then(result => {
if (result.code === 200) {
console.log('视频标题:', result.data.title);
console.log('作者:', result.data.author);
console.log('视频链接:', result.data.video_url);
} else {
console.error('错误:', result.msg);
}
});</pre>
</div>
<h3>cURL</h3>
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">复制</button>
<pre>curl "https://your-domain.com/api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxxxx"</pre>
</div>
</div>
<!-- 限制说明 -->
<div class="doc-card">
<h2>使用限制</h2>
<table class="param-table">
<thead>
<tr>
<th>用户等级</th>
<th>每日限额</th>
</tr>
</thead>
<tbody>
<tr>
<td>普通用户</td>
<td>100 次/天/Key</td>
</tr>
<tr>
<td>VIP 用户</td>
<td>500 次/天/Key</td>
</tr>
<tr>
<td>SVIP 用户</td>
<td>2000 次/天/Key</td>
</tr>
</tbody>
</table>
<div class="tip-box">
每个用户最多可创建 5 个 API Key每日限额按单个 Key 计算。
</div>
</div>
</div>
<script src="/static/js/ui-components.js"></script>
<script>
function copyCode(btn) {
const codeBlock = btn.parentElement;
const code = codeBlock.querySelector('pre').textContent;
navigator.clipboard.writeText(code).then(() => {
const originalText = btn.textContent;
btn.textContent = '已复制';
setTimeout(() => {
btn.textContent = originalText;
}, 2000);
}).catch(() => {
UI.notify('复制失败', 'error');
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Key 管理 - 短视频解析平台</title>
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
<style>
body {
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
min-height: 100vh;
padding: 2rem 1rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--secondary-900);
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.info-card {
background: white;
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
margin-bottom: 2rem;
}
.info-card h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--secondary-900);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.key-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.key-item {
background: var(--secondary-50);
border-radius: var(--radius-md);
padding: 1.25rem;
border: 1px solid var(--border-color);
}
.key-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.key-name {
font-weight: 600;
color: var(--secondary-900);
font-size: 1rem;
}
.key-status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
}
.key-status.active {
background: #dcfce7;
color: #166534;
}
.key-status.inactive {
background: #fee2e2;
color: #991b1b;
}
.key-value {
font-family: monospace;
background: white;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--secondary-700);
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border-color);
}
.key-meta {
display: flex;
gap: 1.5rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.key-actions {
display: flex;
gap: 0.5rem;
}
.key-actions button {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
.create-form {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.create-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.9rem;
}
.create-form input:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px var(--primary-100);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal-overlay.show {
opacity: 1;
visibility: visible;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-xl);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--secondary-900);
}
.modal-key {
background: var(--secondary-50);
padding: 1rem;
border-radius: var(--radius-md);
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
}
.modal-warning {
background: #fef3c7;
color: #92400e;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.tip-box {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
color: #1e40af;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.create-form {
flex-direction: column;
}
.key-meta {
flex-direction: column;
gap: 0.5rem;
}
.key-actions {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1 class="page-title">API Key 管理</h1>
<div class="header-actions">
<a href="/api-docs" class="ui-btn ui-btn-secondary">API 文档</a>
<a href="/auth/profile" class="ui-btn ui-btn-secondary">个人中心</a>
<a href="/" class="ui-btn ui-btn-secondary">返回首页</a>
</div>
</div>
<div class="info-card">
<h3>创建新的 API Key</h3>
<div class="tip-box">
API Key 用于调用解析接口,请妥善保管。每个用户最多创建 5 个 Key。
</div>
<div class="create-form">
<input type="text" id="keyName" placeholder="输入 Key 名称(如:我的应用)" maxlength="100">
<button class="ui-btn ui-btn-primary" onclick="createKey()">创建 Key</button>
</div>
</div>
<div class="info-card">
<h3>我的 API Keys</h3>
<div class="key-list" id="keyList">
<div class="empty-state">加载中...</div>
</div>
</div>
</div>
<!-- 新建成功弹窗 -->
<div class="modal-overlay" id="keyModal">
<div class="modal-content">
<h3 class="modal-title">API Key 创建成功</h3>
<div class="modal-key" id="newKeyValue"></div>
<div class="modal-warning">
请立即复制并保存此 Key关闭后将无法再次查看完整内容
</div>
<div class="modal-actions">
<button class="ui-btn ui-btn-secondary" onclick="copyNewKey()">复制 Key</button>
<button class="ui-btn ui-btn-primary" onclick="closeModal()">我已保存</button>
</div>
</div>
</div>
<script src="/static/js/ui-components.js"></script>
<script>
let newApiKey = '';
async function loadKeys() {
try {
const response = await fetch('/user/apikey/list');
const result = await response.json();
if (!result.success) {
if (response.status === 401) {
window.location.href = '/auth/login';
return;
}
throw new Error(result.message || '加载失败');
}
renderKeys(result.data);
} catch (error) {
UI.notify('加载失败: ' + error.message, 'error');
}
}
function renderKeys(keys) {
const container = document.getElementById('keyList');
if (keys.length === 0) {
container.innerHTML = '<div class="empty-state">暂无 API Key请创建一个</div>';
return;
}
container.innerHTML = keys.map(key => `
<div class="key-item">
<div class="key-header">
<span class="key-name">${escapeHtml(key.name)}</span>
<span class="key-status ${key.is_active ? 'active' : 'inactive'}">
${key.is_active ? '启用' : '禁用'}
</span>
</div>
<div class="key-value">
<span>${key.api_key}</span>
</div>
<div class="key-meta">
<span>每日限额: ${key.daily_limit} 次</span>
<span>今日已用: ${key.today_calls} 次</span>
<span>总调用: ${key.total_calls} 次</span>
<span>创建时间: ${key.created_at}</span>
</div>
<div class="key-actions">
<button class="ui-btn ui-btn-secondary" onclick="toggleKey(${key.id}, ${key.is_active})">
${key.is_active ? '禁用' : '启用'}
</button>
<button class="ui-btn ui-btn-danger" onclick="deleteKey(${key.id}, '${escapeHtml(key.name)}')">
删除
</button>
</div>
</div>
`).join('');
}
async function createKey() {
const nameInput = document.getElementById('keyName');
const name = nameInput.value.trim();
if (!name) {
UI.notify('请输入 Key 名称', 'warning');
return;
}
try {
const response = await fetch('/user/apikey/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '创建失败');
}
// 显示新 Key
newApiKey = result.data.api_key;
document.getElementById('newKeyValue').textContent = newApiKey;
document.getElementById('keyModal').classList.add('show');
nameInput.value = '';
loadKeys();
} catch (error) {
UI.notify(error.message, 'error');
}
}
async function toggleKey(keyId, currentStatus) {
try {
const response = await fetch(`/user/apikey/toggle/${keyId}`, {
method: 'POST'
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '操作失败');
}
UI.notify(result.message, 'success');
loadKeys();
} catch (error) {
UI.notify(error.message, 'error');
}
}
async function deleteKey(keyId, keyName) {
if (!confirm(`确定要删除 "${keyName}" 吗?此操作不可恢复。`)) {
return;
}
try {
const response = await fetch(`/user/apikey/delete/${keyId}`, {
method: 'DELETE'
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '删除失败');
}
UI.notify('删除成功', 'success');
loadKeys();
} catch (error) {
UI.notify(error.message, 'error');
}
}
function copyNewKey() {
navigator.clipboard.writeText(newApiKey).then(() => {
UI.notify('已复制到剪贴板', 'success');
}).catch(() => {
UI.notify('复制失败,请手动复制', 'error');
});
}
function closeModal() {
document.getElementById('keyModal').classList.remove('show');
newApiKey = '';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 点击遮罩关闭
document.getElementById('keyModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
loadKeys();
</script>
</body>
</html>

View File

@@ -201,6 +201,73 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 输入框按钮组 */
.input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.input-group .url-input {
flex: 1;
margin-bottom: 0;
}
.input-btn {
padding: 0 1.25rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 12px;
border: 2px solid var(--primary-200);
background: white;
color: var(--primary-600);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.input-btn:hover {
background: var(--primary-50);
border-color: var(--primary-400);
}
.input-btn:active {
transform: scale(0.96);
}
/* 进度条 */
.progress-container {
display: none;
margin-top: 1.5rem;
}
.progress-container.show {
display: block;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--secondary-600);
}
.progress-bar {
height: 8px;
background: var(--secondary-100);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 4px;
width: 0%;
transition: width 0.3s ease;
}
</style>
</head>
<body>
@@ -217,22 +284,36 @@
<div class="main-card">
<div class="input-wrapper">
<input type="text" class="url-input" id="videoUrl" placeholder="在此粘贴 抖音/TikTok/B站 视频链接...">
<div class="input-group">
<input type="text" class="url-input" id="videoUrl" placeholder="在此粘贴 抖音/TikTok/B站/快手/皮皮虾/微博 视频链接...">
<button class="input-btn" onclick="pasteUrl()">粘贴</button>
<button class="input-btn" onclick="clearAll()">清空</button>
</div>
</div>
<button class="action-btn" onclick="parseVideo()" id="parseBtn">
<span class="btn-text">立即解析</span>
<div class="loading-spinner"></div>
</button>
<div class="progress-container" id="progressContainer">
<div class="progress-label">
<span id="progressText">正在解析...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div id="result" class="result-card">
<video id="videoPlayer" class="video-preview" controls></video>
<div class="result-content">
<h3 class="video-title" id="title"></h3>
<p class="video-meta" id="description"></p>
<div class="download-actions">
<a id="videoLink" class="ui-btn ui-btn-primary" href="" download target="_blank">
<button class="ui-btn ui-btn-primary" onclick="downloadVideo()">
下载视频
</a>
</button>
<button class="ui-btn ui-btn-primary" onclick="downloadCover()">
下载封面
</button>
@@ -240,12 +321,14 @@
复制链接
</button>
</div>
<input type="hidden" id="videoLink" value="">
<a id="coverLink" href="" style="display: none;"></a>
</div>
</div>
</div>
<div class="nav-links" id="navLinks">
<a href="/api-docs" class="nav-link">API 文档</a>
<a href="/auth/login" class="nav-link">登录账号</a>
<a href="/auth/register" class="nav-link">注册新用户</a>
</div>
@@ -258,13 +341,103 @@
<script src="/static/js/ui-components.js"></script>
<script>
// 粘贴按钮
async function pasteUrl() {
try {
const text = await navigator.clipboard.readText();
document.getElementById('videoUrl').value = text;
UI.notify('已粘贴', 'success');
} catch (e) {
UI.notify('无法访问剪贴板,请手动粘贴', 'warning');
}
}
// 清空按钮
function clearAll() {
document.getElementById('videoUrl').value = '';
document.getElementById('result').classList.remove('show');
document.getElementById('progressContainer').classList.remove('show');
document.getElementById('videoPlayer').src = '';
document.getElementById('videoPlayer').poster = '';
UI.notify('已清空', 'info');
}
// 进度条控制
let progressInterval = null;
function startProgress() {
const container = document.getElementById('progressContainer');
const fill = document.getElementById('progressFill');
const text = document.getElementById('progressText');
const percent = document.getElementById('progressPercent');
container.classList.add('show');
let progress = 0;
const steps = [
{ p: 15, t: '正在连接服务器...' },
{ p: 35, t: '正在解析链接...' },
{ p: 55, t: '正在获取视频信息...' },
{ p: 75, t: '正在提取无水印地址...' },
{ p: 90, t: '即将完成...' }
];
let stepIndex = 0;
progressInterval = setInterval(() => {
if (stepIndex < steps.length && progress >= steps[stepIndex].p - 10) {
text.textContent = steps[stepIndex].t;
stepIndex++;
}
if (progress < 90) {
progress += Math.random() * 8 + 2;
if (progress > 90) progress = 90;
fill.style.width = progress + '%';
percent.textContent = Math.round(progress) + '%';
}
}, 300);
}
function completeProgress() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
const fill = document.getElementById('progressFill');
const text = document.getElementById('progressText');
const percent = document.getElementById('progressPercent');
fill.style.width = '100%';
percent.textContent = '100%';
text.textContent = '解析完成!';
setTimeout(() => {
document.getElementById('progressContainer').classList.remove('show');
fill.style.width = '0%';
}, 500);
}
function resetProgress() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
const container = document.getElementById('progressContainer');
const fill = document.getElementById('progressFill');
container.classList.remove('show');
fill.style.width = '0%';
}
function extractVideoUrl(text) {
const patterns = [
/https?:\/\/v\.douyin\.com\/[A-Za-z0-9_-]+/,
/https?:\/\/www\.douyin\.com\/video\/\d+/,
/https?:\/\/(?:www\.)?bilibili\.com\/video\/[A-Za-z0-9?&=]+/,
/https?:\/\/b23\.tv\/[A-Za-z0-9]+/,
/https?:\/\/(?:www\.)?tiktok\.com\/@[^\/]+\/video\/\d+/
/https?:\/\/(?:www\.)?tiktok\.com\/@[^\/]+\/video\/\d+/,
/https?:\/\/(?:www\.)?kuaishou\.com\/[A-Za-z0-9\/_-]+/,
/https?:\/\/h5\.pipix\.com\/s\/[A-Za-z0-9_-]+\/?/,
/https?:\/\/(?:www\.)?pipix\.com\/[A-Za-z0-9\/_-]+/,
/https?:\/\/(?:video\.)?weibo\.com\/show\?fid=[0-9:]+/,
/https?:\/\/(?:www\.)?weibo\.com\/[A-Za-z0-9\/]+/
];
for (const pattern of patterns) {
@@ -295,6 +468,7 @@
resultCard.classList.remove('show');
btn.disabled = true;
btn.classList.add('loading');
startProgress();
try {
const response = await fetch('/api/parse', {
@@ -318,6 +492,7 @@
UI.notify(error.message, 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
}
}
@@ -331,6 +506,7 @@
UI.notify('解析超时,请稍后重试', 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
return;
}
@@ -350,6 +526,7 @@
UI.notify(error.message, 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
}
};
@@ -360,14 +537,31 @@
const btn = document.getElementById('parseBtn');
const resultCard = document.getElementById('result');
completeProgress();
document.getElementById('title').textContent = data.title || '无标题';
document.getElementById('description').textContent = data.description || '';
const player = document.getElementById('videoPlayer');
player.src = data.video_url;
player.poster = data.cover;
// 先停止并清空当前视频
player.pause();
player.removeAttribute('src');
player.load();
document.getElementById('videoLink').href = data.video_url;
// 使用代理播放视频,绕过防盗链
const proxyVideoUrl = '/proxy/download?url=' + encodeURIComponent(data.video_url);
player.src = proxyVideoUrl;
// 封面也使用代理
if (data.cover) {
const proxyCoverUrl = '/proxy/download?url=' + encodeURIComponent(data.cover);
player.poster = proxyCoverUrl;
}
// 强制重新加载视频
player.load();
document.getElementById('videoLink').value = data.video_url;
document.getElementById('coverLink').href = data.cover;
resultCard.classList.add('show');
@@ -377,6 +571,18 @@
UI.notify('解析成功', 'success');
}
function downloadVideo() {
const videoUrl = document.getElementById('videoLink').value;
if (!videoUrl) {
UI.notify('视频链接无效', 'error');
return;
}
// 使用代理下载
const proxyUrl = '/proxy/download?url=' + encodeURIComponent(videoUrl);
window.open(proxyUrl, '_blank');
}
async function downloadCover() {
const coverUrl = document.getElementById('coverLink').href;
if (!coverUrl) {
@@ -434,6 +640,7 @@
const data = await response.json();
if (data.success) {
document.getElementById('navLinks').innerHTML = `
<a href="/api-docs" class="nav-link">API 文档</a>
<a href="/auth/profile" class="nav-link">个人中心</a>
<a href="#" class="nav-link" onclick="logout(); return false;">退出登录</a>
`;

View File

@@ -251,6 +251,132 @@
overflow-x: auto;
}
}
/* API Key 管理样式 */
.api-key-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--secondary-100);
border-radius: var(--radius-md);
margin-bottom: 0.5rem;
}
.api-key-item:last-child {
margin-bottom: 0;
}
.api-key-info {
flex: 1;
}
.api-key-name {
font-weight: 500;
color: var(--secondary-800);
margin-bottom: 0.25rem;
}
.api-key-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.api-key-status {
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-weight: 500;
}
.api-key-status.active {
background: #dcfce7;
color: #166534;
}
.api-key-status.inactive {
background: #fee2e2;
color: #991b1b;
}
.api-key-actions {
display: flex;
gap: 0.5rem;
}
/* 弹窗样式 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
border: none;
padding: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--secondary-700);
}
</style>
</head>
@@ -299,6 +425,10 @@
<span class="info-label">用户组</span>
<span class="info-value" id="groupName">-</span>
</div>
<div class="info-row" id="expiryRow" style="display: none;">
<span class="info-label">套餐到期</span>
<span class="info-value" id="expiryTime">-</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value" id="createdAt">-</span>
@@ -312,6 +442,74 @@
</div>
</div>
<!-- 兑换码 -->
<div class="info-card">
<h3>兑换码</h3>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
输入兑换码升级您的账户套餐
</p>
<div style="display: flex; gap: 0.75rem;">
<input type="text" id="redeemCode" class="ui-input" placeholder="请输入兑换码" style="flex: 1; text-transform: uppercase;">
<button class="ui-btn ui-btn-primary" onclick="redeemCode()">兑换</button>
</div>
</div>
<!-- API Key 管理 -->
<div class="info-card">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
API Key 管理
<button class="ui-btn ui-btn-primary ui-btn-sm" onclick="showCreateKeyModal()">创建 Key</button>
</h3>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
通过 API Key 可以在您的应用中调用视频解析接口最多5个
</p>
<div id="apiKeyList">
<p style="color: var(--text-muted); text-align: center;">加载中...</p>
</div>
</div>
<!-- 创建 API Key 弹窗 -->
<div class="modal" id="createKeyModal">
<div class="modal-content">
<div class="modal-header">
<h3>创建 API Key</h3>
<button class="modal-close" onclick="closeModal('createKeyModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Key 名称</label>
<input type="text" id="keyName" class="ui-input" placeholder="如:我的应用">
</div>
</div>
<div class="modal-footer">
<button class="ui-btn ui-btn-secondary" onclick="closeModal('createKeyModal')">取消</button>
<button class="ui-btn ui-btn-primary" onclick="createApiKey()">创建</button>
</div>
</div>
</div>
<!-- 显示新 Key 弹窗 -->
<div class="modal" id="newKeyModal">
<div class="modal-content">
<div class="modal-header">
<h3>API Key 创建成功</h3>
<button class="modal-close" onclick="closeModal('newKeyModal')">&times;</button>
</div>
<div class="modal-body">
<p style="color: #dc2626; font-size: 0.875rem; margin-bottom: 1rem;">
请立即复制保存,此 Key 只显示一次!
</p>
<div class="form-group">
<label>API Key</label>
<input type="text" id="newKeyValue" class="ui-input" readonly style="font-family: monospace;">
</div>
</div>
<div class="modal-footer">
<button class="ui-btn ui-btn-primary" onclick="copyNewKey()">复制 Key</button>
</div>
</div>
</div>
<!-- 解析记录 -->
<div class="logs-card">
<div class="logs-header">
@@ -368,6 +566,15 @@
const groupBadge = getGroupBadge(data.group.name);
document.getElementById('groupName').innerHTML = groupBadge;
// 显示套餐到期时间(在账户信息卡片中)
if (data.group.expires_at) {
document.getElementById('expiryRow').style.display = 'flex';
const expiryText = data.group.is_expired
? `<span style="color: #dc2626;">${data.group.expires_at}(已过期)</span>`
: data.group.expires_at;
document.getElementById('expiryTime').innerHTML = expiryText;
}
// 使用进度
const usagePercent = data.usage.daily_limit > 0
? Math.round((data.usage.today_used / data.usage.daily_limit) * 100)
@@ -440,7 +647,167 @@
}
}
async function redeemCode() {
const code = document.getElementById('redeemCode').value.trim();
if (!code) {
UI.notify('请输入兑换码', 'warning');
return;
}
try {
const response = await fetch('/auth/api/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
});
const result = await response.json();
if (result.success) {
UI.notify(result.message, 'success');
document.getElementById('redeemCode').value = '';
loadProfile();
} else {
UI.notify(result.message, 'error');
}
} catch (error) {
UI.notify('兑换失败', 'error');
}
}
document.getElementById('redeemCode').addEventListener('keypress', (e) => {
if (e.key === 'Enter') redeemCode();
});
// ==================== API Key 管理 ====================
async function loadApiKeys() {
try {
const response = await fetch('/user/apikey/list');
const result = await response.json();
const container = document.getElementById('apiKeyList');
if (!result.success) {
container.innerHTML = '<p style="color: var(--text-muted); text-align: center;">加载失败</p>';
return;
}
if (result.data.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); text-align: center;">暂无 API Key</p>';
return;
}
container.innerHTML = result.data.map(key => `
<div class="api-key-item">
<div class="api-key-info">
<div class="api-key-name">${key.name || '未命名'}</div>
<div class="api-key-meta">
<span>Key: ${key.api_key}</span>
<span>调用: ${key.total_calls || 0}次</span>
<span class="api-key-status ${key.is_active ? 'active' : 'inactive'}">${key.is_active ? '启用' : '禁用'}</span>
</div>
</div>
<div class="api-key-actions">
<button class="ui-btn ui-btn-sm ${key.is_active ? 'ui-btn-secondary' : 'ui-btn-primary'}" onclick="toggleApiKey(${key.id})">
${key.is_active ? '禁用' : '启用'}
</button>
<button class="ui-btn ui-btn-sm ui-btn-danger" onclick="deleteApiKey(${key.id})">删除</button>
</div>
</div>
`).join('');
} catch (error) {
document.getElementById('apiKeyList').innerHTML = '<p style="color: var(--text-muted); text-align: center;">加载失败</p>';
}
}
function showCreateKeyModal() {
document.getElementById('keyName').value = '';
document.getElementById('createKeyModal').classList.add('show');
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
async function createApiKey() {
const name = document.getElementById('keyName').value.trim();
if (!name) {
UI.notify('请输入 Key 名称', 'warning');
return;
}
try {
const response = await fetch('/user/apikey/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
const result = await response.json();
if (result.success) {
closeModal('createKeyModal');
document.getElementById('newKeyValue').value = result.data.api_key;
document.getElementById('newKeyModal').classList.add('show');
loadApiKeys();
} else {
UI.notify(result.message, 'error');
}
} catch (error) {
UI.notify('创建失败', 'error');
}
}
function copyNewKey() {
const input = document.getElementById('newKeyValue');
input.select();
document.execCommand('copy');
UI.notify('已复制到剪贴板', 'success');
}
async function toggleApiKey(keyId) {
try {
const response = await fetch(`/user/apikey/toggle/${keyId}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
UI.notify(result.message, 'success');
loadApiKeys();
} else {
UI.notify(result.message, 'error');
}
} catch (error) {
UI.notify('操作失败', 'error');
}
}
async function deleteApiKey(keyId) {
if (!confirm('确定要删除这个 API Key 吗?')) return;
try {
const response = await fetch(`/user/apikey/delete/${keyId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
UI.notify('删除成功', 'success');
loadApiKeys();
} else {
UI.notify(result.message, 'error');
}
} catch (error) {
UI.notify('删除失败', 'error');
}
}
loadProfile();
loadApiKeys();
</script>
</body>

104
utils/api_auth.py Normal file
View File

@@ -0,0 +1,104 @@
from functools import wraps
from flask import request, jsonify
from models import db, UserApiKey, ApiKeyDailyStat
from datetime import datetime, date
def get_api_key_from_request():
"""从请求中获取 API Key"""
# 优先从 Header 获取
api_key = request.headers.get('X-API-Key')
if api_key:
return api_key
# 其次从 Authorization Bearer 获取
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
return auth_header[7:]
# 最后从查询参数获取
return request.args.get('api_key')
def validate_api_key(api_key):
"""
验证 API Key
返回: (is_valid, key_obj_or_error_msg)
"""
if not api_key:
return False, 'API Key 不能为空'
key_obj = UserApiKey.query.filter_by(api_key=api_key).first()
if not key_obj:
return False, 'API Key 无效'
if not key_obj.is_active:
return False, 'API Key 已被禁用'
if not key_obj.user.is_active:
return False, '用户账号已被禁用'
# 检查每日限额
today_stat = ApiKeyDailyStat.query.filter_by(
api_key_id=key_obj.id,
date=date.today()
).first()
if today_stat and today_stat.call_count >= key_obj.daily_limit:
return False, f'已达到每日调用限额({key_obj.daily_limit}次)'
return True, key_obj
def record_api_call(key_obj, ip_address, success=True):
"""记录 API 调用"""
# 更新 Key 统计
key_obj.total_calls += 1
key_obj.last_used_at = datetime.utcnow()
key_obj.last_used_ip = ip_address
# 更新每日统计
today_stat = ApiKeyDailyStat.query.filter_by(
api_key_id=key_obj.id,
date=date.today()
).first()
if not today_stat:
today_stat = ApiKeyDailyStat(
api_key_id=key_obj.id,
date=date.today()
)
db.session.add(today_stat)
today_stat.call_count += 1
if success:
today_stat.success_count += 1
else:
today_stat.fail_count += 1
db.session.commit()
def api_key_required(f):
"""API Key 鉴权装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
api_key = get_api_key_from_request()
is_valid, result = validate_api_key(api_key)
if not is_valid:
return jsonify({
'success': False,
'error': {
'code': 'UNAUTHORIZED',
'message': result
}
}), 401
# 将 key 对象传递给视图函数
request.api_key_obj = result
return f(*args, **kwargs)
return decorated_function

View File

@@ -0,0 +1,99 @@
# 开发会话记录 - 2025-11-30
## 本次会话完成的功能
### 1. API Key 内联管理(个人中心)
- 将 API Key 管理功能直接嵌入到个人中心页面
- 支持创建、查看、启用/禁用、删除 API Key
- 新创建的 Key 只显示一次,提示用户立即复制
- 文件修改:`templates/profile.html`
### 2. 用户套餐到期时间管理
- 管理后台编辑用户时可设置套餐到期时间
- VIP/SVIP 用户显示到期时间输入框
- 游客和普通用户不显示(永久有效)
- 用户列表显示套餐到期状态
- 文件修改:
- `templates/admin_users.html`
- `routes/admin.py`get_users、update_user
### 3. 自定义下拉框组件
- 创建美观的自定义下拉框替代浏览器原生 select
- 自动初始化所有 `.ui-input``.ui-select` 下拉框
- 支持键盘操作Enter/Space 打开Escape 关闭)
- 点击外部自动关闭
- 动态选项更新支持
- 文件修改:
- `static/css/ui-components.css`
- `static/js/ui-components.js`
### 4. 平台选项更新
- 管理后台所有平台下拉框添加新平台选项
- 新增:快手(kuaishou)、皮皮虾(pipixia)、微博(weibo)
- 文件修改:
- `templates/admin_apis.html`
- `templates/admin_logs.html`
- `templates/admin_health_checks.html`
---
## 之前会话完成的功能(摘要)
### 兑换码系统
- 管理员批量生成兑换码
- 用户在个人中心兑换升级套餐
- 支持套餐时间叠加
- 文件:`models/__init__.py``routes/admin.py``routes/auth.py``templates/admin_redeem_codes.html`
### 视频代理下载
- 解决防盗链问题Referer
- 支持多平台域名白名单
- 文件:`routes/main.py`
### B站短链接支持
- 自动展开 b23.tv 短链接
- 文件:`parsers/factory.py`
### 前端优化
- 粘贴按钮、清空按钮
- 解析进度条
- 视频播放器缓存修复
- 文件:`templates/index.html`
---
## 项目当前支持的平台
1. 抖音 (douyin)
2. TikTok (tiktok)
3. 哔哩哔哩 (bilibili)
4. 快手 (kuaishou)
5. 皮皮虾 (pipixia)
6. 微博 (weibo)
---
## 关键文件清单
### 后端路由
- `routes/auth.py` - 用户认证、个人中心、兑换码
- `routes/admin.py` - 管理后台 API
- `routes/parser.py` - 视频解析 API
- `routes/api_v1.py` - 外部 APIAPI Key 认证)
- `routes/apikey.py` - API Key 管理
- `routes/main.py` - 主页、代理下载
### 前端模板
- `templates/index.html` - 首页
- `templates/profile.html` - 个人中心
- `templates/admin_*.html` - 管理后台页面
### 静态资源
- `static/css/ui-components.css` - UI 组件样式
- `static/js/ui-components.js` - UI 组件脚本
### 数据模型
- `models/__init__.py` - 所有数据库模型
### 解析器
- `parsers/factory.py` - 解析器工厂
- `parsers/base.py` - 基础解析器类

View File

@@ -0,0 +1,71 @@
{
"info": {
"_postman_id": "38e90715-747a-4f04-89b2-194e902cdbdf",
"name": "微博解析",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "49242368",
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-38e90715-747a-4f04-89b2-194e902cdbdf?action=share&source=collection_link&creator=49242368"
},
"item": [
{
"name": "优创微博API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://apis.uctb.cn/api/videojx?url=https://video.weibo.com/show?fid=1034:5233299304415254",
"protocol": "http",
"host": [
"apis",
"uctb",
"cn"
],
"path": [
"api",
"videojx"
],
"query": [
{
"key": "url",
"value": "https://video.weibo.com/show?fid=1034:5233299304415254"
}
]
}
},
"response": []
},
{
"name": "妖狐微博API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://api.yaohud.cn/api/v6/video/weibo?key=SM227DLC0ZgJ6DXJhAx&url=https://video.weibo.com/show?fid=1034:5233299304415254",
"protocol": "https",
"host": [
"api",
"yaohud",
"cn"
],
"path": [
"api",
"v6",
"video",
"weibo"
],
"query": [
{
"key": "key",
"value": "SM227DLC0ZgJ6DXJhAx"
},
{
"key": "url",
"value": "https://video.weibo.com/show?fid=1034:5233299304415254"
}
]
}
},
"response": []
}
]
}

View File

@@ -0,0 +1,97 @@
{
"info": {
"_postman_id": "f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8",
"name": "快手解析",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "49242368",
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8?action=share&source=collection_link&creator=49242368"
},
"item": [
{
"name": "BugPk快手",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
"protocol": "https",
"host": [
"api",
"bugpk",
"com"
],
"path": [
"api",
"kuaishou"
],
"query": [
{
"key": "url",
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
}
]
}
},
"response": []
},
{
"name": "Nice猫快手API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://api.nsmao.net/api/kuaishou/query?key=UhjPIwpNdljUSdRUGCTxsuDEMm&url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
"protocol": "https",
"host": [
"api",
"nsmao",
"net"
],
"path": [
"api",
"kuaishou",
"query"
],
"query": [
{
"key": "key",
"value": "UhjPIwpNdljUSdRUGCTxsuDEMm"
},
{
"key": "url",
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
}
]
}
},
"response": []
},
{
"name": "优创快手API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://apis.uctb.cn/api/videojx?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
"protocol": "http",
"host": [
"apis",
"uctb",
"cn"
],
"path": [
"api",
"videojx"
],
"query": [
{
"key": "url",
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
}
]
}
},
"response": []
}
]
}

View File

@@ -0,0 +1,65 @@
{
"info": {
"_postman_id": "31f6f547-0cb0-4610-b2b6-130ec8e75811",
"name": "皮皮虾解析",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "49242368",
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-31f6f547-0cb0-4610-b2b6-130ec8e75811?action=share&source=collection_link&creator=49242368"
},
"item": [
{
"name": "BugPK皮皮虾API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/",
"protocol": "https",
"host": [
"api",
"bugpk",
"com"
],
"path": [
"api",
"pipixia"
],
"query": [
{
"key": "url",
"value": "https://h5.pipix.com/s/kLUS2a4iJ_M/"
}
]
}
},
"response": []
},
{
"name": "优创皮皮虾API",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://apis.uctb.cn/api/videojx?url=https://h5.pipix.com/s/wrDsplDprWs/",
"protocol": "http",
"host": [
"apis",
"uctb",
"cn"
],
"path": [
"api",
"videojx"
],
"query": [
{
"key": "url",
"value": "https://h5.pipix.com/s/wrDsplDprWs/"
}
]
}
},
"response": []
}
]
}