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; } /** * 正文外链图搬家:提取 ,非微信域名的下载后传图床并替换 * 为什么跳过 mmbiz 域名与 data URI:微信自家图床无需搬;base64 内联图公众号不支持,直接提示 */ private function migrateContentImages(WechatApiClient $client, string $token, string $html): string { preg_match_all('/]+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(), ]); } }