Files
mini_tp/app/api/service/VideoWorkService.php
T
nepiedg be58aac7e7 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.
2026-04-03 03:48:25 +00:00

663 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}