Files
mini_tp/app/api/service/PublishPlanService.php
T
nepiedg 044586d60a feat(publish-plan): add publish plan query and API routes
- Introduced `buildPublishPlanQuery` method in `DyVideoCron` model to create a base query for the publish plan module, filtering records based on user ID and project status.
- Added new API routes under `v1/publish-plan` for listing, starting, and stopping publish plans, requiring user authentication.
2026-04-02 09:00:48 +00:00

714 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\DyVideoCron;
use app\api\model\DyVideoUser;
use app\api\model\Member;
use think\Collection;
use think\db\Query;
use think\facade\Db;
/**
* 发布计划服务。
*
* 本服务按 acgpmw 发布计划模块最小可用范围实现:
* 1. 普通发布计划:`controller/cron.php::cron_list()`
* 2. AI 项目计划:`controller/ai_project_cron.php::cron_list()`
* 3. 启停动作:`controller/cron.php::fabu()` / `controller/cron.php::stop()`
*
* 当前不擅自补齐删除、编辑等复杂动作,避免偏离原始程序。
*/
class PublishPlanService
{
/**
* 平台中文名直接按基线常用映射返回给前端,避免小程序自行猜测。
*/
private const PLATFORM_NAME_MAP = [
0 => '抖音',
1 => '快手',
2 => '百家号',
3 => '小红书',
4 => '视频号',
5 => 'B站',
6 => '公众号',
10 => 'TikTok',
];
/**
* 获取发布计划列表。
*
* @param int $userid 当前登录用户ID
* @param array<string, mixed> $params 前端筛选参数
* @return array<string, mixed>
* @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<string, int>
* @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<string, int>
* @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<int, DyVideoCron> $records 当前页记录
* @return array<int, string>
*/
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<int, string> $projectMap */
$projectMap = Db::connect('dbmember')
->name('dy_ai_project')
->where('userid', $userid)
->whereIn('id', $projectIds)
->column('name', 'id');
return $projectMap;
}
/**
* 加载当前页计划中用到的账号信息。
*
* @param Collection<int, DyVideoCron> $records 当前页记录
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $record 原始记录
* @param array<int, string> $projectNameMap 项目名称映射
* @param array<int, array<string, mixed>> $accountInfoMap 账号信息映射
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, int>
*/
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<string, string>
*/
private function getStatusFilterMap(): array
{
return [
'all' => '全部',
'running' => '进行中',
'stopped' => '已停止',
'finished' => '已完成',
];
}
/**
* 构建计划状态块。
*
* @param int $status 原始状态码
* @param int $jrstop 今日暂停标记
* @return array<string, mixed>
*/
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<string, mixed> $statusInfo 状态块
* @return array<string, string>|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<string, mixed> $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<string, mixed> $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<int, string> $publishAccounts 发布账号名
* @param array<int, string> $atAccounts @账号名
* @param array<int, string> $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<int> $materialIds 素材ID集合
* @param array<int> $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<int>
*/
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<int> $accountIds 账号ID集合
* @param array<int, array<string, mixed>> $accountInfoMap 账号信息映射
* @return array<int, string>
*/
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);
}
}