Files
nl-admin-api/app/Service/WechatArticleService.php

481 lines
19 KiB
PHP
Raw Normal View History

<?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(),
]);
}
}