Files
nl-admin-api/app/Service/WechatArticleService.php
李琦 6e2769c528
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
优化部分代码,增加微信公众号模板
2026-08-13 19:02:05 +08:00

481 lines
19 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
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(),
]);
}
}