diff --git a/.cursor/rules/Dual-Backend-Sync.mdc b/.cursor/rules/Dual-Backend-Sync.mdc new file mode 100644 index 00000000..ee75a42e --- /dev/null +++ b/.cursor/rules/Dual-Backend-Sync.mdc @@ -0,0 +1,38 @@ +--- +description: 双后端同步:本仓库(Laravel)与 nl-admin-api-gf(GoFrame)互为镜像实现,改一端必须同步另一端 +alwaysApply: true +--- +# 双后端同步(Laravel ↔ GoFrame) + +本仓库(Laravel 13 版 nl-admin-api)与工作区内 `d:\myCode\nl-admin\nl-admin-api-gf`(GoFrame v2 的 Go 版同功能实现)**互为镜像**: +接口路径、入参出参、业务规则、响应结构、数据表结构必须保持一致,两端 JWT/密码/加密互通(同库同 Redis 可互认)。 + +## 强制要求 + +- 本仓库任何**接口、业务逻辑、表结构、配置键**的改动,必须同步修改 `nl-admin-api-gf` 对应实现 +- 若本次会话无法当场同步(如用户明确说只改一端),必须在回复末尾列出「gf 侧待同步清单」(文件级 + 行为级) +- 新增接口两端都实现完才算完成;禁止只改一端悄悄收尾 +- 改动 `config/nl.php` 的键值(如 log.no_insert、redis 前缀、jwt)时,同步改 gf 的 `manifest/config/config.yaml` 的 `nl` 段 + +## 层级对应关系(本仓库 → nl-admin-api-gf) + +| Laravel(本仓库) | GoFrame(nl-admin-api-gf) | +|---|---| +| routes/api.php autoRouteRegister | internal/cmd/cmd.go 手写注册 + internal/router/genroutes | +| app/Http/Controllers/Api(及 core)/*Controller | internal/controller/<模块>/ | +| app/Service/*Service | internal/logic/<模块>/ | +| app/Service/common/(JWT/Utils/加密/OSS/AI/CURD 基类) | internal/library/(auth/utils/crypto/oss/ai/curd/response/xerr) | +| app/Models/*Model | internal/model/entity + internal/dao | +| app/Enum/ | internal/consts/consts.go | +| app/Http/Middleware/ApiOpLogMiddleware | internal/middleware/oplog.go | +| config/nl.php | manifest/config/config.yaml 的 nl 段 | +| app/template/(api/view/uniapp 代码生成模板) | resource/template/(api 模板为 Go 版,view/uniapp 语义一致) | + +## 必须对齐的行为基线(两端一致,改动需双端验证) + +- 鉴权:`Authorization: Bearer `(HS256,同 secret 可互认)+ Redis `nl_login_{id}`(不设 TTL);无独立鉴权中间件差异不影响行为口径 +- 响应:`{code, message, result, type}`,成功 code=0,失败默认 500,HTTP 恒 200(sql 安装向导 `{success,...}` 除外) +- 密码 bcrypt(PHP password_hash ↔ Go bcrypt cost12 互通);库内敏感字段 AES-256-CBC,密文前缀 `nl_ase_256_` +- 分页 `page`/`pageSize` 默认 1/20(个人 login-log/op-log 默认 10 上限 100,兼容 `size`);软删 `deleted_at`;批量删除入参 `ids` +- 操作日志按 `nl_api_endpoint` 的 `is_log`/`status` 落 `nl_api_op_log`,脱敏键与跳过白名单两端同步 +- 代码生成产物:后端代码 + 建表 + 路由挂载 + `nl_menu` 菜单 + zip(view/ 与 uniapp/ 双前端),两端能力必须同口径 diff --git a/app/Enum/WechatArticleStatusEnum.php b/app/Enum/WechatArticleStatusEnum.php new file mode 100644 index 00000000..19eb6bc6 --- /dev/null +++ b/app/Enum/WechatArticleStatusEnum.php @@ -0,0 +1,29 @@ + '草稿', + self::PUBLISHING => '发布中', + self::PUBLISHED => '已发布', + self::FAILED => '发布失败', + }; + } +} diff --git a/app/Enum/WechatVersionActionEnum.php b/app/Enum/WechatVersionActionEnum.php new file mode 100644 index 00000000..1472fd9f --- /dev/null +++ b/app/Enum/WechatVersionActionEnum.php @@ -0,0 +1,31 @@ + '创建', + self::UPDATE => '修改', + self::DELETE => '删除', + self::ROLLBACK => '回退', + self::PUBLISH => '发布', + }; + } +} diff --git a/app/Http/Controllers/Api/WechatAccountController.php b/app/Http/Controllers/Api/WechatAccountController.php new file mode 100644 index 00000000..2569335c --- /dev/null +++ b/app/Http/Controllers/Api/WechatAccountController.php @@ -0,0 +1,62 @@ +service = WechatAccountService::getInstance(); + } + + /** + * 新增账号(覆盖基类:Secret 加密、AppID 查重在 Service 内处理) + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '创建成功'); + } + + /** + * 更新账号(app_secret 传空 = 不修改密钥) + * @Method POST + */ + public function update(): JsonResponse + { + $params = request()->post(); + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return jerr('参数错误'); + } + unset($params['id']); + return jok($this->service->update($id, $params), '更新成功'); + } + + /** + * 设为默认发布账号 + * @Method POST + */ + public function setDefault(): JsonResponse + { + return jok($this->service->setDefault(request()->post()), '设置成功'); + } + + /** + * 测试连通性(appid+secret 直连微信换 token 验证) + * @Method POST + */ + public function testConnection(): JsonResponse + { + return jok($this->service->testConnection(request()->post()), '连接成功,凭据有效'); + } +} diff --git a/app/Http/Controllers/Api/WechatArticleController.php b/app/Http/Controllers/Api/WechatArticleController.php new file mode 100644 index 00000000..722f1435 --- /dev/null +++ b/app/Http/Controllers/Api/WechatArticleController.php @@ -0,0 +1,97 @@ +service = WechatArticleService::getInstance(); + } + + /** + * @Method NO + */ + public function option(): mixed + { + return jerr('不支持'); + } + + /** + * 新增图文(标题必填在 Service 校验;创建即落 v1 版本流水) + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '创建成功'); + } + + /** + * 更新图文(版本号 +1 并落「修改」流水) + * @Method POST + */ + public function update(): JsonResponse + { + $params = request()->post(); + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return jerr('参数错误'); + } + unset($params['id']); + return jok($this->service->update($id, $params), '更新成功'); + } + + /** + * 版本流水分页(?article_id=) + * @Method GET + */ + public function versionList(): JsonResponse + { + return jok($this->service->versionList((int) request()->get('article_id', 0)), '获取成功'); + } + + /** + * 单版本详情(含正文快照,回退前预览) + * @Method GET + */ + public function versionDetail(): JsonResponse + { + return jok($this->service->versionDetail((int) request()->get('id', 0)), '获取成功'); + } + + /** + * 版本回退 {id, version_id} + * @Method POST + */ + public function rollback(): JsonResponse + { + return jok($this->service->rollback(request()->post()), '回退成功'); + } + + /** + * 一键发布 {id, content_html, account_id?}(content_html 为前端渲染好的内联样式 HTML) + * @Method POST + */ + public function publish(): JsonResponse + { + return jok($this->service->publish(request()->post()), '已提交发布,正在审核'); + } + + /** + * 查询发布状态并回填(?id=) + * @Method GET + */ + public function publishStatus(): JsonResponse + { + return jok($this->service->publishStatus((int) request()->get('id', 0)), '获取成功'); + } +} diff --git a/app/Http/Controllers/Api/WechatThemeController.php b/app/Http/Controllers/Api/WechatThemeController.php new file mode 100644 index 00000000..070a9085 --- /dev/null +++ b/app/Http/Controllers/Api/WechatThemeController.php @@ -0,0 +1,44 @@ +service = WechatThemeService::getInstance(); + } + + /** + * 导入模板(JSON 清洗入库) + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '导入成功'); + } + + /** + * 更新模板(名称/色板/样式整体覆盖) + * @Method POST + */ + public function update(): JsonResponse + { + $params = request()->post(); + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + return jerr('参数错误'); + } + unset($params['id']); + return jok($this->service->update($id, $params), '更新成功'); + } +} diff --git a/app/Models/wechat/WechatAccountModel.php b/app/Models/wechat/WechatAccountModel.php new file mode 100644 index 00000000..f9831f1c --- /dev/null +++ b/app/Models/wechat/WechatAccountModel.php @@ -0,0 +1,15 @@ +model = WechatAccountModel::class; + $this->selectField = [ + 'id', 'name', 'appid', 'app_secret', 'original_id', + 'is_default', 'status', 'remark', 'sort', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'name' => 'like', + 'appid' => 'like', + 'status' => '=', + ]; + $this->orderBy = [ + 'name' => 'sort', + 'sort' => 'desc', + ]; + } + + /** + * 分页列表(脱敏:app_secret 整字段剔除,返回 has_secret 布尔) + */ + public function list(): array + { + $result = $this->getPageList(); + $result['items'] = $this->maskRows($result['items'] ?? []); + return $result; + } + + /** + * 下拉选项(发布弹窗选账号用):仅启用账号,默认账号排最前 + */ + public function option(): mixed + { + return WechatAccountModel::where('deleted_at', 0) + ->where('status', 1) + ->orderByDesc('is_default') + ->orderByDesc('sort') + ->get(['id', 'name', 'appid', 'is_default']); + } + + /** + * 详情(同样剔除 secret,只给 has_secret) + */ + public function detail($id): mixed + { + $info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first($this->selectField); + if (empty($info)) { + return $this->utils->notFound('账号不存在'); + } + $rows = $this->maskRows([$info->toArray()]); + return $rows[0]; + } + + /** + * 新增账号:secret 用 AES 加密入库;is_default=1 时清掉其它默认标记 + */ + public function create($params): mixed + { + $name = trim((string) ($params['name'] ?? '')); + $appid = trim((string) ($params['appid'] ?? '')); + $secret = trim((string) ($params['app_secret'] ?? '')); + if ($name === '' || $appid === '' || $secret === '') { + $this->utils->errorThrow('请填写公众号名称、AppID 与 AppSecret'); + } + // 同一 AppID 不允许重复配置(未删除范围内) + $exists = WechatAccountModel::where('appid', $appid)->where('deleted_at', 0)->exists(); + if ($exists) { + $this->utils->errorThrow('该 AppID 已存在,请勿重复配置'); + } + $now = time(); + $isDefault = (int) ($params['is_default'] ?? 0); + if ($isDefault === 1) { + WechatAccountModel::where('deleted_at', 0)->update(['is_default' => 0, 'updated_at' => $now]); + } + $id = WechatAccountModel::insertGetId([ + 'name' => $name, + 'appid' => $appid, + 'app_secret' => FieldEncryptService::getInstance()->encryptForStorage($secret), + 'original_id' => trim((string) ($params['original_id'] ?? '')), + 'is_default' => $isDefault, + 'status' => (int) ($params['status'] ?? 1), + 'remark' => trim((string) ($params['remark'] ?? '')), + 'sort' => (int) ($params['sort'] ?? 0), + 'created_at' => $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + return ['id' => $id]; + } + + /** + * 更新账号:app_secret 传空 = 不改密钥(前端「已配置」占位不回传) + */ + public function update($id, $params): mixed + { + $id = (int) $id; + $info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + return $this->utils->notFound('账号不存在'); + } + $data = []; + if (array_key_exists('name', $params)) { + $name = trim((string) $params['name']); + if ($name === '') { + $this->utils->errorThrow('公众号名称不能为空'); + } + $data['name'] = $name; + } + if (array_key_exists('appid', $params)) { + $appid = trim((string) $params['appid']); + if ($appid === '') { + $this->utils->errorThrow('AppID 不能为空'); + } + // 改 AppID 时同样查重(排除自己) + $exists = WechatAccountModel::where('appid', $appid) + ->where('deleted_at', 0)->where('id', '<>', $id)->exists(); + if ($exists) { + $this->utils->errorThrow('该 AppID 已被其它账号使用'); + } + $data['appid'] = $appid; + } + foreach (['original_id', 'remark'] as $field) { + if (array_key_exists($field, $params)) { + $data[$field] = trim((string) $params[$field]); + } + } + if (array_key_exists('status', $params)) { + $data['status'] = (int) $params['status']; + } + if (array_key_exists('sort', $params)) { + $data['sort'] = (int) $params['sort']; + } + // 密钥字段只在非空时更新(空串代表用户没改) + $secret = trim((string) ($params['app_secret'] ?? '')); + if ($secret !== '') { + $data['app_secret'] = FieldEncryptService::getInstance()->encryptForStorage($secret); + } + $now = time(); + if (array_key_exists('is_default', $params) && (int) $params['is_default'] === 1) { + WechatAccountModel::where('deleted_at', 0)->where('id', '<>', $id) + ->update(['is_default' => 0, 'updated_at' => $now]); + $data['is_default'] = 1; + } elseif (array_key_exists('is_default', $params)) { + $data['is_default'] = (int) $params['is_default']; + } + if (empty($data)) { + return true; + } + $data['updated_at'] = $now; + return WechatAccountModel::where('id', $id)->where('deleted_at', 0)->update($data); + } + + /** + * 软删除(批量) + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + return WechatAccountModel::whereIn('id', $ids)->where('deleted_at', 0)->update([ + 'deleted_at' => time(), + 'updated_at' => time(), + 'is_default' => 0, + ]); + } + + /** + * 切换默认发布账号(全局仅一个) + */ + public function setDefault(array $params): array + { + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + $this->utils->errorThrow('请选择账号'); + } + $info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + $this->utils->notFound('账号不存在'); + } + if ((int) $info->status !== 1) { + $this->utils->errorThrow('该账号已禁用,无法设为默认'); + } + $now = time(); + WechatAccountModel::where('deleted_at', 0)->update(['is_default' => 0, 'updated_at' => $now]); + WechatAccountModel::where('id', $id)->update(['is_default' => 1, 'updated_at' => $now]); + return ['id' => $id]; + } + + /** + * 测试连通性:解密 secret 后直连微信换 token 验证(不落缓存) + * 支持两种入参:已有账号传 id;表单未保存时直接传 appid+app_secret + */ + public function testConnection(array $params): array + { + $id = (int) ($params['id'] ?? 0); + $appid = trim((string) ($params['appid'] ?? '')); + $secret = trim((string) ($params['app_secret'] ?? '')); + if ($id > 0) { + $info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + $this->utils->notFound('账号不存在'); + } + $appid = $appid !== '' ? $appid : (string) $info->appid; + // 表单里没重填 secret 时,用库里已配置的密文解密测试 + if ($secret === '') { + $secret = FieldEncryptService::getInstance()->decryptFromStorage((string) $info->app_secret); + } + } + if ($appid === '' || $secret === '') { + $this->utils->errorThrow('请填写 AppID 与 AppSecret'); + } + return WechatApiClient::getInstance()->testCredential($appid, $secret); + } + + /** + * 发布链路取账号:指定 id 优先,缺省回落默认账号;返回含解密后 secret 的数组 + * 为什么放这里:解密动作收敛在账号服务内,文章服务只拿可用凭据 + */ + public function getPublishAccount(int $accountId = 0): array + { + $query = WechatAccountModel::where('deleted_at', 0)->where('status', 1); + if ($accountId > 0) { + $info = (clone $query)->where('id', $accountId)->first(); + if (empty($info)) { + $this->utils->errorThrow('指定的公众号账号不存在或已禁用'); + } + } else { + $info = (clone $query)->where('is_default', 1)->first(); + if (empty($info)) { + $this->utils->errorThrow('未配置默认公众号账号,请先到「公众号账号」设置默认账号'); + } + } + $secret = FieldEncryptService::getInstance()->decryptFromStorage((string) $info->app_secret); + if ($secret === '') { + $this->utils->errorThrow('该账号的 AppSecret 解密失败,请重新保存密钥'); + } + return [ + 'id' => (int) $info->id, + 'name' => (string) $info->name, + 'appid' => (string) $info->appid, + 'secret' => $secret, + ]; + } + + /** + * 行脱敏:secret 密文剔除,替换为 has_secret 布尔标记 + */ + private function maskRows(array $rows): array + { + foreach ($rows as &$row) { + $row['has_secret'] = trim((string) ($row['app_secret'] ?? '')) !== ''; + unset($row['app_secret']); + } + unset($row); + return $rows; + } +} diff --git a/app/Service/WechatArticleService.php b/app/Service/WechatArticleService.php new file mode 100644 index 00000000..e8e7bf0b --- /dev/null +++ b/app/Service/WechatArticleService.php @@ -0,0 +1,480 @@ +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(), + ]); + } +} diff --git a/app/Service/WechatThemeService.php b/app/Service/WechatThemeService.php new file mode 100644 index 00000000..d65915cc --- /dev/null +++ b/app/Service/WechatThemeService.php @@ -0,0 +1,187 @@ +"\ 字符(防止逃逸 style 属性注入标签)、 + * 去掉 javascript: / expression( 危险片段,长度截断 2000 + * - palette 仅接受合法 HEX 色值数组(最多 8 个) + */ +class WechatThemeService extends BaseService +{ + /** 样式表允许的元素键(与前端 ThemeStyleSheet 类型一一对应) */ + private const ALLOWED_STYLE_KEYS = [ + 'container', 'h1', 'h2', 'h3', 'p', 'blockquote', 'blockquoteP', + 'ul', 'ol', 'li', 'strong', 'em', 'a', 'hr', 'img', + 'code', 'pre', 'preCode', 'table', 'th', 'td', + ]; + + /** 自定义主题数量上限(防止无限膨胀拖慢编辑器加载) */ + private const MAX_THEME_COUNT = 100; + + public function __construct() + { + parent::__construct(); + $this->model = WechatThemeModel::class; + } + + /** + * 全量列表(编辑器主题面板一次性加载,非分页) + * palette/styles 解码成数组返回,前端拿到即可注册渲染 + */ + public function list(): array + { + $rows = WechatThemeModel::where('deleted_at', 0) + ->orderByDesc('id') + ->limit(self::MAX_THEME_COUNT) + ->get(['id', 'name', 'palette', 'styles', 'created_by', 'created_at', 'updated_at']) + ->toArray(); + foreach ($rows as &$row) { + $row['palette'] = $this->decodeJson($row['palette'] ?? ''); + $row['styles'] = $this->decodeJson($row['styles'] ?? ''); + } + unset($row); + return $rows; + } + + /** + * 导入模板:清洗校验后入库,返回新 id + */ + public function create($params): mixed + { + [$name, $palette, $styles] = $this->validatePayload($params); + $count = WechatThemeModel::where('deleted_at', 0)->count(); + if ($count >= self::MAX_THEME_COUNT) { + $this->utils->errorThrow('自定义模板已达上限(' . self::MAX_THEME_COUNT . ' 个),请先删除不用的模板'); + } + $id = WechatThemeModel::insertGetId([ + 'name' => $name, + 'palette' => $this->encodeJson($palette), + 'styles' => $this->encodeJson($styles), + 'created_by' => $this->userId, + 'created_at' => time(), + 'updated_at' => 0, + 'deleted_at' => 0, + ]); + return ['id' => $id]; + } + + /** + * 更新模板(名称/色板/样式整体覆盖,与导入同一套清洗) + */ + public function update($id, $params): mixed + { + $id = (int) $id; + $info = WechatThemeModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + return $this->utils->notFound('模板不存在'); + } + [$name, $palette, $styles] = $this->validatePayload($params); + return WechatThemeModel::where('id', $id)->update([ + 'name' => $name, + 'palette' => $this->encodeJson($palette), + 'styles' => $this->encodeJson($styles), + 'updated_at' => time(), + ]); + } + + /** + * 批量软删除 + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + return WechatThemeModel::whereIn('id', $ids)->where('deleted_at', 0)->update([ + 'deleted_at' => time(), + 'updated_at' => time(), + ]); + } + + /** + * 入参校验 + 清洗:返回 [name, palette, styles] + * 为什么集中在一处:create/update 共用,保证任何入口都过同一套白名单 + */ + private function validatePayload(array $params): array + { + $name = mb_substr(trim((string) ($params['name'] ?? '')), 0, 50); + if ($name === '') { + $this->utils->errorThrow('请填写模板名称'); + } + $styles = $params['styles'] ?? []; + if (is_string($styles)) { + $styles = $this->decodeJson($styles); + } + if (!is_array($styles) || empty($styles)) { + $this->utils->errorThrow('模板样式(styles)不能为空'); + } + $cleanStyles = []; + foreach (self::ALLOWED_STYLE_KEYS as $key) { + if (!array_key_exists($key, $styles)) { + continue; + } + $value = $this->cleanStyleValue((string) $styles[$key]); + if ($value !== '') { + $cleanStyles[$key] = $value; + } + } + if (empty($cleanStyles)) { + $this->utils->errorThrow('styles 里没有可用的元素样式(支持键:' . implode('/', self::ALLOWED_STYLE_KEYS) . ')'); + } + $palette = $params['palette'] ?? []; + if (is_string($palette)) { + $palette = $this->decodeJson($palette); + } + $cleanPalette = []; + foreach (is_array($palette) ? $palette : [] as $colorItem) { + $colorItem = trim((string) $colorItem); + // 只收合法 HEX(#rgb/#rrggbb/#rrggbbaa),防止色板被塞入任意字符串 + if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $colorItem)) { + $cleanPalette[] = strtolower($colorItem); + } + if (count($cleanPalette) >= 8) { + break; + } + } + return [$name, $cleanPalette, $cleanStyles]; + } + + /** + * 单个样式值清洗:内联 CSS 会被前端注入 style="" 属性, + * 必须去掉能逃逸属性/注入脚本的字符与片段 + */ + private function cleanStyleValue(string $value): string + { + // 去掉双引号/尖括号/反斜杠:双引号会截断 style 属性,尖括号可注入标签 + $value = str_replace(['"', '<', '>', '\\'], '', $value); + // 去掉历史遗留的危险 CSS 片段(旧 IE expression、javascript 伪协议) + $value = preg_replace('/javascript\s*:/i', '', $value) ?? ''; + $value = preg_replace('/expression\s*\(/i', '', $value) ?? ''; + return mb_substr(trim($value), 0, 2000); + } + + /** JSON 解码兜底:解不开返回空数组,不让脏数据打崩接口 */ + private function decodeJson($raw): array + { + if (is_array($raw)) { + return $raw; + } + $decoded = json_decode((string) $raw, true); + return is_array($decoded) ? $decoded : []; + } + + /** JSON 编码(保留中文与斜杠原样,方便库内直读排查) */ + private function encodeJson(array $data): string + { + return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; + } +} diff --git a/app/Service/common/wechat/WechatApiClient.php b/app/Service/common/wechat/WechatApiClient.php new file mode 100644 index 00000000..4f6877ef --- /dev/null +++ b/app/Service/common/wechat/WechatApiClient.php @@ -0,0 +1,275 @@ + 'AppSecret 错误或 access_token 已失效,请检查账号配置', + 40013 => 'AppID 无效,请检查账号配置', + 40125 => 'AppSecret 无效,请检查账号配置', + 41004 => '缺少 AppSecret 参数', + 40164 => '服务器出口 IP 不在公众号后台的 IP 白名单中,请到公众平台「设置与开发-安全中心」添加', + 48001 => '该公众号无此接口权限(个人订阅号无发布能力,需认证服务号/订阅号)', + 45009 => '接口调用次数超过当日限额,请明天再试', + 45028 => '该公众号无留言/相关高级能力权限', + 40007 => '无效的 media_id,素材可能已过期,请重新发布', + 53503 => '该草稿未通过发布检查', + 53504 => '需前往公众平台官网使用草稿', + 53505 => '请手动保存成功后再发布', + ]; + + /** + * 获取稳定版 access_token(优先走 Redis 缓存) + * + * 为什么用 stable_token 而不是旧 token 接口:stable 模式重复获取不会互踢, + * 多实例/双后端(Laravel+GF)共用同一个 Redis key 也不会导致 token 失效竞争 + * + * @param string $appid 公众号 AppID + * @param string $secret AppSecret 明文(调用方负责解密) + * @param bool $forceRefresh true 时绕过缓存强刷(如 token 失效重试场景) + */ + public function getStableToken(string $appid, string $secret, bool $forceRefresh = false): string + { + $redis = RedisService::getInstance()->init((string) config('nl.redis.wechat_token', 'nl_wechat_at_')); + if (!$forceRefresh) { + $cached = $redis->get($appid); + if (!empty($cached)) { + return (string) $cached; + } + } + $body = $this->postWxJson('/cgi-bin/stable_token', [ + 'grant_type' => 'client_credential', + 'appid' => $appid, + 'secret' => $secret, + 'force_refresh' => $forceRefresh, + ], '获取access_token'); + $token = (string) ($body['access_token'] ?? ''); + if ($token === '') { + $this->utils->errorThrow('获取 access_token 失败:微信未返回 token'); + } + // 提前 5 分钟过期,避免边界时刻拿到将失效的 token + $expire = max(60, (int) ($body['expires_in'] ?? 7200) - 300); + $redis->set($appid, $token, $expire); + return $token; + } + + /** + * 校验 appid/secret 连通性(不落缓存,直接强刷验证凭据有效) + * 返回微信侧 expires_in,便于前端提示 + */ + public function testCredential(string $appid, string $secret): array + { + $body = $this->postWxJson('/cgi-bin/stable_token', [ + 'grant_type' => 'client_credential', + 'appid' => $appid, + 'secret' => $secret, + ], '测试连通'); + return [ + 'ok' => true, + 'expires_in' => (int) ($body['expires_in'] ?? 0), + ]; + } + + /** + * 正文图片搬家:下载外链图后传微信图床,返回微信侧 URL + * 微信限制:仅 jpg/png、单张 ≤1MB;该 URL 不占素材库额度,仅可用于图文正文 + */ + public function uploadContentImage(string $token, string $imageUrl): string + { + [$content, $ext] = $this->downloadImage($imageUrl); + if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) { + $this->utils->errorThrow("正文图片仅支持 jpg/png:{$imageUrl}"); + } + if (strlen($content) > 1024 * 1024) { + $this->utils->errorThrow("正文图片超过 1MB,无法上传微信图床:{$imageUrl}"); + } + $body = $this->postWxMultipart( + '/cgi-bin/media/uploadimg?access_token=' . $token, + 'media', $content, 'content_' . substr(md5($imageUrl), 0, 8) . '.' . $ext, + '正文图片上传' + ); + $url = (string) ($body['url'] ?? ''); + if ($url === '') { + $this->utils->errorThrow('正文图片上传微信图床失败:未返回 URL'); + } + return $url; + } + + /** + * 封面图上传为永久图片素材,返回 media_id(草稿 thumb_media_id 用) + * 微信限制:图片素材 ≤10MB,支持 bmp/png/jpeg/jpg/gif + */ + public function addImageMaterial(string $token, string $imageUrl): string + { + [$content, $ext] = $this->downloadImage($imageUrl); + if (strlen($content) > 10 * 1024 * 1024) { + $this->utils->errorThrow("封面图超过 10MB,无法上传素材库:{$imageUrl}"); + } + $body = $this->postWxMultipart( + '/cgi-bin/material/add_material?access_token=' . $token . '&type=image', + 'media', $content, 'cover_' . substr(md5($imageUrl), 0, 8) . '.' . $ext, + '封面素材上传' + ); + $mediaId = (string) ($body['media_id'] ?? ''); + if ($mediaId === '') { + $this->utils->errorThrow('封面上传素材库失败:未返回 media_id'); + } + return $mediaId; + } + + /** + * 新建草稿,返回草稿 media_id + * + * @param array $article 单篇图文:title/author/digest/content/thumb_media_id 等 + */ + public function addDraft(string $token, array $article): string + { + $body = $this->postWxJson('/cgi-bin/draft/add?access_token=' . $token, [ + 'articles' => [$article], + ], '新建草稿'); + $mediaId = (string) ($body['media_id'] ?? ''); + if ($mediaId === '') { + $this->utils->errorThrow('新建草稿失败:未返回 media_id'); + } + return $mediaId; + } + + /** + * 提交发布任务(freepublish 为异步审核,返回 publish_id 供轮询) + */ + public function submitPublish(string $token, string $mediaId): string + { + $body = $this->postWxJson('/cgi-bin/freepublish/submit?access_token=' . $token, [ + 'media_id' => $mediaId, + ], '提交发布'); + $publishId = (string) ($body['publish_id'] ?? ''); + if ($publishId === '') { + $this->utils->errorThrow('提交发布失败:未返回 publish_id'); + } + return $publishId; + } + + /** + * 查询发布任务状态 + * publish_status:0成功 1发布中 2原创审核不通过 3常规失败 4平台审核不通过 5成功后用户删除 6成功后系统封禁 + */ + public function getPublishStatus(string $token, string $publishId): array + { + return $this->postWxJson('/cgi-bin/freepublish/get?access_token=' . $token, [ + 'publish_id' => $publishId, + ], '查询发布状态'); + } + + /** + * 微信 JSON 接口统一出口:JSON_UNESCAPED_UNICODE 编码 + errcode 校验 + * + * @param string $scene 场景名(拼错误提示用) + */ + private function postWxJson(string $uri, array $data, string $scene): array + { + $this->init(); + try { + $response = $this->client->request('POST', $uri, [ + 'body' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'headers' => ['Content-Type' => 'application/json'], + ]); + $body = json_decode((string) $response->getBody(), true) ?: []; + } catch (GuzzleException $e) { + $this->utils->errorThrow("微信接口请求失败({$scene}):" . $e->getMessage()); + return []; + } + $this->assertWxOk($body, $scene); + return $body; + } + + /** + * 微信 multipart 文件上传统一出口 + */ + private function postWxMultipart(string $uri, string $field, string $content, string $filename, string $scene): array + { + $this->init(); + try { + $response = $this->client->request('POST', $uri, [ + 'multipart' => [[ + 'name' => $field, + 'contents' => $content, + 'filename' => $filename, + ]], + ]); + $body = json_decode((string) $response->getBody(), true) ?: []; + } catch (GuzzleException $e) { + $this->utils->errorThrow("微信接口请求失败({$scene}):" . $e->getMessage()); + return []; + } + $this->assertWxOk($body, $scene); + return $body; + } + + /** + * errcode 非 0 即失败,映射常见错误码后抛业务异常 + */ + private function assertWxOk(array $body, string $scene): void + { + $errcode = (int) ($body['errcode'] ?? 0); + if ($errcode === 0) { + return; + } + $friendly = self::ERROR_MAP[$errcode] ?? ('errmsg: ' . (string) ($body['errmsg'] ?? '未知错误')); + $this->utils->errorThrow("微信{$scene}失败({$errcode}):{$friendly}"); + } + + /** + * 下载图片(本地/OSS/任意外链),返回 [二进制内容, 扩展名] + * 为什么按 Content-Type 兜底扩展名:OSS 直链可能不带扩展名,微信上传要求文件名有效 + */ + private function downloadImage(string $url): array + { + $this->init(); + try { + $response = $this->client->request('GET', $url, ['timeout' => 30]); + } catch (GuzzleException $e) { + $this->utils->errorThrow("下载图片失败:{$url}(" . $e->getMessage() . ')'); + return ['', '']; + } + $content = (string) $response->getBody(); + if ($content === '') { + $this->utils->errorThrow("下载图片失败:{$url}(内容为空)"); + } + // 优先从 URL 扩展名判断,取不到再从 Content-Type 兜底 + $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)); + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp'], true)) { + $contentType = strtolower($response->getHeaderLine('Content-Type')); + $ext = match (true) { + str_contains($contentType, 'png') => 'png', + str_contains($contentType, 'gif') => 'gif', + str_contains($contentType, 'bmp') => 'bmp', + default => 'jpg', + }; + } + return [$content, $ext]; + } +}