优化部分代码,增加微信公众号模板
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled

This commit is contained in:
李琦
2026-08-13 19:02:05 +08:00
parent 2f5e298d6b
commit 6e2769c528
14 changed files with 1578 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
---
description: 双后端同步本仓库Laravel与 nl-admin-api-gfGoFrame互为镜像实现改一端必须同步另一端
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本仓库 | GoFramenl-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失败默认 500HTTP 恒 200sql 安装向导 `{success,...}` 除外)
- 密码 bcryptPHP 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` 菜单 + zipview/ 与 uniapp/ 双前端),两端能力必须同口径

View 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 => '发布失败',
};
}
}

View 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 => '发布',
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\WechatAccountService;
use Illuminate\Http\JsonResponse;
/**
* 公众号账号配置控制器CRUDSecret 加密入库)/ 默认账号切换 / 连通性测试
* 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()), '连接成功,凭据有效');
}
}

View 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)), '获取成功');
}
}

View 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), '更新成功');
}
}

View 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 = [];
}

View 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 = [];
}

View 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 = [];
}

View 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 = [];
}

View 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;
/**
* 公众号账号配置服务CRUDAppSecret 入库 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;
}
}

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

View 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) ?: '[]';
}
}

View 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_status0成功 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];
}
}