481 lines
19 KiB
PHP
481 lines
19 KiB
PHP
<?php
|
||
|
||
namespace App\Service;
|
||
|
||
use App\BaseApp\BaseService;
|
||
use App\Enum\WechatArticleStatusEnum;
|
||
use App\Enum\WechatVersionActionEnum;
|
||
use App\Models\AdminModel;
|
||
use App\Models\wechat\WechatAccountModel;
|
||
use App\Models\wechat\WechatArticleModel;
|
||
use App\Models\wechat\WechatArticleVersionModel;
|
||
use App\Service\common\wechat\WechatApiClient;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* 公众号图文服务:CRUD(每次操作落版本流水)、版本回退、一键发布与发布状态回填
|
||
*
|
||
* 版本机制:创建=v1;每次修改/回退 version_no+1;删除/发布不递增版本号但同样落流水,
|
||
* 快照存全量排版要素(标题/摘要/正文MD/主题/主色),回退即用快照覆盖回文章
|
||
*/
|
||
class WechatArticleService extends BaseService
|
||
{
|
||
/**
|
||
* 文章表允许写入的排版要素字段(create/update 白名单,防止前端塞非法字段)
|
||
*/
|
||
private const EDITABLE_FIELDS = ['account_id', 'title', 'author', 'digest', 'cover_url', 'content_md', 'theme_key', 'theme_color'];
|
||
|
||
public function __construct()
|
||
{
|
||
parent::__construct();
|
||
$this->model = WechatArticleModel::class;
|
||
// 列表不查 content_md:正文可能几百 KB,分页列表带上会拖慢接口
|
||
$this->selectField = [
|
||
'id', 'account_id', 'title', 'author', 'digest', 'cover_url', 'theme_key', 'theme_color',
|
||
'status', 'version_no', 'article_url', 'publish_error', 'created_by', 'updated_by', 'created_at', 'updated_at',
|
||
];
|
||
$this->queryField = [
|
||
'title' => 'like',
|
||
'status' => '=',
|
||
'account_id' => '=',
|
||
];
|
||
$this->orderBy = [
|
||
'name' => 'id',
|
||
'sort' => 'desc',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 分页列表,补账号名与操作人昵称
|
||
*/
|
||
public function list(): array
|
||
{
|
||
$result = $this->getPageList();
|
||
$result['items'] = $this->enrichRows($result['items'] ?? []);
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 详情(含 content_md 全文,编辑器回显用)
|
||
*/
|
||
public function detail($id): mixed
|
||
{
|
||
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
|
||
if (empty($info)) {
|
||
return $this->utils->notFound('文章不存在');
|
||
}
|
||
$rows = $this->enrichRows([$info->toArray()]);
|
||
return $rows[0];
|
||
}
|
||
|
||
/**
|
||
* 新增文章:写入 v1 并落「创建」版本流水
|
||
*/
|
||
public function create($params): mixed
|
||
{
|
||
$data = $this->pickEditable($params);
|
||
if (trim((string) ($data['title'] ?? '')) === '') {
|
||
$this->utils->errorThrow('请填写文章标题');
|
||
}
|
||
$now = time();
|
||
$data['status'] = WechatArticleStatusEnum::DRAFT->value;
|
||
$data['version_no'] = 1;
|
||
$data['created_by'] = $this->userId;
|
||
$data['updated_by'] = $this->userId;
|
||
$data['created_at'] = $now;
|
||
$data['updated_at'] = 0;
|
||
$data['deleted_at'] = 0;
|
||
DB::beginTransaction();
|
||
try {
|
||
$id = WechatArticleModel::insertGetId($data);
|
||
$this->writeVersion(array_merge($data, ['id' => $id]), WechatVersionActionEnum::CREATE);
|
||
DB::commit();
|
||
} catch (\Exception $e) {
|
||
DB::rollBack();
|
||
$this->utils->errorThrow($e->getMessage());
|
||
}
|
||
return ['id' => $id];
|
||
}
|
||
|
||
/**
|
||
* 编辑文章:版本号 +1 并落「修改」版本流水(快照为修改后的内容)
|
||
*/
|
||
public function update($id, $params): mixed
|
||
{
|
||
$id = (int) $id;
|
||
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
|
||
if (empty($info)) {
|
||
return $this->utils->notFound('文章不存在');
|
||
}
|
||
$data = $this->pickEditable($params);
|
||
if (array_key_exists('title', $data) && trim((string) $data['title']) === '') {
|
||
$this->utils->errorThrow('文章标题不能为空');
|
||
}
|
||
if (empty($data)) {
|
||
return true;
|
||
}
|
||
$data['version_no'] = (int) $info->version_no + 1;
|
||
$data['updated_by'] = $this->userId;
|
||
$data['updated_at'] = time();
|
||
DB::beginTransaction();
|
||
try {
|
||
WechatArticleModel::where('id', $id)->update($data);
|
||
// 快照 = 旧数据叠加本次变更后的最终态
|
||
$this->writeVersion(array_merge($info->toArray(), $data, ['id' => $id]), WechatVersionActionEnum::UPDATE);
|
||
DB::commit();
|
||
} catch (\Exception $e) {
|
||
DB::rollBack();
|
||
$this->utils->errorThrow($e->getMessage());
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 删除文章(软删):每篇都落「删除」版本流水,保留删除前快照可追溯
|
||
*/
|
||
public function delete($id): mixed
|
||
{
|
||
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
|
||
if (empty($ids)) {
|
||
$this->utils->errorThrow('参数错误');
|
||
}
|
||
$rows = WechatArticleModel::whereIn('id', $ids)->where('deleted_at', 0)->get();
|
||
if ($rows->isEmpty()) {
|
||
return $this->utils->notFound('文章不存在');
|
||
}
|
||
DB::beginTransaction();
|
||
try {
|
||
WechatArticleModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
|
||
'deleted_at' => time(),
|
||
'updated_at' => time(),
|
||
]);
|
||
foreach ($rows as $row) {
|
||
$this->writeVersion($row->toArray(), WechatVersionActionEnum::DELETE, '删除前快照');
|
||
}
|
||
DB::commit();
|
||
} catch (\Exception $e) {
|
||
DB::rollBack();
|
||
$this->utils->errorThrow($e->getMessage());
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 版本流水分页(不带正文快照,抽屉列表用;查看单版本正文走 versionDetail)
|
||
*/
|
||
public function versionList(int $articleId): array
|
||
{
|
||
if ($articleId <= 0) {
|
||
$this->utils->errorThrow('参数错误');
|
||
}
|
||
$result = WechatArticleVersionModel::where('article_id', $articleId)
|
||
->orderByDesc('id')
|
||
->select(['id', 'article_id', 'version_no', 'action', 'title', 'digest', 'theme_key', 'theme_color', 'operator_id', 'operator_name', 'remark', 'created_at'])
|
||
->paginate(request()->get('pageSize', 20))
|
||
->toArray();
|
||
$items = $result['data'] ?? [];
|
||
foreach ($items as &$item) {
|
||
$item['action_text'] = WechatVersionActionEnum::tryFrom((int) $item['action'])?->description() ?? '未知';
|
||
}
|
||
unset($item);
|
||
return [
|
||
'page' => $result['current_page'],
|
||
'size' => $result['per_page'],
|
||
'page_count' => $result['last_page'],
|
||
'total' => $result['total'],
|
||
'items' => $items,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 单版本详情(含正文 MD 快照,回退前预览用)
|
||
*/
|
||
public function versionDetail(int $id): array
|
||
{
|
||
$info = WechatArticleVersionModel::where('id', $id)->first();
|
||
if (empty($info)) {
|
||
$this->utils->notFound('版本不存在');
|
||
}
|
||
$row = $info->toArray();
|
||
$row['action_text'] = WechatVersionActionEnum::tryFrom((int) $row['action'])?->description() ?? '未知';
|
||
return $row;
|
||
}
|
||
|
||
/**
|
||
* 版本回退:用指定版本快照覆盖文章,版本号 +1 并落「回退」流水
|
||
* 为什么回退也是新版本:保证 version_no 单调递增,历史不被改写,可再次回退回去
|
||
*/
|
||
public function rollback(array $params): array
|
||
{
|
||
$id = (int) ($params['id'] ?? 0);
|
||
$versionId = (int) ($params['version_id'] ?? 0);
|
||
if ($id <= 0 || $versionId <= 0) {
|
||
$this->utils->errorThrow('参数错误');
|
||
}
|
||
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
|
||
if (empty($info)) {
|
||
$this->utils->notFound('文章不存在');
|
||
}
|
||
$version = WechatArticleVersionModel::where('id', $versionId)->where('article_id', $id)->first();
|
||
if (empty($version)) {
|
||
$this->utils->notFound('版本不存在或不属于该文章');
|
||
}
|
||
$newVersionNo = (int) $info->version_no + 1;
|
||
$data = [
|
||
'title' => (string) $version->title,
|
||
'digest' => (string) $version->digest,
|
||
'content_md' => $version->content_md,
|
||
'theme_key' => (string) $version->theme_key,
|
||
'theme_color' => (string) $version->theme_color,
|
||
'version_no' => $newVersionNo,
|
||
'updated_by' => $this->userId,
|
||
'updated_at' => time(),
|
||
];
|
||
DB::beginTransaction();
|
||
try {
|
||
WechatArticleModel::where('id', $id)->update($data);
|
||
$this->writeVersion(
|
||
array_merge($info->toArray(), $data, ['id' => $id]),
|
||
WechatVersionActionEnum::ROLLBACK,
|
||
'回退自 v' . (int) $version->version_no
|
||
);
|
||
DB::commit();
|
||
} catch (\Exception $e) {
|
||
DB::rollBack();
|
||
$this->utils->errorThrow($e->getMessage());
|
||
}
|
||
return ['id' => $id, 'version_no' => $newVersionNo];
|
||
}
|
||
|
||
/**
|
||
* 一键发布:搬图 → 封面素材 → 草稿 → freepublish 提交,置「发布中」并落「发布」流水
|
||
*
|
||
* 入参 content_html 是前端主题引擎渲染好的内联样式 HTML(公众号编辑器只认内联样式),
|
||
* 后端不重复渲染 Markdown,避免两端渲染器排版不一致
|
||
*/
|
||
public function publish(array $params): array
|
||
{
|
||
$id = (int) ($params['id'] ?? 0);
|
||
$contentHtml = (string) ($params['content_html'] ?? '');
|
||
if ($id <= 0 || trim($contentHtml) === '') {
|
||
$this->utils->errorThrow('参数错误:缺少文章 ID 或渲染后的正文');
|
||
}
|
||
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
|
||
if (empty($info)) {
|
||
$this->utils->notFound('文章不存在');
|
||
}
|
||
if (trim((string) $info->cover_url) === '') {
|
||
$this->utils->errorThrow('请先设置封面图(发布需要上传封面素材)');
|
||
}
|
||
// 指定账号 > 文章绑定账号 > 默认账号
|
||
$accountId = (int) ($params['account_id'] ?? 0);
|
||
if ($accountId <= 0) {
|
||
$accountId = (int) $info->account_id;
|
||
}
|
||
$account = WechatAccountService::getInstance()->getPublishAccount($accountId);
|
||
$client = WechatApiClient::getInstance();
|
||
$token = $client->getStableToken($account['appid'], $account['secret']);
|
||
// 1. 正文外链图搬微信图床(公众号正文只显示微信域名图片)
|
||
$contentHtml = $this->migrateContentImages($client, $token, $contentHtml);
|
||
// 2. 封面传永久素材拿 thumb_media_id
|
||
$thumbMediaId = $client->addImageMaterial($token, (string) $info->cover_url);
|
||
// 3. 建草稿
|
||
$mediaId = $client->addDraft($token, [
|
||
'title' => mb_substr((string) $info->title, 0, 64),
|
||
'author' => (string) $info->author,
|
||
'digest' => mb_substr((string) $info->digest, 0, 120),
|
||
'content' => $contentHtml,
|
||
'content_source_url' => '',
|
||
'thumb_media_id' => $thumbMediaId,
|
||
'need_open_comment' => 0,
|
||
'only_fans_can_comment' => 0,
|
||
]);
|
||
// 4. 提交发布(异步审核)
|
||
$publishId = $client->submitPublish($token, $mediaId);
|
||
$now = time();
|
||
DB::beginTransaction();
|
||
try {
|
||
WechatArticleModel::where('id', $id)->update([
|
||
'status' => WechatArticleStatusEnum::PUBLISHING->value,
|
||
'account_id' => $account['id'],
|
||
'media_id' => $mediaId,
|
||
'publish_id' => $publishId,
|
||
'publish_error' => '',
|
||
'updated_by' => $this->userId,
|
||
'updated_at' => $now,
|
||
]);
|
||
$this->writeVersion(
|
||
array_merge($info->toArray(), ['id' => $id]),
|
||
WechatVersionActionEnum::PUBLISH,
|
||
'发布到「' . $account['name'] . '」'
|
||
);
|
||
DB::commit();
|
||
} catch (\Exception $e) {
|
||
DB::rollBack();
|
||
$this->utils->errorThrow($e->getMessage());
|
||
}
|
||
return [
|
||
'id' => $id,
|
||
'publish_id' => $publishId,
|
||
'status' => WechatArticleStatusEnum::PUBLISHING->value,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 查询发布状态并回填:0成功→已发布+文章链接;1审核中;其余→发布失败+原因
|
||
*/
|
||
public function publishStatus(int $id): array
|
||
{
|
||
if ($id <= 0) {
|
||
$this->utils->errorThrow('参数错误');
|
||
}
|
||
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
|
||
if (empty($info)) {
|
||
$this->utils->notFound('文章不存在');
|
||
}
|
||
if (trim((string) $info->publish_id) === '') {
|
||
$this->utils->errorThrow('该文章尚未提交发布');
|
||
}
|
||
$account = WechatAccountService::getInstance()->getPublishAccount((int) $info->account_id);
|
||
$client = WechatApiClient::getInstance();
|
||
$token = $client->getStableToken($account['appid'], $account['secret']);
|
||
$body = $client->getPublishStatus($token, (string) $info->publish_id);
|
||
$publishStatus = (int) ($body['publish_status'] ?? -1);
|
||
$articleUrl = '';
|
||
// 成功时从 article_detail 里取第一篇的线上链接
|
||
if ($publishStatus === 0) {
|
||
$items = $body['article_detail']['item'] ?? [];
|
||
$articleUrl = (string) ($items[0]['article_url'] ?? '');
|
||
}
|
||
[$status, $failReason] = match (true) {
|
||
$publishStatus === 0 => [WechatArticleStatusEnum::PUBLISHED->value, ''],
|
||
$publishStatus === 1 => [WechatArticleStatusEnum::PUBLISHING->value, ''],
|
||
default => [WechatArticleStatusEnum::FAILED->value, $this->failReason($publishStatus, $body)],
|
||
};
|
||
$data = [
|
||
'status' => $status,
|
||
'publish_error' => $failReason,
|
||
'updated_at' => time(),
|
||
];
|
||
if ($articleUrl !== '') {
|
||
$data['article_url'] = $articleUrl;
|
||
}
|
||
WechatArticleModel::where('id', $id)->update($data);
|
||
return [
|
||
'id' => $id,
|
||
'status' => $status,
|
||
'status_text' => WechatArticleStatusEnum::tryFrom($status)?->description() ?? '',
|
||
'publish_status' => $publishStatus,
|
||
'article_url' => $articleUrl !== '' ? $articleUrl : (string) $info->article_url,
|
||
'fail_reason' => $failReason,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 微信 publish_status 失败码转人话(fail_idx 标记第几篇出问题,单篇场景直接拼说明)
|
||
*/
|
||
private function failReason(int $publishStatus, array $body): string
|
||
{
|
||
$text = match ($publishStatus) {
|
||
2 => '原创声明审核不通过',
|
||
3 => '常规失败(内容可能违规或素材失效)',
|
||
4 => '平台审核不通过',
|
||
5 => '发布成功后用户删除了所有文章',
|
||
6 => '发布成功后系统封禁了所有文章',
|
||
default => '未知失败(publish_status=' . $publishStatus . ')',
|
||
};
|
||
$failIdx = $body['fail_idx'] ?? [];
|
||
if (!empty($failIdx)) {
|
||
$text .= ',失败篇目序号:' . implode(',', array_map('intval', (array) $failIdx));
|
||
}
|
||
return $text;
|
||
}
|
||
|
||
/**
|
||
* 正文外链图搬家:提取 <img src>,非微信域名的下载后传图床并替换
|
||
* 为什么跳过 mmbiz 域名与 data URI:微信自家图床无需搬;base64 内联图公众号不支持,直接提示
|
||
*/
|
||
private function migrateContentImages(WechatApiClient $client, string $token, string $html): string
|
||
{
|
||
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $html, $matches);
|
||
$urls = array_values(array_unique($matches[1] ?? []));
|
||
foreach ($urls as $url) {
|
||
if (str_contains($url, 'mmbiz.qpic.cn') || str_contains($url, 'mmbiz.qlogo.cn')) {
|
||
continue;
|
||
}
|
||
if (str_starts_with($url, 'data:')) {
|
||
$this->utils->errorThrow('正文包含 base64 内联图片,请改用「上传图片」插图后再发布');
|
||
}
|
||
$wxUrl = $client->uploadContentImage($token, $url);
|
||
$html = str_replace($url, $wxUrl, $html);
|
||
}
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* 白名单挑选可编辑字段并做基础清洗
|
||
*/
|
||
private function pickEditable(array $params): array
|
||
{
|
||
$data = [];
|
||
foreach (self::EDITABLE_FIELDS as $field) {
|
||
if (!array_key_exists($field, $params)) {
|
||
continue;
|
||
}
|
||
$value = $params[$field];
|
||
$data[$field] = match ($field) {
|
||
'account_id' => (int) $value,
|
||
'content_md' => (string) $value,
|
||
default => trim((string) $value),
|
||
};
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* 列表/详情行补充:账号名、创建人/修改人昵称、状态文案
|
||
*/
|
||
private function enrichRows(array $rows): array
|
||
{
|
||
if (empty($rows)) {
|
||
return $rows;
|
||
}
|
||
$accountIds = array_values(array_unique(array_filter(array_column($rows, 'account_id'))));
|
||
$adminIds = array_values(array_unique(array_filter(array_merge(
|
||
array_column($rows, 'created_by'),
|
||
array_column($rows, 'updated_by')
|
||
))));
|
||
$accountMap = empty($accountIds) ? [] : WechatAccountModel::whereIn('id', $accountIds)->pluck('name', 'id')->toArray();
|
||
$adminMap = empty($adminIds) ? [] : AdminModel::whereIn('id', $adminIds)->pluck('nick_name', 'id')->toArray();
|
||
foreach ($rows as &$row) {
|
||
$row['account_name'] = $accountMap[$row['account_id'] ?? 0] ?? '';
|
||
$row['created_by_name'] = $adminMap[$row['created_by'] ?? 0] ?? '';
|
||
$row['updated_by_name'] = $adminMap[$row['updated_by'] ?? 0] ?? '';
|
||
$row['status_text'] = WechatArticleStatusEnum::tryFrom((int) ($row['status'] ?? 0))?->description() ?? '';
|
||
}
|
||
unset($row);
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 落一条版本流水(快照 + 操作人;操作人取当前登录态 userInfo)
|
||
*/
|
||
private function writeVersion(array $article, WechatVersionActionEnum $action, string $remark = ''): void
|
||
{
|
||
WechatArticleVersionModel::insert([
|
||
'article_id' => (int) ($article['id'] ?? 0),
|
||
'version_no' => (int) ($article['version_no'] ?? 0),
|
||
'action' => $action->value,
|
||
'title' => (string) ($article['title'] ?? ''),
|
||
'digest' => (string) ($article['digest'] ?? ''),
|
||
'content_md' => $article['content_md'] ?? null,
|
||
'theme_key' => (string) ($article['theme_key'] ?? ''),
|
||
'theme_color' => (string) ($article['theme_color'] ?? ''),
|
||
'operator_id' => $this->userId,
|
||
'operator_name' => (string) ($this->userInfo['nick_name'] ?? ''),
|
||
'remark' => $remark,
|
||
'created_at' => time(),
|
||
]);
|
||
}
|
||
}
|