From 494f667719ddd4bbd1e3e1136a34d751e659f2e8 Mon Sep 17 00:00:00 2001 From: lz_db Date: Sun, 25 Oct 2026 11:46:44 +0800 Subject: [PATCH] 1 --- admin/91docs/list.md | 104 +++ admin/91docs/login.py | 192 ++++ admin/src/api/system.js | 57 ++ admin/src/api/videos.js | 12 +- admin/src/layout/MainLayout.vue | 2 + admin/src/router/index.js | 18 + admin/src/views/CollectSources.vue | 146 ++- admin/src/views/SeriesManage.vue | 66 +- admin/src/views/SourceManage.vue | 155 ++++ admin/src/views/SystemBackup.vue | 218 +++++ admin/src/views/UserActionLogs.vue | 156 ++++ admin/src/views/VideoList.vue | 118 ++- app/lib/core/log/action_logger.dart | 134 +++ app/lib/core/media/cover_cache.dart | 18 + app/lib/core/network/endpoints.dart | 2 + app/lib/core/player/feed_player_pool.dart | 15 + app/lib/models/video_item.dart | 38 + app/lib/providers/auth_provider.dart | 7 + app/lib/providers/feed_provider.dart | 24 +- app/lib/router.dart | 10 + app/lib/screens/category/category_screen.dart | 130 ++- .../screens/downloads/downloads_screen.dart | 5 +- app/lib/screens/feed/feed_screen.dart | 43 +- .../screens/feed/widgets/comment_sheet.dart | 3 +- .../screens/feed/widgets/feed_video_page.dart | 95 +- .../screens/feed/widgets/interaction_bar.dart | 3 +- .../feed/widgets/video_info_section.dart | 44 +- .../screens/player/simple_player_screen.dart | 249 ++++- app/lib/screens/profile/mine_screen.dart | 3 +- .../screens/profile/profile_edit_screen.dart | 3 +- .../screens/profile/user_profile_screen.dart | 16 +- .../screens/profile/video_list_screens.dart | 22 +- .../screens/search/search_feed_screen.dart | 230 +++++ app/lib/screens/search/search_screen.dart | 50 +- app/lib/screens/search/tag_videos_screen.dart | 13 +- app/lib/services/upload_service.dart | 10 +- app/lib/services/video_service.dart | 18 + app/pubspec.yaml | 4 +- deploy-full.sh | 463 ++++++++++ docs/04-部署运行文档.md | 264 +++++- ...2e5f18b04_collection_source_kind_config.py | 29 + .../cd1b499eba7a_add_user_action_logs.py | 36 + .../f1b7d4a9c310_add_sources_and_source_id.py | 49 + .../api/v1/__pycache__/videos.cpython-311.pyc | Bin 15303 -> 18397 bytes server/app/api/v1/admin.py | 401 ++++++++- server/app/api/v1/logs.py | 74 ++ server/app/api/v1/media.py | 108 ++- server/app/api/v1/system.py | 295 ++++++ server/app/api/v1/videos.py | 56 +- server/app/main.py | 5 +- server/app/models/__init__.py | 3 +- .../__pycache__/__init__.cpython-311.pyc | Bin 1523 -> 1608 bytes .../models/__pycache__/video.cpython-311.pyc | Bin 13312 -> 15725 bytes server/app/models/user_action_log.py | 26 + server/app/models/video.py | 28 +- .../__pycache__/admin_service.cpython-311.pyc | Bin 92515 -> 94350 bytes .../__pycache__/video_service.cpython-311.pyc | Bin 55188 -> 63731 bytes server/app/services/admin_service.py | 23 +- server/app/services/aidrama91_service.py | 850 ++++++++++++++++++ server/app/services/collection_service.py | 37 +- server/app/services/title_tags.py | 62 ++ server/app/services/video_service.py | 152 +++- server/scripts/build_tags_from_titles.py | 58 +- 63 files changed, 5213 insertions(+), 239 deletions(-) create mode 100644 admin/91docs/list.md create mode 100644 admin/91docs/login.py create mode 100644 admin/src/api/system.js create mode 100644 admin/src/views/SourceManage.vue create mode 100644 admin/src/views/SystemBackup.vue create mode 100644 admin/src/views/UserActionLogs.vue create mode 100644 app/lib/core/log/action_logger.dart create mode 100644 app/lib/core/media/cover_cache.dart create mode 100644 app/lib/screens/search/search_feed_screen.dart create mode 100755 deploy-full.sh create mode 100644 server/alembic/versions/a7c2e5f18b04_collection_source_kind_config.py create mode 100644 server/alembic/versions/cd1b499eba7a_add_user_action_logs.py create mode 100644 server/alembic/versions/f1b7d4a9c310_add_sources_and_source_id.py create mode 100644 server/app/api/v1/logs.py create mode 100644 server/app/api/v1/system.py create mode 100644 server/app/models/user_action_log.py create mode 100644 server/app/services/aidrama91_service.py create mode 100644 server/app/services/title_tags.py diff --git a/admin/91docs/list.md b/admin/91docs/list.md new file mode 100644 index 0000000..4d41417 --- /dev/null +++ b/admin/91docs/list.md @@ -0,0 +1,104 @@ +首页-默认 + +```JSON +{ + "oauth_type": "pwa", + "oauth_id": "cXNQe5FQXIkaW8Ny1792802204704", + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + "mod": "index", + "code": "home", + "page": 3, + "token": "3fba65f06e1e308c819e343474075461", + "timestamp": 1792811390633 +} +``` + +首页-精选-最新 + +```JSON +{ +"oauth_type": "pwa", +"oauth_id": "cXNQe5FQXIkaW8Ny1792802204704", +"trace_id": "", +"aff_x_code": "", +"version": "7.5.0", +"mod": "choice", +"code": "vlog", +"type": "new", +"page": 1, +"limit": 30, +"token": "3fba65f06e1e308c819e343474075461", +"timestamp": 1792810190406 +} +``` + +短剧-原创-全部 + +```JSON +{ + "oauth_type": "pwa", + "oauth_id": "cXNQe5FQXIkaW8Ny1792802204704", + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + "mod": "aidrama", + "code": "home", + "page": 1, + "limit": 10, + "pay_type": "all", + "sort": "all", + "category_ids": [ + 4 + ], + "token": "3fba65f06e1e308c819e343474075461", + "timestamp": 1792811903280 +} +``` + +短剧-详情页(请求) + +```json +{ + "oauth_type": "pwa", + "oauth_id": "cXNQe5FQXIkaW8Ny1792802204704", + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + "mod": "aidrama", + "code": "detail", + "drama_id": 1008, + "token": "3fba65f06e1e308c819e343474075461", + "timestamp": 1792812296047 +} +``` + +短剧-请求所有集数 + +```json +{ + "oauth_type": "pwa", + "oauth_id": "cXNQe5FQXIkaW8Ny1792802204704", + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + "mod": "aidrama", + "code": "episodes", + "drama_id": 1008, + "page": 1, + "limit": 100, + "token": "3fba65f06e1e308c819e343474075461", + "timestamp": 1792812296047 +} +``` + +
+ +短剧-获得所有集数 + +```json +aaa +aaa +``` + diff --git a/admin/91docs/login.py b/admin/91docs/login.py new file mode 100644 index 0000000..2a117d8 --- /dev/null +++ b/admin/91docs/login.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""91Video PWA 登录脚本 + +通过逆向 pwa4.jl8v0p5t.cc 的加密协议,调用登录接口获取 token。 + +依赖: pip install requests pycryptodome +""" + +import hashlib +import json +import random +import string +import time + +import requests +from Crypto.Cipher import AES + +# ===== 协议常量 (从前端 JS 逆向得到) ===== +API_URL = "https://api4.zqpnd5a7.cc/pwa.php" + +# AES-256 密钥与 IV (请求加密 / 响应解密共用) +AES_KEY = b"cc88ddc9357ff461e08f047aedee692b" # 32 bytes +AES_IV = b"e89225cfbbimgkcu" # 16 bytes + +# 签名用的密钥 (即 AES 密钥) +SIGN_KEY = "cc88ddc9357ff461e08f047aedee692b" + +# oauth_id 随机字符集 +OAUTH_CHARSET = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" +) + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Linux; Android 10; SM-G973F) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Mobile Safari/537.36" + ), + "Origin": "https://pwa4.jl8v0p5t.cc", + "Referer": "https://pwa4.jl8v0p5t.cc/", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/x-www-form-urlencoded", +} + + +def _aes_encrypt(plaintext: str) -> str: + """AES-256-CFB (segment_size=128) 加密, 返回大写十六进制字符串。""" + cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128) + return cipher.encrypt(plaintext.encode("utf-8")).hex().upper() + + +def _aes_decrypt(hex_data: str) -> str: + """AES-256-CFB (segment_size=128) 解密。""" + cipher = AES.new(AES_KEY, AES.MODE_CFB, AES_IV, segment_size=128) + return cipher.decrypt(bytes.fromhex(hex_data)).decode("utf-8", "replace") + + +def _make_sign(data_hex: str, timestamp_sec: int) -> str: + """生成 sign: MD5(SHA256('client=pwa&data=×tamp='))""" + base = f"client=pwa&data={data_hex}×tamp={timestamp_sec}{SIGN_KEY}" + sha = hashlib.sha256(base.encode("utf-8")).hexdigest() + return hashlib.md5(sha.encode("utf-8")).hexdigest() + + +def _gen_oauth_id() -> str: + """生成 oauth_id: 16 位随机字符 + 毫秒时间戳。""" + rand_part = "".join(random.choices(OAUTH_CHARSET, k=16)) + return rand_part + str(int(time.time() * 1000)) + + +def _post(session: requests.Session, payload: dict) -> dict: + """加密 payload 并发送请求,返回解密后的 JSON 响应。""" + ts_ms = int(time.time() * 1000) + ts_sec = ts_ms // 1000 + payload["timestamp"] = ts_ms + + plaintext = json.dumps(payload, separators=(",", ":")) + data_hex = _aes_encrypt(plaintext) + sign = _make_sign(data_hex, ts_sec) + + body = f"client=pwa×tamp={ts_sec}&data={data_hex}&sign={sign}" + + resp = session.post(API_URL, data=body, headers=HEADERS, timeout=30) + if resp.status_code == 503: + raise RuntimeError( + "服务端返回 503。若 oauth_id 未经 system/index 注册会触发此错误;" + "也可能是 CloudFront 限流,请稍后重试。" + ) + resp.raise_for_status() + + result = resp.json() + if result.get("errcode") != 0: + raise RuntimeError(f"接口返回错误: {result}") + + encrypted_data = result.get("data", "") + if not encrypted_data: + return result + + decrypted = _aes_decrypt(encrypted_data) + return json.loads(decrypted) + + +def login(account: str, password: str): + """登录并返回 (session, oauth_id, 解析后的登录响应)。 + + 登录前必须先调用 system/index 注册 oauth_id, + 否则服务端会对未注册的 oauth_id 返回 503。 + """ + session = requests.Session() + oauth_id = _gen_oauth_id() + + base = { + "oauth_type": "pwa", + "oauth_id": oauth_id, + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + } + + # 1) 先调用 system/index 注册 oauth_id + _post(session, dict(base, mod="system", code="index", data={}, token="")) + time.sleep(0.3) + + # 2) 再调用登录接口 + payload = dict( + base, + mod="user", + code="regOrLogin", + account=account, + password=password, + type="login", + ) + result = _post(session, payload) + return session, oauth_id, result + + +def fetch_home(session: requests.Session, oauth_id: str, token: str, page: int) -> dict: + """请求首页列表 (mod=index, code=home) 并返回解密后的数据。""" + payload = { + "oauth_type": "pwa", + "oauth_id": oauth_id, + "trace_id": "", + "aff_x_code": "", + "version": "7.5.0", + "mod": "index", + "code": "home", + "page": page, + "token": token, + } + return _post(session, payload) + + +def main(): + account = "16774351679" + password = "admin123456" + + print(f"正在登录账号: {account}") + session, oauth_id, result = login(account, password) + + inner = result.get("data", result) + if not inner.get("success"): + print("登录失败:", inner.get("msg", result)) + return + + token = inner.get("token") + print(f"登录成功! token: {token}") + + # 请求首页第 1~10 页并解密保存 + for page in range(1, 2): + try: + data = fetch_home(session, oauth_id, token, page) + except Exception as e: + print(f"第 {page} 页请求失败: {e}") + continue + + out_file = f"home_page_{page}.json" + with open(out_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + # 打印摘要:data 直接就是视频列表 + items = data.get("data") if isinstance(data.get("data"), list) else [] + print(f"第 {page} 页: 已解密并保存到 {out_file} (共 {len(items)} 条)") + time.sleep(0.5) + + +if __name__ == "__main__": + # data_str = '46CF9369809931B4D43024E4A689EADDF078D83A100348DB8CB1B386522B6F5811D57BAD96801571597E550A83F20B865F34F7CEA7BCA0ED2CE780257CE0D818F2E36A04941BC58B0767C3263377995656FC9C251C58BB8C3C5BF36DBFD0B65348FF1031D9E381377593BB8B32F39C8F0AF2DFF7579BA2B260E6073B9B6A6EB1FC309116F38380C7EA555684C9BF4464D172B1DA3513461AB5E1A1865FF4E7AE146D980129EFFE464773B03212543462D1CBB31A79EDE0B9402A17677CCF1526213B4E787BAF7F6252A0C445412C519DCF663CA477D6CC9F1E7C8586F2D486EADE8A12A16FB215BCBCB9D4128DB568FEC5D572863A61AE0D55B36D00' + # decrypted = json.loads(_aes_decrypt(data_str)) + + # print("-----") + # print(json.dumps(decrypted, ensure_ascii=False, indent=2)) + main() diff --git a/admin/src/api/system.js b/admin/src/api/system.js new file mode 100644 index 0000000..058b63f --- /dev/null +++ b/admin/src/api/system.js @@ -0,0 +1,57 @@ +import request from './request' + +/** 列出所有备份文件 */ +export function listBackups() { + return request.get('/admin/system/backups') +} + +/** 立即创建一次备份(同步等待返回,可能耗时几秒~几十秒) */ +export function createBackup() { + // 备份耗时较长,单独放宽超时到 5 分钟 + return request.post('/admin/system/backup', null, { timeout: 5 * 60 * 1000 }) +} + +/** 删除指定备份 */ +export function deleteBackup(filename) { + return request.delete(`/admin/system/backups/${encodeURIComponent(filename)}`) +} + +/** 从已有备份恢复(高危) */ +export function restoreFromExisting(filename) { + return request.post( + `/admin/system/backups/${encodeURIComponent(filename)}/restore`, + null, + { timeout: 10 * 60 * 1000 }, + ) +} + +/** 上传 .sql.gz 文件并恢复(高危) */ +export function restoreFromUpload(file) { + const fd = new FormData() + fd.append('file', file) + return request.post('/admin/system/restore', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 10 * 60 * 1000, + }) +} + +/** 下载备份文件(手动携带 Authorization 头,绕过 axios 直接打开) */ +export async function downloadBackup(filename) { + const { getToken } = await import('@/utils/auth') + const token = getToken() + const resp = await fetch(`/api/v1/admin/system/backups/${encodeURIComponent(filename)}/download`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!resp.ok) { + throw new Error(`下载失败:${resp.status}`) + } + const blob = await resp.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/admin/src/api/videos.js b/admin/src/api/videos.js index 4be4ddf..a219f94 100644 --- a/admin/src/api/videos.js +++ b/admin/src/api/videos.js @@ -21,11 +21,21 @@ export function offlineVideo(videoId, data) { return request.post(`/admin/videos/${videoId}/offline`, data) } -/** 视频基础编辑 { title, description, cover_url, category_id, series_id, episode_no, status } */ +/** 视频基础编辑 { title, description, cover_url, category_id, source_id, series_id, episode_no, status } */ export function updateVideo(videoId, data) { return request.put(`/admin/videos/${videoId}`, data) } +/** 批量编辑视频 { video_ids, source_id?, status?, category_id? } */ +export function batchUpdateVideos(data) { + return request.put('/admin/videos/batch', data) +} + +/** 批量下架视频 { video_ids, reason }(复用单条下架:通知作者 + 写日志 + 清缓存) */ +export function batchOfflineVideos(data) { + return request.post('/admin/videos/batch-offline', data) +} + /** 删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报) */ export function deleteVideo(videoId) { return request.delete(`/admin/videos/${videoId}`) diff --git a/admin/src/layout/MainLayout.vue b/admin/src/layout/MainLayout.vue index 6161892..5b71bfd 100644 --- a/admin/src/layout/MainLayout.vue +++ b/admin/src/layout/MainLayout.vue @@ -71,6 +71,7 @@ const menuConfig = [ { title: '发布视频', path: '/videos/create', icon: 'Upload', roles: [] }, { title: '视频管理', path: '/videos', icon: 'Film', roles: [] }, { title: '系列管理', path: '/series', icon: 'CollectionTag', roles: [] }, + { title: '来源管理', path: '/sources', icon: 'Flag', roles: [] }, { title: '采集源管理', path: '/collect-sources', icon: 'Download', roles: [] }, { title: '分类管理', path: '/categories', icon: 'Menu', roles: [] }, { title: '标签管理', path: '/tags', icon: 'PriceTag', roles: [] }, @@ -103,6 +104,7 @@ const menuConfig = [ { title: '推荐风控', path: '/referrals', icon: 'Share', roles: [] }, { title: '敏感词库', path: '/sensitive-words', icon: 'Collection', roles: [] }, { title: '平台配置', path: '/configs', icon: 'Setting', roles: [] }, + { title: '数据库备份', path: '/system/backup', icon: 'Coin', roles: [] }, { title: '操作日志', path: '/logs', icon: 'Document', roles: [] }, ], }, diff --git a/admin/src/router/index.js b/admin/src/router/index.js index 0710d49..3ab5850 100644 --- a/admin/src/router/index.js +++ b/admin/src/router/index.js @@ -58,6 +58,12 @@ const routes = [ component: () => import('@/views/SeriesManage.vue'), meta: { title: '系列管理', icon: 'CollectionTag', roles: [] }, }, + { + path: 'sources', + name: 'source-manage', + component: () => import('@/views/SourceManage.vue'), + meta: { title: '来源管理', icon: 'Flag', roles: [] }, + }, { path: 'collect-sources', name: 'collect-sources', @@ -148,6 +154,18 @@ const routes = [ component: () => import('@/views/ConfigList.vue'), meta: { title: '平台配置', icon: 'Setting', roles: [] }, }, + { + path: 'system/backup', + name: 'system-backup', + component: () => import('@/views/SystemBackup.vue'), + meta: { title: '数据库备份', icon: 'Coin', roles: [] }, + }, + { + path: 'user-action-logs', + name: 'user-action-logs', + component: () => import('@/views/UserActionLogs.vue'), + meta: { title: '用户操作日志', icon: 'Document', roles: [] }, + }, { path: 'logs', name: 'log-list', diff --git a/admin/src/views/CollectSources.vue b/admin/src/views/CollectSources.vue index 6e92114..643a30b 100644 --- a/admin/src/views/CollectSources.vue +++ b/admin/src/views/CollectSources.vue @@ -14,12 +14,28 @@ 添加"分类名 + 列表URL"后点同步即可自动采集;URL 中用 {{page}} 表示页码占位。 - 分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。 + 分类名不存在时会自动创建。同步为增量:已入库的视频自动跳过。
+ 91 短剧类型:填写源站账号密码,自动登录并按源站分类拉取短剧列表与全部集数,入库为「系列 + 剧集」,仅保存封面地址与播放代理地址。
+ + + + + +