mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
|
||
namespace app\services\message;
|
||
|
||
use app\services\message\admin\NoticeLogServices;
|
||
use app\services\message\pipeline\SendNoticeService;
|
||
use crmeb\exceptions\NoticeSendException;
|
||
use crmeb\plugin\ModuleStateChecker;
|
||
use crmeb\services\notice\NoticeChannelInterface;
|
||
use crmeb\services\notice\NoticeMessage;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 消息通知发送门面类
|
||
*
|
||
* 提供链式 API,业务代码中直接发送消息通知;
|
||
* 渠道选择、适配器实例化、投递与日志记录全部在本类完成,不再经过独立管线。
|
||
* 接收者即类型:to($uid>0) 为用户通知(type=1),to(0) 为平台/客服提醒(type=2),
|
||
* 两种配置分别由核心安装 SQL 或所属插件 Installer 按 (mark,type) 登记。
|
||
* 双侧通知(同 mark 既发用户又发客服):监听器内 to($uid) 与 to(0) 各调用一次,
|
||
* 本类按本次调用类型分别投递,互不重复。
|
||
*
|
||
* 使用示例:
|
||
* ```php
|
||
* // 用户通知:支付成功后发给下单用户
|
||
* Notice::to($uid)->sendMsg('order_pay_success', '支付成功', [
|
||
* 'order_id' => $orderId,
|
||
* ]);
|
||
*
|
||
* // 平台客服提醒:同一事件通知平台方(注意 to(0))
|
||
* Notice::to(0)->sendMsg('order_pay_success', '用户下单客服提醒', [
|
||
* 'order_id' => $orderId,
|
||
* ]);
|
||
*
|
||
* // 指定渠道发送(不指定则投递该通知已配置的全部启用渠道)
|
||
* Notice::to($uid)->channels(['is_system', 'is_sms'])
|
||
* ->sendMsg('pink_complete', '拼团成功', [
|
||
* '活动名称' => $title,
|
||
* ]);
|
||
* ```
|
||
*/
|
||
class Notice
|
||
{
|
||
/**
|
||
* 接收用户 ID(>0 用户通知 type=1;0 平台/客服通知 type=2)
|
||
* @var int
|
||
*/
|
||
protected int $uid = 0;
|
||
|
||
/**
|
||
* 指定发送的渠道标识列表(空数组 = 投递该通知全部启用渠道)
|
||
* @var array
|
||
*/
|
||
protected array $channelKeys = [];
|
||
|
||
/**
|
||
* 指定消息接收用户
|
||
*
|
||
* @param int $uid 用户ID,0 表示平台/客服端
|
||
* @return static
|
||
*/
|
||
public static function to(int $uid): self
|
||
{
|
||
$instance = new static();
|
||
$instance->uid = $uid;
|
||
return $instance;
|
||
}
|
||
|
||
/**
|
||
* 指定发送渠道(多个)
|
||
*
|
||
* @param array $channels 渠道标识数组,如 ['is_system', 'is_sms', 'is_wechat']
|
||
* @return $this
|
||
*/
|
||
public function channels(array $channels): self
|
||
{
|
||
$this->channelKeys = $channels;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 指定发送渠道(单个)
|
||
*
|
||
* @param string $channel 渠道标识,如 'is_wechat'
|
||
* @return $this
|
||
*/
|
||
public function channel(string $channel): self
|
||
{
|
||
return $this->channels([$channel]);
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
*
|
||
* $data 统一为扁平业务变量(如 ['订单编号' => 'wx123', '支付金额' => '99.00']),
|
||
* 各渠道按自身模板配置渲染:站内信/短信/机器人直接替换 {key} 占位符,
|
||
* 公众号/小程序经后台配置的 key_list 把业务变量映射为微信模板字段。
|
||
*
|
||
* @param string $mark 消息标识(如 pink_complete、order_pay_success),须已由安装 SQL/Installer 登记
|
||
* @param string $name 消息名称(如 拼团成功提醒),仅描述用途
|
||
* @param array $data 扁平业务变量
|
||
* @return array [channel_key => ['success' => bool, 'error' => string]]
|
||
* 空数组 = 没有任何渠道实际执行发送(mark 未登记/未启用/被过滤)
|
||
*/
|
||
public function sendMsg(string $mark, string $name, array $data): array
|
||
{
|
||
try {
|
||
// 通知由核心安装 SQL 或插件 Installer 登记;未知 mark 无配置可投
|
||
$configs = app()->make(SendNoticeService::class)->getNoticeInfo($mark);
|
||
if (empty($configs) || !$this->ownerActive($configs)) {
|
||
return [];
|
||
}
|
||
|
||
// 选渠道:纯过滤,产出待投递清单(无 IO、无异常)
|
||
$targets = $this->selectTargets($configs, $this->channelKeys);
|
||
|
||
// 逐渠道投递:单渠道失败只影响自身,靠返回值汇总成败
|
||
$results = [];
|
||
foreach ($targets as $channelKey => $target) {
|
||
$results[$channelKey] = $this->deliver($target, $mark, $data);
|
||
}
|
||
return $results;
|
||
} catch (\Throwable $e) {
|
||
Log::error('通知发送异常 ' . json_encode([
|
||
'mark' => $mark,
|
||
'uid' => $this->uid,
|
||
'exception' => get_class($e),
|
||
'error' => $e->getMessage(),
|
||
'file' => $e->getFile(),
|
||
'line' => $e->getLine(),
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取本次调用对应接收类型的配置行(uk_mark_type 唯一键保证同 mark 同 type 仅一行)
|
||
*
|
||
* @param array $configs getNoticeInfo 返回的同 mark 配置行列表
|
||
* @return array|null
|
||
*/
|
||
protected function typeConfig(array $configs): ?array
|
||
{
|
||
$wantType = $this->uid > 0 ? 1 : 2;
|
||
foreach ($configs as $config) {
|
||
if ((int)($config['type'] ?? 1) === $wantType) {
|
||
return $config;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 校验通知归属插件的启用状态
|
||
*
|
||
* 归属为空 = 核心安装 SQL 登记的核心通知,直接放行;
|
||
* 显式插件归属(module:xxx)校验模块启用状态,未启用不投递。
|
||
*
|
||
* @param array $configs 同 mark 配置行列表
|
||
* @return bool
|
||
*/
|
||
protected function ownerActive(array $configs): bool
|
||
{
|
||
$config = $this->typeConfig($configs);
|
||
if ($config === null) {
|
||
return false;
|
||
}
|
||
return ModuleStateChecker::isOwnerActive((string)($config['owner_plugin_id'] ?? ''));
|
||
}
|
||
|
||
/**
|
||
* 选渠道:筛出本次调用应投递的渠道清单
|
||
*
|
||
* 过滤条件:模板行存在 + 渠道启用(含全局开关)+ 支持本次接收端
|
||
* (用户通知只走 support_user=1 的渠道,平台通知只走 support_platform=1 的渠道,
|
||
* 防止钉钉/企业微信群机器人被误用于向具体用户发消息)+
|
||
* 渠道归属模块已启用(防止已禁用插件登记的适配器类名仍被调度)。
|
||
*
|
||
* @param array $configs getNoticeInfo 返回的同 mark 配置行列表
|
||
* @param array $onlyChannels 显式指定的渠道标识,空数组 = 遍历该通知已配置的全部渠道
|
||
* @return array [channel_key => ['channel_key','channel_name','adapter','message'=>NoticeMessage]]
|
||
*/
|
||
protected function selectTargets(array $configs, array $onlyChannels): array
|
||
{
|
||
$config = $this->typeConfig($configs);
|
||
if ($config === null) {
|
||
return [];
|
||
}
|
||
|
||
// 渠道支持范围按接收端选列
|
||
$scopeKey = $this->uid > 0 ? 'support_user' : 'support_platform';
|
||
|
||
$targets = [];
|
||
foreach ($onlyChannels ?: array_keys($config['channels'] ?? []) as $channelKey) {
|
||
$channel = $config['channels'][$channelKey] ?? null;
|
||
|
||
if (!$channel
|
||
|| (int)($channel['status'] ?? 0) !== 1
|
||
|| (int)($channel['global_status'] ?? 1) !== 1
|
||
|| (int)($channel[$scopeKey] ?? 1) !== 1
|
||
// 渠道归属模块已禁用/卸载时不投递(渠道表 adapter_class 是静态登记,需运行时校验归属状态)
|
||
|| !ModuleStateChecker::isOwnerActive((string)($channel['owner_plugin_id'] ?? ''))
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
$extra = [];
|
||
if (!empty($channel['extra'])) {
|
||
$extra = is_string($channel['extra'])
|
||
? (json_decode($channel['extra'], true) ?: [])
|
||
: (array)$channel['extra'];
|
||
}
|
||
|
||
$targets[$channelKey] = [
|
||
'channel_key' => $channelKey,
|
||
'channel_name' => (string)($channel['channel_name'] ?? $channelKey),
|
||
'adapter' => (string)($channel['adapter_class'] ?? ''),
|
||
'message' => new NoticeMessage(
|
||
mark: (string)$config['mark'],
|
||
uid: $this->uid,
|
||
title: (string)($channel['template_title'] ?? ''),
|
||
content: (string)($channel['template_content'] ?? ''),
|
||
extra: $extra,
|
||
),
|
||
];
|
||
}
|
||
return $targets;
|
||
}
|
||
|
||
/**
|
||
* 投递单个渠道:实例化适配器 → 发送 → 写日志 → 返回该渠道结果
|
||
*
|
||
* 任何一步失败都只终结本渠道,绝不向上抛——调用方在循环里投递多个渠道,
|
||
* 靠返回值汇总成败,这就是"单渠道异常隔离"的全部内容。
|
||
*
|
||
* @param array $target selectTargets 产出的待投递目标
|
||
* @param string $mark 消息标识(写日志用)
|
||
* @param array $variables 扁平业务变量
|
||
* @return array ['success' => bool, 'error' => string]
|
||
*/
|
||
protected function deliver(array $target, string $mark, array $variables): array
|
||
{
|
||
// 变量在投递时才装配进消息:selectTargets 阶段保持纯过滤
|
||
$target['message'] = new NoticeMessage(
|
||
mark: $mark,
|
||
uid: $target['message']->uid,
|
||
title: $target['message']->title,
|
||
content: $target['message']->content,
|
||
variables: $variables,
|
||
extra: $target['message']->extra,
|
||
);
|
||
|
||
// 适配器类名来自渠道表字段,属于可配置数据:空值/类不存在/没实现接口都算配置错误,
|
||
// 归为该渠道失败并留日志,而不是让整批发送崩掉
|
||
$adapter = $target['adapter'];
|
||
if ($adapter === '' || !class_exists($adapter)) {
|
||
return $this->recordFailure($target, '渠道适配器类不存在:' . $adapter);
|
||
}
|
||
if (!is_subclass_of($adapter, NoticeChannelInterface::class)) {
|
||
return $this->recordFailure($target, '适配器未实现 NoticeChannelInterface:' . $adapter);
|
||
}
|
||
|
||
try {
|
||
$this->makeChannel($adapter)->send($target['message']);
|
||
$ok = true;
|
||
$error = '';
|
||
} catch (NoticeSendException $e) {
|
||
// 渠道业务失败:消息即原因,直接记录
|
||
$ok = false;
|
||
$error = $e->getMessage();
|
||
} catch (\Throwable $e) {
|
||
// 渠道未预期的系统异常:带上异常类便于定位
|
||
$ok = false;
|
||
$error = get_class($e) . ':' . $e->getMessage();
|
||
}
|
||
|
||
$this->writeLog($target, $ok, $error);
|
||
|
||
return ['success' => $ok, 'error' => $error];
|
||
}
|
||
|
||
/**
|
||
* 适配器本身就是坏的场景:发送不可能发生,但仍要留下一条失败日志
|
||
*
|
||
* @param array $target 待投递目标
|
||
* @param string $error 失败原因
|
||
* @return array ['success' => bool, 'error' => string]
|
||
*/
|
||
protected function recordFailure(array $target, string $error): array
|
||
{
|
||
$this->writeLog($target, false, $error);
|
||
return ['success' => false, 'error' => $error];
|
||
}
|
||
|
||
/**
|
||
* 写通知日志。日志表故障不能反过来决定投递结果,但不允许空 catch——降级到框架日志留痕
|
||
*
|
||
* @param array $target 待投递目标
|
||
* @param bool $ok 发送是否成功
|
||
* @param string $error 失败原因(成功时为空)
|
||
* @return void
|
||
*/
|
||
protected function writeLog(array $target, bool $ok, string $error): void
|
||
{
|
||
try {
|
||
$message = $target['message'];
|
||
$logId = $this->logServices()->createLog(
|
||
$message->mark,
|
||
$message->uid,
|
||
$target['channel_key'],
|
||
$target['channel_name'],
|
||
$message->title,
|
||
$message->content
|
||
);
|
||
if ($ok) {
|
||
$this->logServices()->logSuccess($logId);
|
||
} else {
|
||
$this->logServices()->logFailed($logId, $error);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::error('通知日志写入失败 ' . json_encode([
|
||
'mark' => $target['message']->mark,
|
||
'uid' => $target['message']->uid,
|
||
'chan' => $target['channel_key'],
|
||
'reason' => $e->getMessage(),
|
||
], JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 实例化渠道适配器(独立方法便于测试替身注入)
|
||
*
|
||
* @param string $adapter 适配器类名
|
||
* @return NoticeChannelInterface
|
||
*/
|
||
protected function makeChannel(string $adapter): NoticeChannelInterface
|
||
{
|
||
return app()->make($adapter);
|
||
}
|
||
|
||
/**
|
||
* 通知日志服务(独立方法便于测试替身注入)
|
||
*
|
||
* @return NoticeLogServices
|
||
*/
|
||
protected function logServices(): NoticeLogServices
|
||
{
|
||
return app()->make(NoticeLogServices::class);
|
||
}
|
||
}
|