From be58aac7e7913fd7910841ab955292f5e750774f Mon Sep 17 00:00:00 2001 From: nepiedg <806669289@qq.com.com> Date: Fri, 3 Apr 2026 03:48:25 +0000 Subject: [PATCH] feat(video): add user-specific video query and filter accounts functionality - Introduced `buildUserQuery` method in `DysVideoLog` model to create a base query for user-specific video logs. - Added `getVideoFilterAccountsByUserId` method in `DyVideoUser` model to retrieve active accounts for a specified user. - Updated routing to include new video work API endpoints under `v1/video-work`, requiring user authentication. --- app/api/controller/v1/VideoWork.php | 66 +++ app/api/model/DyVideoUser.php | 24 + app/api/model/DysVideoLog.php | 20 + app/api/service/VideoWorkService.php | 662 +++++++++++++++++++++++++++ route/app.php | 6 + 5 files changed, 778 insertions(+) create mode 100644 app/api/controller/v1/VideoWork.php create mode 100644 app/api/service/VideoWorkService.php diff --git a/app/api/controller/v1/VideoWork.php b/app/api/controller/v1/VideoWork.php new file mode 100644 index 0000000..710cd15 --- /dev/null +++ b/app/api/controller/v1/VideoWork.php @@ -0,0 +1,66 @@ +videoWorkService = new VideoWorkService(); + } + + /** + * 视频作品列表。 + * + * GET /api/v1/video-work/list + * + * 请求参数: + * - `platform`:平台编号,传 `all` 时不过滤 + * - `vuid`:账号ID,传 `all` 时不过滤 + * - `page`:页码 + * - `page_size`:每页数量 + * + * 返回结构: + * - `filters`:平台与账号筛选项 + * - `summary`:顶部统计卡片 + * - `pagination`:分页信息 + * - `list`:作品卡片列表 + */ + public function index() + { + try { + $payload = $this->request->payload ?? null; + if (!$payload || empty($payload['userid'])) { + return Response::error('未登录', 401); + } + + $result = $this->videoWorkService->getVideoList((int) $payload['userid'], [ + 'platform' => $this->request->get('platform', 'all'), + 'vuid' => $this->request->get('vuid', 'all'), + 'page' => (int) $this->request->get('page', 1), + 'page_size' => (int) $this->request->get('page_size', 12), + ]); + + return Response::success($result); + } catch (\Exception $exception) { + return Response::error($exception->getMessage(), $exception->getCode() ?: 500); + } + } +} diff --git a/app/api/model/DyVideoUser.php b/app/api/model/DyVideoUser.php index c55bc5f..9f55e53 100644 --- a/app/api/model/DyVideoUser.php +++ b/app/api/model/DyVideoUser.php @@ -78,4 +78,28 @@ class DyVideoUser extends Model return $query->select(); } + + /** + * 获取视频作品页筛选用的账号列表。 + * + * 对齐 acgpmw `video_info::video_list()`: + * - 仅返回当前用户启用中的账号 + * - 返回平台与昵称,供前端拼出“平台(昵称)#Pid”样式 + * + * @param int $userid 用户ID + * @return Collection + */ + public static function getVideoFilterAccountsByUserId(int $userid): Collection + { + return self::where('userid', $userid) + ->where('disabled', 0) + ->order(['id' => 'desc']) + ->field([ + 'id', + 'platform', + 'dy_nickname', + 'dy_avatar', + ]) + ->select(); + } } diff --git a/app/api/model/DysVideoLog.php b/app/api/model/DysVideoLog.php index ba373e8..ba7b3b3 100644 --- a/app/api/model/DysVideoLog.php +++ b/app/api/model/DysVideoLog.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\api\model; use think\Model; +use think\db\Query; /** * 抖音发布日志模型(对应分表 dys_video_log_{userid % 1000}) @@ -41,6 +42,25 @@ class DysVideoLog extends Model ->count(); } + /** + * 构建当前用户的视频作品基础查询。 + * + * 作品日志按 `dys_video_log_{userid % 1000}` 分表存储, + * 这里统一返回已定位分表且已限定用户范围的查询对象。 + * + * @param int $userid 用户ID + * @return Query + */ + public static function buildUserQuery(int $userid): Query + { + $tableName = self::resolveTableName($userid); + + return (new self()) + ->db() + ->name($tableName) + ->where('userid', $userid); + } + /** * 根据用户ID计算分表名。 * diff --git a/app/api/service/VideoWorkService.php b/app/api/service/VideoWorkService.php new file mode 100644 index 0000000..bc134a5 --- /dev/null +++ b/app/api/service/VideoWorkService.php @@ -0,0 +1,662 @@ + 'D音', + 1 => 'K手', + 2 => 'B家号', + 3 => '小红薯', + 4 => '视P号', + 5 => 'B哩哔哩', + 6 => '公Z号', + 10 => 'TiTk', + ]; + + /** + * 平台英文键按 acgpmw `data/other/platforms_py.php` 对齐。 + */ + private const PLATFORM_KEY_MAP = [ + 0 => 'douyin', + 1 => 'kuaishou', + 2 => 'baijiahao', + 3 => 'xiaohongshu', + 4 => 'shipinhao', + 5 => 'bilibili', + 6 => 'gongzhonghao', + 10 => 'tiktok', + ]; + + /** + * 获取视频作品列表。 + * + * @param int $userid 当前登录用户ID + * @param array $params 前端查询参数 + * @return array + * @throws \Exception + */ + public function getVideoList(int $userid, array $params = []): array + { + $member = Member::findByUserid($userid); + if (!$member) { + throw new \Exception('用户不存在', 4004); + } + + $platform = $this->normalizeOptionalInt($params['platform'] ?? 'all', '平台参数格式错误'); + $vuid = $this->normalizeOptionalInt($params['vuid'] ?? 'all', '账号参数格式错误'); + $page = max(1, (int) ($params['page'] ?? 1)); + $pageSize = min(30, max(1, (int) ($params['page_size'] ?? 12))); + + $productInfo = $member->getProductInfo(); + $availablePlatforms = $this->resolveAvailablePlatforms($member, $productInfo); + if (empty($availablePlatforms)) { + throw new \Exception('暂无视频作品权限', 4004); + } + + if ($platform !== null && !in_array($platform, $availablePlatforms, true)) { + throw new \Exception('当前套餐暂无该平台权限', 4004); + } + + $accountOptions = $this->loadAccountOptions($userid, $availablePlatforms); + if ($vuid !== null && !array_key_exists($vuid, $accountOptions['map'])) { + throw new \Exception('当前账号不存在或已解绑', 4004); + } + + $baseQuery = $this->buildFilteredQuery($userid, [ + 'platform' => $platform, + 'vuid' => $vuid, + ]); + + $total = (int) (clone $baseQuery)->count(); + $records = (clone $baseQuery) + ->field([ + 'id', + 'vuid', + 'cron_id', + 'aweme_id', + 'aweme_nid', + 'dy_item_id', + 'dy_title', + 'kwords', + 'dy_video_status', + 'dy_cover', + 'dy_create_time', + 'dy_share_url', + 'dy_comment_count', + 'dy_digg_count', + 'dy_play_count', + 'addtime', + 'fabu_type', + 'disabled', + 'status', + 'fberrinfo', + 'platform', + 'tplid', + ]) + ->order('id', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + $items = []; + foreach ($records as $record) { + $items[] = $this->buildVideoItem($record, $accountOptions['map']); + } + + return [ + 'filters' => [ + 'current_platform' => $platform, + 'platforms' => $this->buildPlatformFilters($userid, $availablePlatforms, $vuid, $platform), + 'current_vuid' => $vuid, + 'accounts' => $accountOptions['items'], + ], + 'summary' => $this->buildSummary(clone $baseQuery), + 'pagination' => [ + 'page' => $page, + 'page_size' => $pageSize, + 'total' => $total, + 'has_more' => $page * $pageSize < $total, + ], + 'list' => $items, + ]; + } + + /** + * 加载账号筛选项。 + * + * @param int $userid 用户ID + * @param array $availablePlatforms 可见平台 + * @return array{items: array>, map: array>} + */ + private function loadAccountOptions(int $userid, array $availablePlatforms): array + { + $accounts = DyVideoUser::getVideoFilterAccountsByUserId($userid)->toArray(); + + $items = [ + [ + 'id' => null, + 'name' => '全部账号', + ], + ]; + $accountMap = []; + + foreach ($accounts as $account) { + $platform = (int) ($account['platform'] ?? 0); + if (!in_array($platform, $availablePlatforms, true)) { + continue; + } + + $accountId = (int) ($account['id'] ?? 0); + $accountMap[$accountId] = $account; + $items[] = [ + 'id' => $accountId, + 'name' => sprintf( + '%s(%s)#P%d', + self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform), + (string) ($account['dy_nickname'] ?? '未命名账号'), + $accountId + ), + ]; + } + + return [ + 'items' => $items, + 'map' => $accountMap, + ]; + } + + /** + * 构建筛选后的作品查询。 + * + * @param int $userid 用户ID + * @param array $filters 筛选条件 + * @return Query + */ + private function buildFilteredQuery(int $userid, array $filters): Query + { + $query = DysVideoLog::buildUserQuery($userid); + + if (($filters['vuid'] ?? null) !== null) { + $query->where('vuid', (int) $filters['vuid']); + } + + if (($filters['platform'] ?? null) !== null) { + $query->where('platform', (int) $filters['platform']); + } + + return $query; + } + + /** + * 构建平台筛选项。 + * + * 这里按当前账号筛选条件统计数量,使前端切平台时与当前账号范围一致。 + * + * @param int $userid 用户ID + * @param array $availablePlatforms 可见平台 + * @param int|null $vuid 当前账号筛选 + * @param int|null $currentPlatform 当前平台筛选 + * @return array> + */ + private function buildPlatformFilters(int $userid, array $availablePlatforms, ?int $vuid, ?int $currentPlatform): array + { + $tabs = [ + [ + 'id' => null, + 'name' => '全部平台', + 'key' => 'all', + 'count' => 0, + ], + ]; + + $allCount = 0; + foreach ($availablePlatforms as $platform) { + $query = $this->buildFilteredQuery($userid, [ + 'platform' => $platform, + 'vuid' => $vuid, + ]); + $count = (int) $query->count(); + $allCount += $count; + + $tabs[] = [ + 'id' => $platform, + 'name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform), + 'key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform), + 'count' => $count, + 'active' => $currentPlatform === $platform, + ]; + } + + $tabs[0]['count'] = $allCount; + $tabs[0]['active'] = $currentPlatform === null; + + return $tabs; + } + + /** + * 构建顶部统计卡片。 + * + * @param Query $query 已按当前筛选条件收窄的查询对象 + * @return array + */ + private function buildSummary(Query $query): array + { + return [ + 'total' => (int) (clone $query)->count(), + 'published_count' => (int) (clone $query)->whereRaw('(dy_video_status in (1,5) or aweme_id <> "" or dy_item_id <> "" or aweme_nid <> "")')->count(), + 'reviewing_count' => (int) (clone $query)->where('dy_video_status', 4)->count(), + 'failed_count' => (int) (clone $query)->whereIn('status', [2, 3])->count(), + ]; + } + + /** + * 构建前端作品卡片数据。 + * + * @param array $record 原始作品记录 + * @param array> $accountMap 账号信息映射 + * @return array + */ + private function buildVideoItem(array $record, array $accountMap): array + { + $platform = (int) ($record['platform'] ?? 0); + $accountId = (int) ($record['vuid'] ?? 0); + $account = $accountMap[$accountId] ?? []; + $title = $this->buildVideoTitle($record); + $previewUrl = $this->buildPreviewUrl($record); + $statusInfo = $this->buildStatusInfo($record); + $publishTimeText = $this->buildPublishTimeText($record); + + return [ + 'id' => (int) ($record['id'] ?? 0), + 'title' => $title, + 'cover' => (string) ($record['dy_cover'] ?? ''), + 'platform' => $platform, + 'platform_name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform), + 'platform_key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform), + 'account_id' => $accountId, + 'account_name' => (string) ($account['dy_nickname'] ?? '未命名账号'), + 'account_avatar' => (string) ($account['dy_avatar'] ?? ''), + 'cron_id' => (int) ($record['cron_id'] ?? 0), + 'tplid' => (int) ($record['tplid'] ?? 0), + 'play_count' => (int) ($record['dy_play_count'] ?? 0), + 'digg_count' => (int) ($record['dy_digg_count'] ?? 0), + 'comment_count' => (int) ($record['dy_comment_count'] ?? 0), + 'publish_time_text' => $publishTimeText, + 'status' => $statusInfo, + 'preview_url' => $previewUrl, + 'has_preview' => $previewUrl !== '', + 'is_article' => (int) ($record['fabu_type'] ?? 0) === 101, + 'display_code' => '#V' . (int) ($record['id'] ?? 0), + 'task_code' => '#C' . (int) ($record['cron_id'] ?? 0), + 'material_code' => (int) ($record['tplid'] ?? 0) > 0 ? '#M' . (int) $record['tplid'] : '', + ]; + } + + /** + * 组装作品标题。 + * + * 对齐 acgpmw:优先 `dy_title`,否则使用 `kwords` 拼接。 + * + * @param array $record 原始作品记录 + * @return string + */ + private function buildVideoTitle(array $record): string + { + $title = trim((string) ($record['dy_title'] ?? '')); + if ($title !== '') { + return $title; + } + + $keywords = trim((string) ($record['kwords'] ?? ''), ','); + if ($keywords === '') { + return '暂无作品信息'; + } + + return '#' . str_replace(',', ' #', $keywords); + } + + /** + * 构建作品状态块。 + * + * 对齐 acgpmw `get_videos()` 的状态展示优先级: + * 1. 定时中 + * 2. 发布失败 + * 3. 已发布 / 未公开 / 审核中 / 好友可见 / 私密视频 + * 4. 待处理 + * + * @param array $record 原始作品记录 + * @return array + */ + private function buildStatusInfo(array $record): array + { + $platform = (int) ($record['platform'] ?? 0); + $dyCreateTime = (int) ($record['dy_create_time'] ?? 0); + $status = (int) ($record['status'] ?? 0); + $dyVideoStatus = (int) ($record['dy_video_status'] ?? 0); + $isPublished = !empty($record['aweme_id']) || !empty($record['dy_item_id']) || !empty($record['aweme_nid']); + + if (in_array($platform, [0, 4], true) && $dyCreateTime > time()) { + return [ + 'text' => '定时中', + 'tone' => 'warning', + 'hint' => '作品已进入定时发布队列', + ]; + } + + if (in_array($status, [2, 3], true)) { + $errorText = $this->translatePublishError((string) ($record['fberrinfo'] ?? ''), $platform); + return [ + 'text' => '发布失败', + 'tone' => 'danger', + 'hint' => $errorText !== '' ? $errorText : '作品发布失败,请回到原系统查看详情', + ]; + } + + if (in_array($dyVideoStatus, [1, 5], true) || $isPublished) { + return [ + 'text' => '已发布', + 'tone' => 'success', + 'hint' => '作品已成功发布到对应平台', + ]; + } + + if ($dyVideoStatus === 2) { + return [ + 'text' => '未公开', + 'tone' => 'danger', + 'hint' => '作品当前为未公开状态', + ]; + } + + if ($dyVideoStatus === 4) { + return [ + 'text' => '审核中', + 'tone' => 'primary', + 'hint' => '作品已提交平台审核,请稍后查看', + ]; + } + + if ($dyVideoStatus === 6) { + return [ + 'text' => '好友可见', + 'tone' => 'info', + 'hint' => '作品仅好友可见', + ]; + } + + if ($dyVideoStatus === 7) { + return [ + 'text' => '私密视频', + 'tone' => 'warning', + 'hint' => '作品当前为私密视频', + ]; + } + + return [ + 'text' => '待处理', + 'tone' => 'slate', + 'hint' => '作品正在处理或等待平台返回结果', + ]; + } + + /** + * 构建发布时间文案。 + * + * @param array $record 原始作品记录 + * @return string + */ + private function buildPublishTimeText(array $record): string + { + $platform = (int) ($record['platform'] ?? 0); + $dyCreateTime = (int) ($record['dy_create_time'] ?? 0); + $addtime = (int) ($record['addtime'] ?? 0); + + $publishTime = $addtime; + if ($dyCreateTime > 0) { + $publishTime = $dyCreateTime; + } + + if (in_array($platform, [0, 4], true) && $dyCreateTime > time()) { + return '将于 ' . $this->formatDateTime($dyCreateTime) . ' 发布'; + } + + return $this->formatDateTime($publishTime); + } + + /** + * 生成作品预览链接。 + * + * 对齐 acgpmw `get_videos()` 中各平台的链接处理规则。 + * + * @param array $record 原始作品记录 + * @return string + */ + private function buildPreviewUrl(array $record): string + { + $platform = (int) ($record['platform'] ?? 0); + $status = (int) ($record['status'] ?? 0); + $shareUrl = (string) ($record['dy_share_url'] ?? ''); + $awemeId = (string) ($record['aweme_id'] ?? ''); + $dyItemId = (string) ($record['dy_item_id'] ?? ''); + $dyCreateTime = (int) ($record['dy_create_time'] ?? 0); + + if ($platform === 0 && $shareUrl !== '') { + $shareUrl = 'https://www.douyin.com/video/' . $awemeId; + if (strpos((string) ($record['dy_share_url'] ?? ''), 'https://video-cn.snssdk.com/') !== false) { + $shareUrl = (string) ($record['dy_share_url'] ?? ''); + } + if ($dyCreateTime < time() && $awemeId !== '') { + $shareUrl = 'https://www.douyin.com/video/' . $awemeId; + } + } + + if ($platform === 5 && $status === 1 && $dyItemId !== '') { + $shareUrl = 'https://www.bilibili.com/video/' . $dyItemId; + } + + if ($platform === 2 && $status === 1 && $dyItemId !== '') { + $shareUrl = 'https://mbd.baidu.com/newspage/data/videolanding?nid=sv_' . $dyItemId; + } + + if ($platform === 4 && strpos($shareUrl, 'https://weixin.qq.com/sph/') === false) { + $shareUrl = ''; + } + + return $shareUrl; + } + + /** + * 解析发布异常原因。 + * + * 这里直接沿用 acgpmw `video_info::getycinfo()` 的关键信息匹配规则。 + * + * @param string $errorInfo 原始错误信息 + * @param int $platform 平台编号 + * @return string + */ + private function translatePublishError(string $errorInfo, int $platform): string + { + if ($errorInfo === '') { + return ''; + } + + if ($platform === 0) { + $map = [ + '"error_code":10008' => '授权过期', + '用户未登录' => '授权过期', + '上传视频按钮超时' => '视频上传超时', + '寻找描述输入框超时' => '素材或内容处理超时', + '"error_code":2114033,' => '包含不合法文字', + '"status_code":-2158,' => '发布频繁或异常', + '"error_code":2100005,' => '参数不合法', + '"error_code":2190003,' => '授权异常', + '"error_code":2114005,' => '视频投稿功能已封禁[#G]', + '"error_code":2100004,' => '系统繁忙异常', + '"status_code":-20' => '视频投稿功能已封禁[#M]', + '"error_code":2190019,' => '系统发布失败或异常', + '"status_msg":"sms"' => '发布短信验证', + '健康分不足投稿功能受限' => '健康分不足投稿功能受限', + ]; + + return $this->matchErrorText($errorInfo, $map); + } + + if ($platform === 1) { + return $this->matchErrorText($errorInfo, [ + 'ACCESS_DENIED' => '授权过期', + 'user_request_limit' => '发布限制或异常', + 'video_not_uploaded' => '视频上传异常', + ]); + } + + if ($platform === 2) { + return $this->matchErrorText($errorInfo, [ + '"errno":60001009' => '账号内容质量不足,今日还可发布0篇内容', + '"errno":60000005' => '账号状态异常', + '"errno":105011' => '百家号开放平台业务调整,已关闭您的接口使用权限', + ]); + } + + if ($platform === 3) { + return $this->matchErrorText($errorInfo, [ + '当日发布数已达到上限' => '当日发布数已达到上限', + '系统正在升级中' => '系统平台升级维护中', + '发布笔记需要绑定手机' => '发布笔记需要绑定手机号', + '笔记发布失败' => '作品笔记发布失败', + '用户被封号' => '用户被封号', + ]); + } + + if ($platform === 4) { + return $this->matchErrorText($errorInfo, [ + '"errcode":-11217' => '暂未绑定手机号', + '"errcode":-11216' => '完成实名后才可以发表', + 'cookie失效' => '授权过期', + '"errCode":300334' => '授权过期(授权完有其他扫码操作)', + ]); + } + + return ''; + } + + /** + * 按关键字匹配错误提示。 + * + * @param string $errorInfo 原始错误信息 + * @param array $map 关键字到提示文案映射 + * @return string + */ + private function matchErrorText(string $errorInfo, array $map): string + { + foreach ($map as $needle => $message) { + if (strpos($errorInfo, $needle) !== false) { + return $message; + } + } + + return ''; + } + + /** + * 计算当前用户可见平台。 + * + * 对齐 acgpmw: + * 1. 先使用已按 `MemberController` 二次加工后的套餐平台 + * 2. 146/147 且不在特殊名单中,只允许 D音 + * + * @param Member $member 当前登录用户 + * @param array|null $productInfo 套餐信息 + * @return array + */ + private function resolveAvailablePlatforms(Member $member, ?array $productInfo): array + { + $platformText = trim((string) ($productInfo['platforms'] ?? ''), ','); + if ($platformText === '') { + return []; + } + + $platforms = array_values(array_filter(array_map('intval', explode(',', $platformText)), static function ($item) { + return array_key_exists($item, self::PLATFORM_NAME_MAP); + })); + + $specialAccounts = $this->loadSpecialAccounts(); + $isRestrictedVType = in_array((int) $member->v_type, [146, 147], true); + $isSpecialAccount = in_array((int) $member->userid, $specialAccounts, true); + + if ($isRestrictedVType && !$isSpecialAccount) { + return [0]; + } + + return $platforms; + } + + /** + * 加载云图文特殊账号名单。 + * + * @return array + */ + private function loadSpecialAccounts(): array + { + $file = root_path() . '../acgpmw/data/other/tw_special_account.php'; + if (!is_file($file)) { + return []; + } + + $accounts = require $file; + + return is_array($accounts) ? array_values(array_map('intval', $accounts)) : []; + } + + /** + * 规范化可选整数参数。 + * + * @param mixed $value 原始输入 + * @param string $errorMessage 错误提示 + * @return int|null + * @throws \Exception + */ + private function normalizeOptionalInt($value, string $errorMessage): ?int + { + if ($value === null || $value === '' || $value === 'all') { + return null; + } + + if (!is_numeric((string) $value)) { + throw new \Exception($errorMessage, 400); + } + + return (int) $value; + } + + /** + * 统一格式化时间。 + * + * @param int $timestamp 时间戳 + * @return string + */ + private function formatDateTime(int $timestamp): string + { + if ($timestamp <= 0) { + return '--'; + } + + return date('Y-m-d H:i', $timestamp); + } +} diff --git a/route/app.php b/route/app.php index 6dfcd97..7a76990 100644 --- a/route/app.php +++ b/route/app.php @@ -5,6 +5,7 @@ use think\facade\Route; use app\api\controller\v1\Auth; use app\api\controller\v1\Platform; use app\api\controller\v1\PublishPlan; +use app\api\controller\v1\VideoWork; /** * 全局路由入口。 @@ -39,3 +40,8 @@ Route::group('api/v1/publish-plan', function () { Route::post('start/:id', [PublishPlan::class, 'start']); Route::post('stop/:id', [PublishPlan::class, 'stop']); })->middleware(\app\api\middleware\Auth::class); + +// v1 视频作品接口(需登录) +Route::group('api/v1/video-work', function () { + Route::get('list', [VideoWork::class, 'index']); +})->middleware(\app\api\middleware\Auth::class);