优化部分代码,增加微信公众号模板
This commit is contained in:
38
.cursor/rules/Dual-Backend-Sync.mdc
Normal file
38
.cursor/rules/Dual-Backend-Sync.mdc
Normal file
@@ -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 <JWT>`(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/ 双前端),两端能力必须同口径
|
||||
29
app/Enum/WechatArticleStatusEnum.php
Normal file
29
app/Enum/WechatArticleStatusEnum.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* 公众号图文文章状态枚举
|
||||
* 发布走微信 freepublish 异步审核:提交后先置「发布中」,由 publish-status 轮询回填终态
|
||||
*/
|
||||
enum WechatArticleStatusEnum: int
|
||||
{
|
||||
// 0草稿 1发布中 2已发布 3发布失败
|
||||
case DRAFT = 0;
|
||||
case PUBLISHING = 1;
|
||||
case PUBLISHED = 2;
|
||||
case FAILED = 3;
|
||||
|
||||
/**
|
||||
* 中文展示名
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => '草稿',
|
||||
self::PUBLISHING => '发布中',
|
||||
self::PUBLISHED => '已发布',
|
||||
self::FAILED => '发布失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/Enum/WechatVersionActionEnum.php
Normal file
31
app/Enum/WechatVersionActionEnum.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* 公众号图文版本流水的操作类型枚举
|
||||
* 每次创建/修改/删除/回退/发布都会写一条版本快照,action 标记这次快照的来源
|
||||
*/
|
||||
enum WechatVersionActionEnum: int
|
||||
{
|
||||
// 1创建 2修改 3删除 4回退 5发布
|
||||
case CREATE = 1;
|
||||
case UPDATE = 2;
|
||||
case DELETE = 3;
|
||||
case ROLLBACK = 4;
|
||||
case PUBLISH = 5;
|
||||
|
||||
/**
|
||||
* 中文展示名(版本列表直接用)
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CREATE => '创建',
|
||||
self::UPDATE => '修改',
|
||||
self::DELETE => '删除',
|
||||
self::ROLLBACK => '回退',
|
||||
self::PUBLISH => '发布',
|
||||
};
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Api/WechatAccountController.php
Normal file
62
app/Http/Controllers/Api/WechatAccountController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\WechatAccountService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 公众号账号配置控制器:CRUD(Secret 加密入库)/ 默认账号切换 / 连通性测试
|
||||
* list/option/detail 走基类(Service 内已做脱敏),create/update 覆盖走加密逻辑
|
||||
*/
|
||||
class WechatAccountController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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()), '连接成功,凭据有效');
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Api/WechatArticleController.php
Normal file
97
app/Http/Controllers/Api/WechatArticleController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\WechatArticleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 公众号图文控制器:CRUD / 版本流水 / 版本回退 / 一键发布 / 发布状态
|
||||
* 图文正文(content_md)体量大且含任意字符,create/update 覆盖基类直接透传给 Service 白名单处理
|
||||
*/
|
||||
class WechatArticleController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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)), '获取成功');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Api/WechatThemeController.php
Normal file
44
app/Http/Controllers/Api/WechatThemeController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\BaseApp\BaseController;
|
||||
use App\Service\WechatThemeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 自定义排版主题控制器:图文编辑器「我的模板」导入/列表/更新/删除
|
||||
* list/delete 走基类;create/update 覆盖直传 Service(清洗校验都在 Service 内)
|
||||
*/
|
||||
class WechatThemeController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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), '更新成功');
|
||||
}
|
||||
}
|
||||
15
app/Models/wechat/WechatAccountModel.php
Normal file
15
app/Models/wechat/WechatAccountModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\wechat;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* 微信公众号账号配置表模型(app_secret 库内 AES 加密存储,查询逻辑放 Service)
|
||||
*/
|
||||
class WechatAccountModel extends BaseModel
|
||||
{
|
||||
protected $table = 'wechat_account';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/wechat/WechatArticleModel.php
Normal file
15
app/Models/wechat/WechatArticleModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\wechat;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* 公众号图文文章表模型(存 Markdown 源码 + 主题 key/主色,查询逻辑放 Service)
|
||||
*/
|
||||
class WechatArticleModel extends BaseModel
|
||||
{
|
||||
protected $table = 'wechat_article';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/wechat/WechatArticleVersionModel.php
Normal file
15
app/Models/wechat/WechatArticleVersionModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\wechat;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* 公众号图文版本流水表模型(日志型,仅 created_at;每次创建/修改/删除/回退/发布落一条快照)
|
||||
*/
|
||||
class WechatArticleVersionModel extends BaseModel
|
||||
{
|
||||
protected $table = 'wechat_article_version';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
15
app/Models/wechat/WechatThemeModel.php
Normal file
15
app/Models/wechat/WechatThemeModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\wechat;
|
||||
|
||||
use App\BaseApp\BaseModel;
|
||||
|
||||
/**
|
||||
* 公众号自定义排版主题表模型(用户上传的模板 JSON,查询逻辑放 Service)
|
||||
*/
|
||||
class WechatThemeModel extends BaseModel
|
||||
{
|
||||
protected $table = 'wechat_theme';
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
275
app/Service/WechatAccountService.php
Normal file
275
app/Service/WechatAccountService.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\wechat\WechatAccountModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\common\wechat\WechatApiClient;
|
||||
|
||||
/**
|
||||
* 公众号账号配置服务:CRUD(AppSecret 入库 AES 加密)、默认账号切换、连通性测试
|
||||
* 对齐 OssConfigService 的加密模式:list/detail 永不回显 secret 明文,只给 has_secret 标记
|
||||
*/
|
||||
class WechatAccountService extends BaseService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
480
app/Service/WechatArticleService.php
Normal file
480
app/Service/WechatArticleService.php
Normal file
@@ -0,0 +1,480 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
187
app/Service/WechatThemeService.php
Normal file
187
app/Service/WechatThemeService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BaseApp\BaseService;
|
||||
use App\Models\wechat\WechatThemeModel;
|
||||
|
||||
/**
|
||||
* 自定义排版主题服务:用户上传的模板 JSON 落库,供图文编辑器「我的模板」使用
|
||||
*
|
||||
* 安全设计(两端后端同口径,GF 版为 internal/logic/wechattheme):
|
||||
* - styles 只允许白名单元素键(container/h1/h2/p/blockquote 等 21 个)
|
||||
* - 每个样式值清洗:去掉 <>"\ 字符(防止逃逸 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) ?: '[]';
|
||||
}
|
||||
}
|
||||
275
app/Service/common/wechat/WechatApiClient.php
Normal file
275
app/Service/common/wechat/WechatApiClient.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\wechat;
|
||||
|
||||
use App\BaseApp\BaseClient;
|
||||
use App\Service\common\RedisService;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
|
||||
/**
|
||||
* 微信公众号 API 客户端(继承 BaseClient,禁止业务代码直接 new Guzzle)
|
||||
*
|
||||
* 职责:
|
||||
* 1. stable_token 获取 + Redis 缓存(key 前缀 config('nl.redis.wechat_token'),TTL 随微信返回的 expires_in)
|
||||
* 2. 正文外链图搬家:media/uploadimg(公众号正文只认微信图床 URL)
|
||||
* 3. 封面永久素材:material/add_material 取 thumb_media_id
|
||||
* 4. 草稿 draft/add、发布 freepublish/submit、发布状态 freepublish/get
|
||||
*
|
||||
* 为什么单独封 postWxJson / postWxMultipart:
|
||||
* - BaseClient::postJson 用 Guzzle 的 json 选项,中文会被 unicode 转义,微信标题/摘要偶发显示 \uXXXX,
|
||||
* 这里手动 JSON_UNESCAPED_UNICODE 编码后按 body 发送
|
||||
* - 素材上传要 multipart 文件流,BaseClient 没有该能力,本类内部补齐(复用父类 init 的 Guzzle 实例)
|
||||
*/
|
||||
class WechatApiClient extends BaseClient
|
||||
{
|
||||
protected string $baseUrl = 'https://api.weixin.qq.com';
|
||||
|
||||
/**
|
||||
* 常见错误码 → 人话(其余透出 errcode + errmsg 原文)
|
||||
*/
|
||||
private const ERROR_MAP = [
|
||||
40001 => '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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user