'抖音', 1 => '快手', 2 => '百家号', 3 => '小红书', 4 => '视频号', 5 => 'B站', 6 => '公众号', 10 => 'TikTok', ]; /** * 获取发布计划列表。 * * @param int $userid 当前登录用户ID * @param array $params 前端筛选参数 * @return array * @throws \Exception */ public function getPlanList(int $userid, array $params = []): array { $member = Member::findByUserid($userid); if (!$member) { throw new \Exception('用户不存在', 4004); } $statusFilter = $this->normalizeStatusFilter((string) ($params['status'] ?? 'all')); $page = max(1, (int) ($params['page'] ?? 1)); $pageSize = min(50, max(1, (int) ($params['page_size'] ?? 20))); // 基础查询范围严格按 acgpmw 发布计划模块两个列表控制器对齐。 $baseQuery = DyVideoCron::buildPublishPlanQuery($userid); $total = (int) (clone $this->applyStatusFilter(clone $baseQuery, $statusFilter))->count(); $records = $this->applyStatusFilter(clone $baseQuery, $statusFilter) ->field([ 'id', 'userid', 'name', 'status', 'jrstop', 'project_id', 'project_type', 'platform', 'fbvuids', 'atvuids', 'fbvuids_a', 'time_range', 'starttime', 'endtime', 'meirinum', 'plnum', 'yifanum', 'suc_fbnum', 'err_fbnum', 'fbdatetime', 'addtime', 'tplids', 'videotemplates', ]) ->order(['id' => 'desc']) ->page($page, $pageSize) ->select(); $projectNameMap = $this->loadProjectNames($userid, $records); $accountInfoMap = $this->loadAccountInfos($records); $items = []; foreach ($records as $record) { $items[] = $this->buildPlanItem($record->toArray(), $projectNameMap, $accountInfoMap); } return [ 'filters' => $this->buildFilters($baseQuery, $statusFilter), 'summary' => $this->buildSummary($baseQuery), 'pagination' => [ 'page' => $page, 'page_size' => $pageSize, 'total' => $total, 'has_more' => $page * $pageSize < $total, ], 'list' => $items, ]; } /** * 开启发布计划。 * * 此处按 acgpmw `cron::fabu()` 对齐,只恢复 `jrstop=0`。 * * @param int $userid 当前登录用户ID * @param int $id 计划ID * @return array * @throws \Exception */ public function startPlan(int $userid, int $id): array { $plan = $this->getOwnedPlan($userid, $id); if ((int) $plan->status === 3) { throw new \Exception('已完成计划不支持继续开启', 4004); } $affected = DyVideoCron::where('id', $id) ->where('userid', $userid) ->update(['jrstop' => 0]); if (!$affected) { throw new \Exception('设置失败', 500); } return ['id' => $id, 'jrstop' => 0]; } /** * 暂停发布计划。 * * 此处按 acgpmw `cron::stop()` 对齐,除设置 `jrstop=1` 外, * 还尝试把 `dy_cron_account` 中当前待执行记录改成状态 5。 * * @param int $userid 当前登录用户ID * @param int $id 计划ID * @return array * @throws \Exception */ public function stopPlan(int $userid, int $id): array { $plan = $this->getOwnedPlan($userid, $id); if ((int) $plan->status === 3) { throw new \Exception('已完成计划不支持暂停', 4004); } $affected = DyVideoCron::where('id', $id) ->where('userid', $userid) ->update(['jrstop' => 1]); if (!$affected) { throw new \Exception('设置失败', 500); } // 按 acgpmw `cron::stop()` 对齐处理新模式账号执行队列。 Db::connect('dbmember') ->name('dy_cron_account') ->where('userid', $userid) ->where('cron_id', $id) ->where('status', 0) ->update(['status' => 5]); return ['id' => $id, 'jrstop' => 1]; } /** * 加载当前页涉及到的项目名称。 * * AI 项目计划列表在 acgpmw 中会把 `project_id` 映射成项目名, * 这里同样在服务端完成,减少小程序字段推断。 * * @param int $userid 当前登录用户ID * @param Collection $records 当前页记录 * @return array */ private function loadProjectNames(int $userid, Collection $records): array { $projectIds = []; foreach ($records as $record) { $projectId = (int) $record->project_id; if ($projectId > 0) { $projectIds[] = $projectId; } } $projectIds = array_values(array_unique($projectIds)); if (empty($projectIds)) { return []; } /** @var array $projectMap */ $projectMap = Db::connect('dbmember') ->name('dy_ai_project') ->where('userid', $userid) ->whereIn('id', $projectIds) ->column('name', 'id'); return $projectMap; } /** * 加载当前页计划中用到的账号信息。 * * @param Collection $records 当前页记录 * @return array> */ private function loadAccountInfos(Collection $records): array { $accountIds = []; foreach ($records as $record) { $accountIds = array_merge( $accountIds, $this->decodeIdList($record->fbvuids), $this->decodeIdList($record->atvuids), $this->decodeIdList($record->fbvuids_a) ); } $accountIds = array_values(array_unique(array_filter(array_map('intval', $accountIds)))); if (empty($accountIds)) { return []; } $accounts = DyVideoUser::whereIn('id', $accountIds) ->field(['id', 'platform', 'dy_nickname']) ->select() ->toArray(); $accountMap = []; foreach ($accounts as $account) { $accountMap[(int) $account['id']] = $account; } return $accountMap; } /** * 把单条计划记录转换成小程序卡片数据。 * * 字段映射依据: * - 发布进度:`plnum / yifanum / suc_fbnum / err_fbnum` * - 账号集合:`fbvuids / atvuids / fbvuids_a` * - 时间展示:`time_range / starttime / endtime / fbdatetime` * - 视频信息:`tplids / videotemplates` * * @param array $record 原始记录 * @param array $projectNameMap 项目名称映射 * @param array> $accountInfoMap 账号信息映射 * @return array */ private function buildPlanItem(array $record, array $projectNameMap, array $accountInfoMap): array { $publishAccountIds = $this->decodeIdList($record['fbvuids'] ?? null); $atAccountIds = $this->decodeIdList($record['atvuids'] ?? null); $syncAccountIds = $this->decodeIdList($record['fbvuids_a'] ?? null); $materialIds = $this->decodeIdList($record['tplids'] ?? null); $videoTemplateIds = $this->decodeIdList($record['videotemplates'] ?? null); $publishAccounts = $this->resolveAccountNames($publishAccountIds, $accountInfoMap); $atAccounts = $this->resolveAccountNames($atAccountIds, $accountInfoMap); $syncAccounts = $this->resolveAccountNames($syncAccountIds, $accountInfoMap); $statusInfo = $this->buildStatusBlock( (int) ($record['status'] ?? 0), (int) ($record['jrstop'] ?? 0) ); $actionInfo = $this->buildActionBlock($statusInfo); $projectId = (int) ($record['project_id'] ?? 0); $projectType = (int) ($record['project_type'] ?? 0); $platform = (int) ($record['platform'] ?? 0); return [ 'id' => (int) ($record['id'] ?? 0), 'name' => (string) ($record['name'] ?? ''), 'plan_type' => $projectId > 0 ? 'ai_project' : 'normal', 'plan_type_text' => $projectId > 0 ? 'AI 项目计划' : '发布任务', 'project_id' => $projectId, 'project_type' => $projectType, 'project_name' => $projectId > 0 ? ($projectNameMap[$projectId] ?? '无项目(异常)') : '', 'platform' => $platform, 'platform_name' => $projectType === 1 ? '作品下载' : (self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform)), 'status' => $statusInfo, 'action' => $actionInfo, 'publish_time_text' => $this->buildPublishTimeText($record), 'schedule_text' => $this->buildScheduleText($record), 'created_at_text' => $this->formatDateTime((int) ($record['addtime'] ?? 0)), 'metrics' => [ [ 'key' => 'progress', 'label' => '已发 / 总量', 'value' => (int) ($record['yifanum'] ?? 0) . ' / ' . (int) ($record['plnum'] ?? 0), 'tone' => 'blue', ], [ 'key' => 'result', 'label' => '成功 / 失败', 'value' => (int) ($record['suc_fbnum'] ?? 0) . ' / ' . (int) ($record['err_fbnum'] ?? 0), 'tone' => 'green', ], [ 'key' => 'accounts', 'label' => '关联账号', 'value' => (string) (count($publishAccountIds) + count($atAccountIds) + count($syncAccountIds)), 'tone' => 'amber', ], ], 'account_summary_text' => $this->buildAccountSummaryText($publishAccounts, $atAccounts, $syncAccounts), 'video_info_text' => $this->buildVideoInfoText($materialIds, $videoTemplateIds), ]; } /** * 构建状态筛选项。 * * 这里把小程序筛选态映射为更稳定的业务语义: * - `running`:`status != 3 and jrstop = 0` * - `stopped`:`status != 3 and jrstop = 1` * - `finished`:`status = 3` * * @param Query $baseQuery 发布计划基础查询 * @param string $currentStatus 当前筛选值 * @return array */ private function buildFilters(Query $baseQuery, string $currentStatus): array { $items = []; foreach ($this->getStatusFilterMap() as $key => $label) { $count = (int) (clone $this->applyStatusFilter(clone $baseQuery, $key))->count(); $items[] = [ 'key' => $key, 'name' => $label, 'count' => $count, ]; } return [ 'current_status' => $currentStatus, 'items' => $items, ]; } /** * 构建顶部统计卡片。 * * @param Query $baseQuery 发布计划基础查询 * @return array */ private function buildSummary(Query $baseQuery): array { return [ 'total' => (int) (clone $baseQuery)->count(), 'running_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'running'))->count(), 'stopped_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'stopped'))->count(), 'finished_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'finished'))->count(), ]; } /** * 应用状态筛选。 * * @param Query $query 查询对象 * @param string $statusFilter 筛选值 * @return Query */ private function applyStatusFilter(Query $query, string $statusFilter): Query { switch ($statusFilter) { case 'running': $query->where('status', '<>', 3)->where('jrstop', 0); break; case 'stopped': $query->where('status', '<>', 3)->where('jrstop', 1); break; case 'finished': $query->where('status', 3); break; case 'all': default: break; } return $query; } /** * 规范化状态筛选参数。 * * @param string $statusFilter 前端传入的筛选值 * @return string * @throws \Exception */ private function normalizeStatusFilter(string $statusFilter): string { $statusFilter = $statusFilter !== '' ? $statusFilter : 'all'; if (!array_key_exists($statusFilter, $this->getStatusFilterMap())) { throw new \Exception('状态筛选参数错误', 400); } return $statusFilter; } /** * 获取筛选映射。 * * @return array */ private function getStatusFilterMap(): array { return [ 'all' => '全部', 'running' => '进行中', 'stopped' => '已停止', 'finished' => '已完成', ]; } /** * 构建计划状态块。 * * @param int $status 原始状态码 * @param int $jrstop 今日暂停标记 * @return array */ private function buildStatusBlock(int $status, int $jrstop): array { if ($status === 3) { return [ 'key' => 'finished', 'text' => '已完成', 'tone' => 'success', 'raw_status' => $status, 'jrstop' => $jrstop, ]; } if ($jrstop === 1) { return [ 'key' => 'stopped', 'text' => '已停止', 'tone' => 'danger', 'raw_status' => $status, 'jrstop' => $jrstop, ]; } $statusTextMap = [ 1 => '发布中', 5 => '定时执行', 9 => '定时执行', 10 => '计划执行中', 20 => '作品生成中', ]; return [ 'key' => 'running', 'text' => $statusTextMap[$status] ?? ('状态 ' . $status), 'tone' => 'primary', 'raw_status' => $status, 'jrstop' => $jrstop, ]; } /** * 根据状态块构建可执行动作。 * * @param array $statusInfo 状态块 * @return array|null */ private function buildActionBlock(array $statusInfo): ?array { if ($statusInfo['key'] === 'finished') { return null; } if ($statusInfo['key'] === 'stopped') { return [ 'type' => 'start', 'text' => '继续计划', 'tone' => 'primary', ]; } return [ 'type' => 'stop', 'text' => '暂停计划', 'tone' => 'danger', ]; } /** * 生成发布时间文案。 * * @param array $record 原始计划记录 * @return string */ private function buildPublishTimeText(array $record): string { $fbdatetime = (int) ($record['fbdatetime'] ?? 0); if ($fbdatetime > 0) { return $this->formatDate($fbdatetime); } return $this->formatDate((int) ($record['addtime'] ?? 0)); } /** * 生成执行时间文案。 * * 普通计划按 `time_range` / `starttime` / `endtime` 对齐展示, * 下载型项目计划没有明确发布时间段时,返回固定文案。 * * @param array $record 原始计划记录 * @return string */ private function buildScheduleText(array $record): string { $projectType = (int) ($record['project_type'] ?? 0); if ($projectType === 1) { return '0点至24点'; } $timeRange = trim((string) ($record['time_range'] ?? '')); if ($timeRange !== '') { $timePoints = array_values(array_filter(array_map('trim', explode(',', $timeRange)), static function ($item) { return $item !== ''; })); if (!empty($timePoints)) { return implode('、', array_map(static function ($item) { return $item . '点'; }, $timePoints)); } } $startTime = (int) ($record['starttime'] ?? 0); $endTime = (int) ($record['endtime'] ?? 0); if ($startTime > 0 || $endTime > 0) { return $startTime . '点至' . $endTime . '点'; } return '未设置'; } /** * 生成账号摘要文案。 * * @param array $publishAccounts 发布账号名 * @param array $atAccounts @账号名 * @param array $syncAccounts 同步账号名 * @return string */ private function buildAccountSummaryText(array $publishAccounts, array $atAccounts, array $syncAccounts): string { $parts = []; if (!empty($publishAccounts)) { $parts[] = '发布 ' . count($publishAccounts) . ' 个'; } if (!empty($atAccounts)) { $parts[] = '@账号 ' . count($atAccounts) . ' 个'; } if (!empty($syncAccounts)) { $parts[] = '同步 ' . count($syncAccounts) . ' 个'; } if (empty($parts)) { return '暂无关联账号'; } return implode(' / ', $parts); } /** * 生成视频信息摘要。 * * @param array $materialIds 素材ID集合 * @param array $videoTemplateIds 视频模板ID集合 * @return string */ private function buildVideoInfoText(array $materialIds, array $videoTemplateIds): string { $parts = []; if (!empty($materialIds)) { $parts[] = '素材 ' . count($materialIds) . ' 个'; } if (!empty($videoTemplateIds)) { $parts[] = '视频模板 ' . count($videoTemplateIds) . ' 个'; } if (empty($parts)) { return '暂无视频信息'; } return implode(' / ', $parts); } /** * 解析序列化 ID 列表。 * * `dy_video_cron` 中多个账号和素材字段都按 PHP serialize 存储, * 这里统一做兼容解析,避免前端处理历史格式。 * * @param mixed $serializedValue 原始字段值 * @return array */ private function decodeIdList($serializedValue): array { if (empty($serializedValue) || !is_string($serializedValue)) { return []; } $decoded = @unserialize($serializedValue); if (!is_array($decoded)) { return []; } return array_values(array_unique(array_filter(array_map('intval', $decoded)))); } /** * 把账号 ID 集合映射为账号名称集合。 * * @param array $accountIds 账号ID集合 * @param array> $accountInfoMap 账号信息映射 * @return array */ private function resolveAccountNames(array $accountIds, array $accountInfoMap): array { $names = []; foreach ($accountIds as $accountId) { if (!isset($accountInfoMap[$accountId])) { continue; } $account = $accountInfoMap[$accountId]; $platformName = self::PLATFORM_NAME_MAP[(int) ($account['platform'] ?? 0)] ?? '平台'; $nickname = trim((string) ($account['dy_nickname'] ?? '')); $names[] = $nickname !== '' ? ($platformName . '·' . $nickname) : ($platformName . '·账号' . $accountId); } return $names; } /** * 获取当前用户拥有的发布计划。 * * @param int $userid 当前登录用户ID * @param int $id 计划ID * @return DyVideoCron * @throws \Exception */ private function getOwnedPlan(int $userid, int $id): DyVideoCron { $plan = DyVideoCron::buildPublishPlanQuery($userid) ->where('id', $id) ->find(); if (!$plan) { throw new \Exception('发布计划不存在', 404); } return $plan; } /** * 格式化日期。 * * @param int $timestamp Unix 时间戳 * @return string */ private function formatDate(int $timestamp): string { if ($timestamp <= 0) { return '未设置'; } return date('Y-m-d', $timestamp); } /** * 格式化日期时间。 * * @param int $timestamp Unix 时间戳 * @return string */ private function formatDateTime(int $timestamp): string { if ($timestamp <= 0) { return '未设置'; } return date('Y-m-d H:i', $timestamp); } }