mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-11-08 03:50:50 +08:00
288 lines
11 KiB
PHP
288 lines
11 KiB
PHP
<?php
|
||
|
||
namespace base\common\service;
|
||
|
||
use app\admin\model\SystemNotice;
|
||
use base\admin\model\SystemNoticeTargetBase;
|
||
use base\common\notice\channel\EmailChannel;
|
||
use base\common\notice\channel\InMailChannel;
|
||
use base\common\notice\channel\SmsChannel;
|
||
use base\common\notice\channel\WechatChannel;
|
||
use think\facade\Db;
|
||
use think\facade\Event;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 通知服务(内核层).
|
||
*
|
||
* 统一发送入口:写 system_notice 主体 + 按 admin×渠道展开 system_notice_target 投递行。
|
||
* 站内信(inmail)行直接置成功终态即完成投递(投递即落库);
|
||
* 其余渠道行置待发送,由 timer 异步消费者按渠道驱动投递。
|
||
*
|
||
* 场景(scene)为代码级注册表(scenes()),不建数据库表;
|
||
* app 层入口类可 override scenes()/channels() 做覆盖合并,
|
||
* 如:return array_merge(parent::scenes(), ['my_scene' => [...]]);
|
||
*/
|
||
class NoticeServiceBase
|
||
{
|
||
/** 渠道标识:站内信 */
|
||
public const CHANNEL_INMAIL = 'inmail';
|
||
|
||
/** 渠道标识:邮件 */
|
||
public const CHANNEL_EMAIL = 'email';
|
||
|
||
/** 渠道标识:短信 */
|
||
public const CHANNEL_SMS = 'sms';
|
||
|
||
/** 渠道标识:公众号 */
|
||
public const CHANNEL_WECHAT = 'wechat';
|
||
|
||
/**
|
||
* 场景注册表(代码级,app 层可覆盖合并).
|
||
*
|
||
* 每项含 title(展示名)/ channels(该场景允许投递的渠道)/ locked(强制必选,
|
||
* 订阅不可关闭,发送侧豁免订阅过滤)。locked 不豁免渠道 ∩ 规则:
|
||
* 强投递同样仅限场景注册渠道。
|
||
*/
|
||
public static function scenes(): array
|
||
{
|
||
return [
|
||
'system' => ['title' => '系统公告', 'channels' => [self::CHANNEL_INMAIL], 'locked' => true],
|
||
'common' => ['title' => '通用通知', 'channels' => [self::CHANNEL_INMAIL, self::CHANNEL_EMAIL], 'locked' => false],
|
||
'admin_login' => ['title' => '登录提醒', 'channels' => [self::CHANNEL_INMAIL, self::CHANNEL_EMAIL], 'locked' => false],
|
||
'mall_stock' => ['title' => '库存预警', 'channels' => [self::CHANNEL_INMAIL, self::CHANNEL_EMAIL], 'locked' => false],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 渠道驱动映射(class-string,延迟实例化).
|
||
*
|
||
* email/sms/wechat 驱动类由后续任务提供类文件;class-string 字面量
|
||
* 为编译期解析,不触发 autoload,本任务内无实例化路径,映射先行声明安全。
|
||
*/
|
||
public static function channels(): array
|
||
{
|
||
return [
|
||
self::CHANNEL_INMAIL => InMailChannel::class,
|
||
self::CHANNEL_EMAIL => EmailChannel::class,
|
||
self::CHANNEL_SMS => SmsChannel::class,
|
||
self::CHANNEL_WECHAT => WechatChannel::class,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 统一发送通知.
|
||
*
|
||
* 渠道解析规则(统一口径):
|
||
* - 实际投递渠道 = options.channels ?? 注册场景的 scene.channels ?? [inmail]
|
||
* - 注册场景再 ∩ scene.channels(保序);locked 强投递同样仅限场景注册渠道
|
||
* - 未注册场景跳过 ∩,缺省 [inmail],并记 warning 放行
|
||
*
|
||
* @param array|int|string $adminIds 管理员ID数组、单个ID或 'all'(status=1 全体)
|
||
* @param string $title 标题
|
||
* @param string $content 内容
|
||
* @param array $options scene(缺省 common)/channels/biz_id/send_admin_id
|
||
* @return int 通知ID(system_notice.id)
|
||
*/
|
||
public static function send(array|int|string $adminIds, string $title, string $content, array $options = []): int
|
||
{
|
||
$scene = (string) ($options['scene'] ?? 'common');
|
||
$scenes = static::scenes();
|
||
$registered = isset($scenes[$scene]);
|
||
|
||
if (!$registered) {
|
||
Log::warning('notice send: unregistered scene, fallback to default channels', ['scene' => $scene]);
|
||
}
|
||
|
||
// 渠道解析:第一段 = options.channels ?? 注册场景的 scene.channels ?? [inmail]
|
||
if (isset($options['channels'])) {
|
||
$channels = array_values(array_unique(array_map('strval', (array) $options['channels'])));
|
||
} elseif ($registered) {
|
||
$channels = array_map('strval', $scenes[$scene]['channels']);
|
||
} else {
|
||
$channels = [self::CHANNEL_INMAIL];
|
||
}
|
||
|
||
// 渠道解析:第二段 = 注册场景再 ∩ scene.channels(保序),locked 不豁免
|
||
if ($registered) {
|
||
$channels = array_values(array_intersect($channels, $scenes[$scene]['channels']));
|
||
}
|
||
|
||
if ($adminIds === 'all') {
|
||
$adminIds = Db::name('system_admin')->where('status', 1)->column('id');
|
||
} else {
|
||
$adminIds = array_values(array_unique(array_map('intval', (array) $adminIds)));
|
||
}
|
||
|
||
$now = time();
|
||
|
||
$notice = SystemNotice::create([
|
||
'scene' => $scene,
|
||
'title' => $title,
|
||
'content' => $content,
|
||
'biz_id' => (string) ($options['biz_id'] ?? ''),
|
||
'send_admin_id' => (int) ($options['send_admin_id'] ?? 0),
|
||
]);
|
||
$noticeId = (int) $notice->id;
|
||
|
||
$targets = [];
|
||
if ($adminIds !== []) {
|
||
$targets = static::buildTargets($noticeId, $adminIds, $scene, $channels, $registered, $now);
|
||
if ($targets !== []) {
|
||
Db::name('system_notice_target')->insertAll($targets);
|
||
}
|
||
}
|
||
|
||
Event::trigger('NoticeSended', [$notice, $targets]);
|
||
|
||
return $noticeId;
|
||
}
|
||
|
||
/**
|
||
* 展开投递行:对每个 admin×每个实际渠道做订阅过滤与地址解析.
|
||
*
|
||
* @param int $noticeId 通知ID
|
||
* @param array $adminIds 管理员ID列表
|
||
* @param string $scene 场景
|
||
* @param array $channels 解析后的实际投递渠道(可能为空数组)
|
||
* @param bool $registered 场景是否已注册(决定 locked 语义是否生效)
|
||
* @param int $now 时间戳
|
||
* @return array 投递行列表(insertAll 格式)
|
||
*/
|
||
protected static function buildTargets(int $noticeId, array $adminIds, string $scene, array $channels, bool $registered, int $now): array
|
||
{
|
||
if ($channels === []) {
|
||
return [];
|
||
}
|
||
|
||
$locked = $registered && !empty(static::scenes()[$scene]['locked']);
|
||
|
||
// inmail 全局总闸:sysconfig('notify','notify_inmail_enable') 严格等于 0 时关闭;
|
||
// 键不存在(null/缺省 1)视为开。DB value 为字符串,须强转 int 后比较。
|
||
$inmailEnabled = true;
|
||
if (in_array(self::CHANNEL_INMAIL, $channels, true)) {
|
||
$inmailEnabled = (int) sysconfig('notify', 'notify_inmail_enable', 1) !== 0;
|
||
}
|
||
|
||
// 一次取齐 email 地址映射(email 渠道地址 = admin.email)
|
||
$adminEmailMap = Db::name('system_admin')
|
||
->whereIn('id', $adminIds)
|
||
->column('email', 'id');
|
||
|
||
$targets = [];
|
||
foreach ($adminIds as $adminId) {
|
||
$adminId = (int) $adminId;
|
||
foreach ($channels as $channel) {
|
||
// locked 场景强制投递;否则按订阅矩阵过滤(status=0 退订即跳过该渠道)
|
||
if (!$locked && !static::isSubscribed($adminId, $scene, $channel)) {
|
||
continue;
|
||
}
|
||
|
||
$targetAddr = '';
|
||
if ($channel === self::CHANNEL_EMAIL) {
|
||
$targetAddr = (string) ($adminEmailMap[$adminId] ?? '');
|
||
if ($targetAddr === '') {
|
||
Log::info('notice send: skip email channel, admin has no email', ['admin_id' => $adminId, 'notice_id' => $noticeId]);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if ($channel === self::CHANNEL_INMAIL && !$inmailEnabled) {
|
||
continue;
|
||
}
|
||
|
||
// inmail 直接置成功终态(投递即落库),其余渠道待消费者投递
|
||
$isInmail = $channel === self::CHANNEL_INMAIL;
|
||
$status = $isInmail ? SystemNoticeTargetBase::STATUS_SUCCESS : SystemNoticeTargetBase::STATUS_PENDING;
|
||
|
||
$targets[] = [
|
||
'notice_id' => $noticeId,
|
||
'admin_id' => $adminId,
|
||
'channel' => $channel,
|
||
'target_addr' => $targetAddr,
|
||
'status' => $status,
|
||
'send_time' => $isInmail ? $now : 0,
|
||
'read_time' => 0,
|
||
'create_time' => $now,
|
||
'update_time' => $now,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $targets;
|
||
}
|
||
|
||
/**
|
||
* 是否订阅指定场景渠道.
|
||
*
|
||
* locked 场景恒 true;subscribe 表无记录视为默认订阅;
|
||
* 仅 status=0(显式退订)返回 false。
|
||
*/
|
||
public static function isSubscribed(int $adminId, string $scene, string $channel): bool
|
||
{
|
||
$scenes = static::scenes();
|
||
if (isset($scenes[$scene]) && !empty($scenes[$scene]['locked'])) {
|
||
return true;
|
||
}
|
||
|
||
$status = Db::name('system_notice_subscribe')
|
||
->where('admin_id', $adminId)
|
||
->where('scene', $scene)
|
||
->where('channel', $channel)
|
||
->value('status');
|
||
|
||
if ($status === null) {
|
||
return true;
|
||
}
|
||
|
||
return (int) $status === 1;
|
||
}
|
||
|
||
/**
|
||
* 未读站内信数(channel=inmail AND read_time=0 AND status=2,走复合索引).
|
||
*/
|
||
public static function unreadCount(int $adminId): int
|
||
{
|
||
return Db::name('system_notice_target')
|
||
->where('admin_id', $adminId)
|
||
->where('channel', self::CHANNEL_INMAIL)
|
||
->where('read_time', 0)
|
||
->where('status', SystemNoticeTargetBase::STATUS_SUCCESS)
|
||
->count();
|
||
}
|
||
|
||
/**
|
||
* 标记站内信已读(带 admin_id 条件防越权).
|
||
*
|
||
* @param array $targetIds 投递行ID列表
|
||
* @param int $adminId 当前管理员
|
||
* @return int 实际标记的行数
|
||
*/
|
||
public static function markRead(array $targetIds, int $adminId): int
|
||
{
|
||
if ($targetIds === []) {
|
||
return 0;
|
||
}
|
||
|
||
return Db::name('system_notice_target')
|
||
->where('admin_id', $adminId)
|
||
->whereIn('id', $targetIds)
|
||
->where('read_time', 0)
|
||
->update(['read_time' => time()]);
|
||
}
|
||
|
||
/**
|
||
* 全部站内信标记已读(带 admin_id 条件防越权).
|
||
*
|
||
* @return int 实际标记的行数
|
||
*/
|
||
public static function markAllRead(int $adminId): int
|
||
{
|
||
return Db::name('system_notice_target')
|
||
->where('admin_id', $adminId)
|
||
->where('channel', self::CHANNEL_INMAIL)
|
||
->where('read_time', 0)
|
||
->update(['read_time' => time()]);
|
||
}
|
||
}
|