Files
ulthon_admin/extend/base/common/service/NoticeServiceBase.php

298 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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') {
// delete_time=0 过滤软删管理员,与 sceneOverview 的 SystemAdmin 模型口径对齐
$adminIds = Db::name('system_admin')->where('status', 1)->where('delete_time', 0)->column('id');
} else {
$adminIds = array_values(array_unique(array_map('intval', (array) $adminIds)));
}
$now = time();
// 主体创建与投递展开同事务:insertAll 中途异常时回滚,不残留零投递主体
[$notice, $targets] = Db::transaction(function () use ($adminIds, $channels, $content, $now, $options, $registered, $scene, $title) {
$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);
}
}
return [$notice, $targets];
});
Event::trigger('NoticeSended', [$notice, $targets]);
return (int) $notice->id;
}
/**
* 展开投递行:对每个 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,走复合索引).
* JOIN 过滤软删通知:与个人域 mine/read 口径一致,管理页删除后未读数同步下降.
*/
public static function unreadCount(int $adminId): int
{
return Db::name('system_notice_target')
->alias('target')
->join('system_notice notice', 'notice.id = target.notice_id')
->where('target.admin_id', $adminId)
->where('target.channel', self::CHANNEL_INMAIL)
->where('target.read_time', 0)
->where('target.status', SystemNoticeTargetBase::STATUS_SUCCESS)
->where('notice.delete_time', 0)
->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()]);
}
}