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.
This commit is contained in:
nepiedg
2026-04-03 03:48:25 +00:00
parent 5a9d6090f3
commit be58aac7e7
5 changed files with 778 additions and 0 deletions
+662
View File
@@ -0,0 +1,662 @@
<?php
declare(strict_types=1);
namespace app\api\service;
use app\api\model\DysVideoLog;
use app\api\model\DyVideoUser;
use app\api\model\Member;
use think\db\Query;
/**
* 视频作品服务。
*
* 该服务按 acgpmw `video_info.php` 的 `video_list()` / `get_videos()`
* 最小可用范围对齐,为小程序提供视频作品列表的只读展示能力。
*/
class VideoWorkService
{
/**
* 平台中文名按 acgpmw `data/other/platforms.php` 对齐。
*/
private const PLATFORM_NAME_MAP = [
0 => '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<string, mixed> $params 前端查询参数
* @return array<string, mixed>
* @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<int> $availablePlatforms 可见平台
* @return array{items: array<int, array<string, mixed>>, map: array<int, array<string, mixed>>}
*/
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<string, int|null> $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<int> $availablePlatforms 可见平台
* @param int|null $vuid 当前账号筛选
* @param int|null $currentPlatform 当前平台筛选
* @return array<int, array<string, mixed>>
*/
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<string, int>
*/
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<string, mixed> $record 原始作品记录
* @param array<int, array<string, mixed>> $accountMap 账号信息映射
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $record 原始作品记录
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, string> $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<string, mixed>|null $productInfo 套餐信息
* @return array<int>
*/
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<int>
*/
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);
}
}