mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 21:40:50 +00:00
508 lines
21 KiB
PHP
508 lines
21 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types=1);
|
||
|
||
namespace app\services\message\admin;
|
||
|
||
use app\dao\message\NoticeConfigDao;
|
||
use app\dao\message\NoticeTemplateDao;
|
||
use app\services\message\pipeline\SendNoticeService;
|
||
use app\services\message\pipeline\NoticeReadiness;
|
||
use crmeb\basic\BaseService;
|
||
use crmeb\exceptions\AdminException;
|
||
use crmeb\utils\FormBuilder as Form;
|
||
use think\facade\Log;
|
||
use think\facade\Route as Url;
|
||
|
||
/**
|
||
* 通知配置管理类
|
||
* Class NoticeConfigServices
|
||
* @package app\services\message\admin
|
||
* @method value($where, $value) 条件获取某个字段的值
|
||
*/
|
||
class NoticeConfigServices extends BaseService
|
||
{
|
||
|
||
/**
|
||
* @var NoticeConfigDao
|
||
*/
|
||
protected $dao;
|
||
|
||
/**
|
||
* @var NoticeTemplateDao
|
||
*/
|
||
protected $templateDao;
|
||
|
||
/**
|
||
* 获取全部通知配置
|
||
*
|
||
* @return array
|
||
*/
|
||
public function getAllNotce(): array
|
||
{
|
||
$notifications = $this->dao->search([])->select()->toArray();
|
||
if (empty($notifications)) {
|
||
return [];
|
||
}
|
||
|
||
$notificationIds = array_column($notifications, 'id');
|
||
$channels = $this->templateDao->getByNotificationIds($notificationIds);
|
||
|
||
$channelMap = [];
|
||
foreach ($channels as $channel) {
|
||
$channelMap[$channel['notification_id']][$channel['channel_key']] = $channel;
|
||
}
|
||
|
||
// 合并渠道支持范围与适配器类名(供发送侧按通知 type 过滤、按类名实例化渠道)
|
||
$supportMap = $this->getChannelSupportMap();
|
||
|
||
foreach ($notifications as &$notification) {
|
||
$notificationChannels = array_filter($channelMap[$notification['id']] ?? [],
|
||
fn($key) => $this->allowsChannel($notification, $key), ARRAY_FILTER_USE_KEY);
|
||
foreach ($notificationChannels as $key => &$channel) {
|
||
if (isset($supportMap[$key])) {
|
||
$channel['support_user'] = $supportMap[$key]['support_user'];
|
||
$channel['support_platform'] = $supportMap[$key]['support_platform'];
|
||
$channel['global_status'] = $supportMap[$key]['global_status'];
|
||
$channel['adapter_class'] = $supportMap[$key]['adapter_class'];
|
||
$channel['channel_name'] = $supportMap[$key]['channel_name'];
|
||
$channel['owner_plugin_id'] = $supportMap[$key]['owner_plugin_id'];
|
||
} else {
|
||
$channel['global_status'] = 0;
|
||
}
|
||
}
|
||
unset($channel);
|
||
$notification['channels'] = $notificationChannels;
|
||
}
|
||
|
||
return $notifications;
|
||
}
|
||
|
||
/**
|
||
* 获取渠道定义映射 [channel_key => ['support_user'=>int, 'support_platform'=>int, 'global_status'=>int, 'adapter_class'=>string, 'channel_name'=>string]]
|
||
* @return array
|
||
*/
|
||
protected function getChannelSupportMap(): array
|
||
{
|
||
try {
|
||
$channelDao = app()->make(\app\dao\message\NoticeChannelDao::class);
|
||
$channels = $channelDao->getList([], 'channel_key, channel_name, support_user, support_platform, status, adapter_class, owner_plugin_id');
|
||
$map = [];
|
||
foreach ($channels as $channel) {
|
||
$map[$channel['channel_key']] = [
|
||
'support_user' => (int)($channel['support_user'] ?? 1),
|
||
'support_platform' => (int)($channel['support_platform'] ?? 1),
|
||
'global_status' => (int)($channel['status'] ?? 0),
|
||
'adapter_class' => (string)($channel['adapter_class'] ?? ''),
|
||
'channel_name' => (string)($channel['channel_name'] ?? ''),
|
||
'owner_plugin_id' => (string)($channel['owner_plugin_id'] ?? ''),
|
||
];
|
||
}
|
||
return $map;
|
||
} catch (\Throwable $e) {
|
||
Log::error('获取渠道支持范围失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断渠道适配器是否已登记且可用:adapter_class 非空、类存在、归属模块已启用
|
||
* @param array|null $definition 渠道定义行(含 adapter_class / owner_plugin_id 字段)
|
||
* @return bool
|
||
*/
|
||
protected function adapterRegistered(?array $definition): bool
|
||
{
|
||
$adapter = (string)($definition['adapter_class'] ?? '');
|
||
if ($adapter === '' || !class_exists($adapter)) {
|
||
return false;
|
||
}
|
||
return \crmeb\plugin\ModuleStateChecker::isOwnerActive((string)($definition['owner_plugin_id'] ?? ''));
|
||
}
|
||
|
||
/**
|
||
* NoticeConfigServices constructor.
|
||
* @param NoticeConfigDao $dao
|
||
* @param NoticeTemplateDao $templateDao
|
||
*/
|
||
public function __construct(NoticeConfigDao $dao, NoticeTemplateDao $templateDao)
|
||
{
|
||
$this->dao = $dao;
|
||
$this->templateDao = $templateDao;
|
||
}
|
||
|
||
/**
|
||
* 单个配置
|
||
* @param array $where
|
||
* @return array
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function getOneNotce(array $where)
|
||
{
|
||
return $this->dao->getOne($where);
|
||
}
|
||
|
||
/**
|
||
* 后台获取列表
|
||
*
|
||
* 从 eb_notice_config 获取通知列表,并从 eb_notice_template 合并各渠道的开关状态。
|
||
* 解决列表页渠道开关因读写不同表导致状态不生效的问题。
|
||
*
|
||
* @param array $where
|
||
* @return array
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function getNotList(array $where)
|
||
{
|
||
$notifications = $this->dao->getList($where);
|
||
if (empty($notifications)) {
|
||
return [];
|
||
}
|
||
|
||
$notificationIds = array_column($notifications, 'id');
|
||
$channels = $this->templateDao->getByNotificationIds($notificationIds);
|
||
|
||
// 按 notification_id + channel_key 组织为嵌套结构,前端通过 row.channels[channelKey].status 读取
|
||
$channelMap = [];
|
||
foreach ($channels as $channel) {
|
||
$channelMap[$channel['notification_id']][$channel['channel_key']] = $channel;
|
||
}
|
||
|
||
$configDao = app()->make(\app\dao\message\NoticeChannelDao::class);
|
||
$dbConfigs = $configDao->getList([], 'channel_key, status, support_user, support_platform, adapter_class, owner_plugin_id');
|
||
$dbConfigMap = [];
|
||
foreach ($dbConfigs as $cfg) {
|
||
$dbConfigMap[$cfg['channel_key']] = $cfg;
|
||
}
|
||
|
||
foreach ($notifications as &$notification) {
|
||
$rowType = (int)$notification['type'];
|
||
$notification['channels'] = array_filter($channelMap[$notification['id']] ?? [], function ($channel) use ($dbConfigMap, $rowType, $notification) {
|
||
$key = $channel['channel_key'];
|
||
$definition = $dbConfigMap[$key] ?? null;
|
||
return $this->allowsChannel($notification, $key)
|
||
&& NoticeReadiness::channelReasons($key, $rowType, $definition, $this->adapterRegistered($definition)) === [];
|
||
});
|
||
}
|
||
|
||
return $notifications;
|
||
}
|
||
|
||
/**
|
||
* 获取单条数据
|
||
* @param array $where
|
||
* @return array
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function getNotInfo(array $where)
|
||
{
|
||
$type = (string)($where['type'] ?? '');
|
||
$definitions = app()->make(\app\dao\message\NoticeChannelDao::class)->getList([], 'channel_key, status, support_user, support_platform, adapter_class, owner_plugin_id');
|
||
$definitionMap = array_column($definitions, null, 'channel_key');
|
||
unset($where['type']);
|
||
$info = $this->dao->getOne($where);
|
||
// 记录不存在时抛出异常(如前端传入无效 id),避免返回空数组导致前端页面无提示空白
|
||
if (!$info) {
|
||
throw new AdminException('通知配置不存在');
|
||
}
|
||
$info = $info->toArray();
|
||
$info['scene_title'] = $info['title'];
|
||
|
||
// 只有核心 SQL / 插件 Installer 写入的模板才能在设置页出现。
|
||
$allChannels = $this->templateDao->getByNotificationIds([$info['id']]);
|
||
$channelMap = [];
|
||
foreach ($allChannels as $ch) {
|
||
if (!$this->allowsChannel($info, $ch['channel_key'])) continue;
|
||
if (NoticeReadiness::channelReasons($ch['channel_key'], (int)$info['type'],
|
||
$definitionMap[$ch['channel_key']] ?? null, $this->adapterRegistered($definitionMap[$ch['channel_key']] ?? null))) continue;
|
||
$channelMap[$ch['channel_key']] = $ch;
|
||
$info[$ch['channel_key']] = $ch['status'];
|
||
}
|
||
$info['channels'] = $channelMap;
|
||
if ($type !== '' && !isset($channelMap[$type])) throw new AdminException('该通知未声明此渠道');
|
||
if ($type === '') $type = (string)(array_key_first($channelMap) ?? '');
|
||
$info['selected_channel'] = $type;
|
||
if ($type === '') {
|
||
$info['variables'] = $this->parseVariableList($info['variable'] ?? '');
|
||
$info['readiness_reasons'] = [];
|
||
return $info;
|
||
}
|
||
|
||
// 获取当前选中渠道的详情,填充表单各字段
|
||
$channel = $this->templateDao->getByNotificationAndChannel($info['id'], $type);
|
||
if ($channel) {
|
||
$channel = $channel->toArray();
|
||
// 约定式字段映射:is_xxx 渠道 → xxx_text / xxx_title
|
||
// 新增渠道无需修改此处,自动适用(前提:前端表单字段遵循相同约定)
|
||
$shortKey = substr($type, 3); // 去掉 'is_' 前缀
|
||
$info['status'] = $channel['status'];
|
||
$info['content'] = $channel['template_content'] ?? '';
|
||
$info['title'] = $channel['template_title'] ?? '';
|
||
$info['channel_title'] = $channel['template_title'] ?? '';
|
||
$info[$shortKey . '_text'] = $channel['template_content'] ?? '';
|
||
$info[$shortKey . '_title'] = $channel['template_title'] ?? '';
|
||
|
||
// extra JSON 内字段直接合并到 $info,如 webhook / tempkey / tempid / key_list / sms_id / url
|
||
if (!empty($channel['extra'])) {
|
||
$extra = json_decode($channel['extra'], true) ?? [];
|
||
foreach ($extra as $key => $value) {
|
||
$info[$key] = $value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据渠道适配器类 getExtraFields() 声明,为未从 extra JSON 中读到的字段补默认值
|
||
$adapterClass = (string)($definitionMap[$type]['adapter_class'] ?? '');
|
||
$channelClass = $adapterClass !== '' && class_exists($adapterClass) ? $adapterClass : null;
|
||
if ($channelClass && method_exists($channelClass, 'getExtraFields')) {
|
||
foreach ($channelClass::getExtraFields() as $field => $default) {
|
||
$info[$field] = $info[$field] ?? $default;
|
||
}
|
||
}
|
||
|
||
// 解析 variable 字段为变量列表,供前端编辑模板时点击插入 {key}
|
||
// 与 key_list(微信/小程序模板字段)区分,独立用 variables 字段返回
|
||
$info['variables'] = $this->parseVariableList($info['variable'] ?? '');
|
||
$template = $channel ? $channel : ($channelMap[$type] ?? []);
|
||
$smsDriver = $this->enabledSmsDriverKey();
|
||
$hasRecipient = (int)$info['type'] !== 2 || $this->hasPlatformRecipient();
|
||
$info['readiness_reasons'] = NoticeReadiness::reasons($type, $template, $info['variable'] ?? '',
|
||
(int)$info['type'], $hasRecipient, $smsDriver, $this->enabledEmailDriverKey());
|
||
|
||
return $info;
|
||
}
|
||
|
||
/**
|
||
* 解析 variable 字段为变量列表
|
||
*
|
||
* variable 字段格式:{key1}描述,{key2}描述 (兼容旧版数据)
|
||
* 描述由扫描服务从业务监听器 sendMsg 数组的行内注释提取落库。
|
||
* 解析后返回 [{key, label}, ...],label 为描述,无描述时用 key 本身
|
||
*
|
||
* @param string $variable
|
||
* @return array
|
||
*/
|
||
protected function parseVariableList(string $variable): array
|
||
{
|
||
$list = [];
|
||
if (empty($variable)) {
|
||
return $list;
|
||
}
|
||
foreach (explode(',', $variable) as $item) {
|
||
$item = trim($item);
|
||
if ($item === '') {
|
||
continue;
|
||
}
|
||
if (preg_match('/^\{([^}]+)\}(.*)$/', $item, $m)) {
|
||
$list[] = [
|
||
'key' => $m[1],
|
||
'label' => $m[2] !== '' ? $m[2] : $m[1],
|
||
];
|
||
}
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 保存数据
|
||
* @param array $data
|
||
* @return bool|\crmeb\basic\BaseModel|null
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function saveData(array $data)
|
||
{
|
||
$type = $data['type'];
|
||
$id = $data['id'];
|
||
$info = $this->dao->get($id);
|
||
if (!$info) {
|
||
throw new AdminException('数据不存在');
|
||
}
|
||
$channel = $this->templateDao->getByNotificationAndChannel($id, $type);
|
||
$this->assertDeclaredTemplate($info, $type, $channel);
|
||
$global = app()->make(\app\dao\message\NoticeChannelDao::class)->getByChannelKey($type);
|
||
$scopeReasons = NoticeReadiness::channelReasons($type, (int)$info['type'], $global, $this->adapterRegistered($global));
|
||
if ($scopeReasons) throw new AdminException('通知渠道不可编辑:' . implode('、', $scopeReasons));
|
||
|
||
$notificationUpdate = [];
|
||
if (isset($data['name'])) $notificationUpdate['name'] = $data['name'];
|
||
if (isset($data['scene_title'])) $notificationUpdate['title'] = $data['scene_title'];
|
||
|
||
// 根据渠道适配器类的 getExtraFields() 声明,从表单数据提取额外配置字段
|
||
// 新增渠道只需在适配器类覆写 getExtraFields(),无需修改此处
|
||
$channelExtra = [];
|
||
$adapterClass = (string)($global['adapter_class'] ?? '');
|
||
$channelClass = $adapterClass !== '' && class_exists($adapterClass) ? $adapterClass : null;
|
||
if ($channelClass && method_exists($channelClass, 'getExtraFields')) {
|
||
foreach ($channelClass::getExtraFields() as $field => $default) {
|
||
if (array_key_exists($field, $data)) $channelExtra[$field] = $data[$field];
|
||
}
|
||
}
|
||
|
||
$previousExtra = $channel && !empty($channel['extra']) ? (json_decode((string)$channel['extra'], true) ?: []) : [];
|
||
$channelExtra = array_merge($previousExtra, $channelExtra);
|
||
$shortKey = substr($type, 3);
|
||
if ($type === 'is_sms' && isset($data['sms_id']) && $data['sms_id'] !== ($previousExtra['sms_id'] ?? '')) {
|
||
$channelExtra['sms_driver_key'] = $this->enabledSmsDriverKey();
|
||
}
|
||
$channelData = [
|
||
'notification_id' => $id,
|
||
'channel_key' => $type,
|
||
'status' => $data['status'] ?? $data[$type] ?? 2,
|
||
'template_title' => $data['channel_title'] ?? $data[$shortKey . '_title'] ?? $data['title'] ?? '',
|
||
'template_content' => $data['content'] ?? $data[$shortKey . '_text'] ?? '',
|
||
'extra' => !empty($channelExtra) ? json_encode($channelExtra) : '',
|
||
'updated_at' => time(),
|
||
];
|
||
if (!in_array((int)$channelData['status'], [1, 2], true)) throw new AdminException('通知状态无效');
|
||
$this->assertVerificationSmsEnabled($info, $type, (int)$channelData['status']);
|
||
if ((int)$channelData['status'] === 1) {
|
||
$reasons = NoticeReadiness::reasons($type, $channelData, $info['variable'] ?? '', (int)$info['type'],
|
||
(int)$info['type'] !== 2 || $this->hasPlatformRecipient(), $this->enabledSmsDriverKey(), $this->enabledEmailDriverKey());
|
||
if ($reasons) throw new AdminException('渠道尚未就绪:' . implode('、', array_unique($reasons)));
|
||
}
|
||
|
||
if ($notificationUpdate) $this->dao->update((int)$id, $notificationUpdate);
|
||
|
||
$res = $this->templateDao->update($channel->id, $channelData);
|
||
|
||
if ($res) {
|
||
app()->make(SendNoticeService::class)->clearCache();
|
||
}
|
||
return $res;
|
||
}
|
||
|
||
/**
|
||
* 列表快捷开关与编辑表单共享开启边界。
|
||
* @param int $id 通知配置 ID
|
||
* @param string $key 渠道标识
|
||
* @param int $status 1开启、2关闭
|
||
* @return bool
|
||
*/
|
||
public function setChannelStatus(int $id, string $key, int $status): bool
|
||
{
|
||
if (!in_array($status, [1, 2], true)) throw new AdminException('通知状态无效');
|
||
$notice = $this->dao->get($id);
|
||
if (!$notice) throw new AdminException('通知配置不存在');
|
||
$this->assertVerificationSmsEnabled($notice, $key, $status);
|
||
$template = $this->templateDao->getByNotificationAndChannel($id, $key);
|
||
$this->assertDeclaredTemplate($notice, $key, $template);
|
||
$global = app()->make(\app\dao\message\NoticeChannelDao::class)->getByChannelKey($key);
|
||
$scopeReasons = NoticeReadiness::channelReasons($key, (int)$notice['type'], $global, $this->adapterRegistered($global));
|
||
if ($scopeReasons) throw new AdminException('通知渠道不可编辑:' . implode('、', $scopeReasons));
|
||
if ($status === 1) {
|
||
$reasons = NoticeReadiness::reasons($key, $template ? $template->toArray() : [], $notice['variable'] ?? '',
|
||
(int)$notice['type'], (int)$notice['type'] !== 2 || $this->hasPlatformRecipient(),
|
||
$this->enabledSmsDriverKey(), $this->enabledEmailDriverKey());
|
||
if ($reasons) throw new AdminException('渠道尚未就绪:' . implode('、', array_unique($reasons)));
|
||
}
|
||
$result = $this->templateDao->updateStatus($id, $key, $status);
|
||
app()->make(SendNoticeService::class)->clearCache();
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 核心验证码只通过短信发送;其他消息以其持久化模板为渠道声明。
|
||
* @param array|\ArrayAccess $notice 通知配置
|
||
* @param string $key 渠道标识
|
||
* @return bool
|
||
*/
|
||
protected function allowsChannel($notice, string $key): bool
|
||
{
|
||
return (string)$notice['mark'] !== 'verify_code' || (int)$notice['type'] !== 1 || $key === 'is_sms';
|
||
}
|
||
|
||
/**
|
||
* 设置和快捷开关不得创建未由安装 SQL / Installer 登记的渠道模板。
|
||
* @param array|\ArrayAccess $notice 通知配置
|
||
* @param string $key 渠道标识
|
||
* @param mixed $template 模板记录
|
||
* @return void
|
||
*/
|
||
protected function assertDeclaredTemplate($notice, string $key, $template): void
|
||
{
|
||
if (!$template || !$this->allowsChannel($notice, $key)) throw new AdminException('该通知未声明此渠道');
|
||
}
|
||
|
||
/**
|
||
* 验证码直发依赖短信模板,后台不得关闭该核心通知。
|
||
* @param array|\ArrayAccess $notice 通知配置
|
||
* @param string $key 渠道标识
|
||
* @param int $status 模板状态
|
||
* @return void
|
||
*/
|
||
protected function assertVerificationSmsEnabled($notice, string $key, int $status): void
|
||
{
|
||
if ($key === 'is_sms' && (string)$notice['mark'] === 'verify_code'
|
||
&& (int)$notice['type'] === 1 && $status !== 1) {
|
||
throw new AdminException('短信验证码必须保持开启');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取tempid
|
||
* @param $type
|
||
* @return array
|
||
*/
|
||
public function getTempId($type)
|
||
{
|
||
return $this->dao->getTempId($type);
|
||
}
|
||
|
||
/**
|
||
* 获取tempkey
|
||
* @param $type
|
||
* @return array
|
||
* @throws \think\db\exception\DataNotFoundException
|
||
* @throws \think\db\exception\DbException
|
||
* @throws \think\db\exception\ModelNotFoundException
|
||
*/
|
||
public function getTempKey($type)
|
||
{
|
||
return $this->dao->getTempKey($type);
|
||
}
|
||
|
||
/** @return string 当前短信驱动标识 */
|
||
protected function enabledSmsDriverKey(): string
|
||
{
|
||
try {
|
||
return (string)(app()->make(\app\services\driver\sms\SmsDriverServices::class)->getEnabledDriver()['driver_key'] ?? '');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/** @return string 当前邮件驱动标识 */
|
||
protected function enabledEmailDriverKey(): string
|
||
{
|
||
try {
|
||
return (string)(app()->make(\app\services\driver\email\EmailDriverServices::class)->getEnabledDriver()['driver_key'] ?? '');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/** @return bool 是否存在平台站内信接收人 */
|
||
protected function hasPlatformRecipient(): bool
|
||
{
|
||
foreach ((array)event('AdminNoticeRecipientResolve', []) as $recipients) {
|
||
if (is_array($recipients) && $recipients) return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|