[...]]); */ 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()]); } }