mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-11-07 19:40:49 +08:00
feat(notice): NoticeService 发送核心/场景注册/订阅过滤与站内信驱动及测试
This commit is contained in:
15
app/common/service/NoticeService.php
Normal file
15
app/common/service/NoticeService.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use base\common\service\NoticeServiceBase;
|
||||
|
||||
/**
|
||||
* 通知服务(app 层入口).
|
||||
*
|
||||
* 业务定制点:可 override scenes()/channels() 做覆盖合并,如
|
||||
* return array_merge(parent::scenes(), ['my_scene' => ['title' => '...', 'channels' => [...], 'locked' => false]]);
|
||||
*/
|
||||
class NoticeService extends NoticeServiceBase
|
||||
{
|
||||
}
|
||||
34
extend/base/common/notice/NoticeChannelInterface.php
Normal file
34
extend/base/common/notice/NoticeChannelInterface.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\notice;
|
||||
|
||||
/**
|
||||
* 通知投递渠道驱动契约.
|
||||
*
|
||||
* notice/ 为领域子模块目录:渠道驱动与传输客户端不是 service 注册服务,
|
||||
* 放 service 树将违反 ulthon-naming-convention 的 Service 后缀规范,
|
||||
* 参照 base\common\event、base\common\scheme 的独立模块目录先例;
|
||||
* PSR-4 base\ → extend/base 自动可载。
|
||||
*
|
||||
* 实现约定:
|
||||
* - name() 返回渠道标识(system_notice_target.channel 的取值,如 inmail/email)
|
||||
* - send() 执行真实投递,抛出异常视为投递失败(消费者据此重试/置终态)
|
||||
* - 驱动不做任何 DB 写入:投递行由 NoticeService::send() 统一落库,
|
||||
* 消费者(timer 异步投递)按行状态调用本契约
|
||||
*/
|
||||
interface NoticeChannelInterface
|
||||
{
|
||||
/**
|
||||
* 渠道标识.
|
||||
*/
|
||||
public static function name(): string;
|
||||
|
||||
/**
|
||||
* 投递一条通知.
|
||||
*
|
||||
* @param array $target 投递行(system_notice_target 一行,含 channel/target_addr/status 等)
|
||||
* @param array $notice 通知主体(system_notice 一行)
|
||||
* @return bool 是否成功;抛出异常视为失败
|
||||
*/
|
||||
public function send(array $target, array $notice): bool;
|
||||
}
|
||||
27
extend/base/common/notice/channel/InMailChannel.php
Normal file
27
extend/base/common/notice/channel/InMailChannel.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\notice\channel;
|
||||
|
||||
use base\common\notice\NoticeChannelInterface;
|
||||
|
||||
/**
|
||||
* 站内信渠道驱动.
|
||||
*
|
||||
* 站内信投递即落库:NoticeService::send() 写 target 行时已置 status=2(成功)、
|
||||
* send_time=当前时间,数据落库即为投递完成,本驱动无需任何外部动作。
|
||||
* 顶栏铃铛/我的消息按 admin_id + channel=inmail 读取行数据呈现;
|
||||
* 消费者(timer 异步投递)永不消费 inmail 行(send 侧直接终态,不入队列)。
|
||||
*/
|
||||
class InMailChannel implements NoticeChannelInterface
|
||||
{
|
||||
public static function name(): string
|
||||
{
|
||||
return 'inmail';
|
||||
}
|
||||
|
||||
public function send(array $target, array $notice): bool
|
||||
{
|
||||
// 站内信投递即落库:send() 阶段已完成写入并置成功终态,此处恒成功。
|
||||
return true;
|
||||
}
|
||||
}
|
||||
287
extend/base/common/service/NoticeServiceBase.php
Normal file
287
extend/base/common/service/NoticeServiceBase.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
410
tests/Feature/NoticeServiceTest.php
Normal file
410
tests/Feature/NoticeServiceTest.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\Feature;
|
||||
|
||||
use app\common\service\NoticeService;
|
||||
use app\common\test\TestCase;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* NoticeService 核心行为集成测试.
|
||||
*
|
||||
* 覆盖 11 个契约点(与计划任务 3 逐项对齐,每项一个 test 方法):
|
||||
* 1. send 主体 + target 展开
|
||||
* 2. 'all' 广播(仅 status=1,含无 email 管理员)
|
||||
* 3. channels 缺省按 scene.channels 展开
|
||||
* 4. 订阅过滤(退订行拦截投递)
|
||||
* 5. locked 场景强投递
|
||||
* 6. locked 场景请求未注册渠道被 ∩ 掉(零 target)
|
||||
* 7. 未注册 scene 放行(缺省 [inmail])
|
||||
* 8. 无 email 跳过 email 渠道
|
||||
* 9. inmail 全局总闸关闭跳过(键不存在视为开)
|
||||
* 10. unreadCount/markRead/markAllRead 数字正确
|
||||
* 11. markRead 越权防护
|
||||
*
|
||||
* fixture 均为事务内自造数据,tearDown 自动回滚;缓存清理显式执行
|
||||
* (Cache 不受事务回滚影响,见 setInmailSwitch/clearInmailSwitch)。
|
||||
*/
|
||||
class NoticeServiceTest extends TestCase
|
||||
{
|
||||
// =========================================================================
|
||||
// 1. send 主体 + target 展开
|
||||
// =========================================================================
|
||||
|
||||
public function test_send_creates_notice_and_expands_inmail_target(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
$noticeId = NoticeService::send([$adminId], '测试标题', '测试内容');
|
||||
|
||||
$this->assertGreaterThan(0, $noticeId);
|
||||
$this->assertDatabaseHas('system_notice', [
|
||||
'id' => $noticeId,
|
||||
'scene' => 'common',
|
||||
'title' => '测试标题',
|
||||
'content' => '测试内容',
|
||||
'biz_id' => '',
|
||||
'send_admin_id' => 0,
|
||||
]);
|
||||
|
||||
$target = Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)
|
||||
->where('admin_id', $adminId)
|
||||
->find();
|
||||
$this->assertNotNull($target, 'target 行应存在');
|
||||
$this->assertSame('inmail', $target['channel']);
|
||||
$this->assertSame(2, (int) $target['status'], 'inmail 投递即落库,直接成功');
|
||||
$this->assertSame('', $target['target_addr'], 'inmail 无需外部地址');
|
||||
$this->assertGreaterThan(0, (int) $target['send_time']);
|
||||
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId)->count());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. 'all' 广播
|
||||
// =========================================================================
|
||||
|
||||
public function test_send_all_broadcasts_to_active_admins_only(): void
|
||||
{
|
||||
// 记录广播前的启用管理员基数(测试库含初始数据,不能假设从 0 开始)
|
||||
$activeBefore = Db::name('system_admin')->where('status', 1)->count();
|
||||
$withMailBefore = Db::name('system_admin')->where('status', 1)->where('email', '<>', '')->count();
|
||||
|
||||
$adminWithMail = $this->createAdmin(['email' => 'all_a@test.com']);
|
||||
$adminNoMail = $this->createAdmin();
|
||||
$adminDisabled = $this->createAdmin(['status' => 0]);
|
||||
|
||||
$noticeId = NoticeService::send('all', '广播标题', '广播内容');
|
||||
|
||||
$this->assertGreaterThan(0, $noticeId);
|
||||
|
||||
// 总展开数 = 全体启用管理员各 1 行 inmail + 有 email 的管理员各 1 行 email
|
||||
$expectedTotal = ($activeBefore + 2) + ($withMailBefore + 1);
|
||||
$actualTotal = Db::name('system_notice_target')->where('notice_id', $noticeId)->count();
|
||||
$this->assertSame($expectedTotal, $actualTotal, "target 总数应为 {$expectedTotal},实际 {$actualTotal}");
|
||||
|
||||
// 禁用管理员不在广播内
|
||||
$this->assertSame(
|
||||
0,
|
||||
Db::name('system_notice_target')->where('notice_id', $noticeId)->where('admin_id', $adminDisabled)->count(),
|
||||
'status=0 的管理员不应收到广播'
|
||||
);
|
||||
|
||||
// 无 email 管理员仅 inmail 一行
|
||||
$this->assertSame(1, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->count());
|
||||
$this->assertSame(1, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->where('channel', 'inmail')->count());
|
||||
|
||||
// 有 email 管理员 inmail + email 两行,email 行待发
|
||||
$this->assertSame(2, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->count());
|
||||
$emailRow = Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->where('channel', 'email')->find();
|
||||
$this->assertNotNull($emailRow);
|
||||
$this->assertSame(0, (int) $emailRow['status'], 'email 渠道应置待发由消费者投递');
|
||||
$this->assertSame('all_a@test.com', $emailRow['target_addr']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. channels 缺省按 scene.channels 展开
|
||||
// =========================================================================
|
||||
|
||||
public function test_send_without_channels_expands_scene_channels(): void
|
||||
{
|
||||
$adminNoMail = $this->createAdmin();
|
||||
$adminWithMail = $this->createAdmin(['email' => 'scene@test.com']);
|
||||
|
||||
// admin_login 注册渠道 [inmail,email],不传 channels 按场景展开
|
||||
$noticeId = NoticeService::send([$adminNoMail, $adminWithMail], '登录提醒', '内容', ['scene' => 'admin_login']);
|
||||
|
||||
// 无 email 管理员:email 被跳过,仅 inmail 一行
|
||||
$this->assertSame(1, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->count());
|
||||
$this->assertSame(1, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminNoMail)->where('channel', 'inmail')->count());
|
||||
|
||||
// 有 email 管理员:两行,email 行地址正确
|
||||
$this->assertSame(2, Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)->where('admin_id', $adminWithMail)->count());
|
||||
$this->assertDatabaseHas('system_notice_target', [
|
||||
'notice_id' => $noticeId,
|
||||
'admin_id' => $adminWithMail,
|
||||
'channel' => 'email',
|
||||
'target_addr' => 'scene@test.com',
|
||||
'status' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. 订阅过滤
|
||||
// =========================================================================
|
||||
|
||||
public function test_unsubscribe_row_filters_channel_delivery(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
// 默认订阅:第一次 send 产生 inmail 行
|
||||
$noticeId1 = NoticeService::send([$adminId], '第一次', '内容');
|
||||
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId1)->count());
|
||||
|
||||
// 显式退订 common 场景的 inmail
|
||||
$this->insertSubscribe($adminId, 'common', 'inmail', 0);
|
||||
|
||||
$noticeId2 = NoticeService::send([$adminId], '第二次', '内容');
|
||||
// notice 主体仍存在,但零 target(email 渠道因无地址跳过,inmail 被退订过滤)
|
||||
$this->assertDatabaseHas('system_notice', ['id' => $noticeId2]);
|
||||
$this->assertSame(
|
||||
0,
|
||||
Db::name('system_notice_target')->where('notice_id', $noticeId2)->count(),
|
||||
'全部退订 common 后不应产生任何 target 行'
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5. locked 强投递
|
||||
// =========================================================================
|
||||
|
||||
public function test_locked_scene_forces_delivery_despite_unsubscribe(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
$this->insertSubscribe($adminId, 'system', 'inmail', 0);
|
||||
|
||||
$noticeId = NoticeService::send([$adminId], '系统公告', '内容', ['scene' => 'system']);
|
||||
|
||||
$target = Db::name('system_notice_target')
|
||||
->where('notice_id', $noticeId)
|
||||
->where('admin_id', $adminId)
|
||||
->find();
|
||||
$this->assertNotNull($target, 'locked 场景即使退订也强制投递');
|
||||
$this->assertSame('inmail', $target['channel']);
|
||||
$this->assertSame(2, (int) $target['status']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 6. locked 场景请求未注册渠道被 ∩ 掉
|
||||
// =========================================================================
|
||||
|
||||
public function test_locked_scene_intersects_requested_channels(): void
|
||||
{
|
||||
$adminId = $this->createAdmin(['email' => 'locked@test.com']);
|
||||
|
||||
// system 场景注册渠道仅 [inmail];locked 不豁免 ∩,请求 email 应被裁掉 → 零 target
|
||||
$noticeId = NoticeService::send([$adminId], '系统公告', '内容', ['scene' => 'system', 'channels' => ['email']]);
|
||||
|
||||
$this->assertDatabaseHas('system_notice', ['id' => $noticeId]);
|
||||
$this->assertSame(
|
||||
0,
|
||||
Db::name('system_notice_target')->where('notice_id', $noticeId)->count(),
|
||||
'locked 场景请求未注册渠道应被 ∩ 掉,零 target'
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 7. 未注册 scene 放行
|
||||
// =========================================================================
|
||||
|
||||
public function test_unregistered_scene_passes_with_default_inmail(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
// 未注册场景:Log::warning 后放行,缺省渠道 [inmail]。
|
||||
// warning 断言取舍:框架 Log::listen 实为 LogWrite 事件监听,
|
||||
// 仅在日志通道 save 时触发(请求收尾),CLI 测试进程不触发,
|
||||
// 监听式断言不可行 —— 此处断言行为(放行 + 缺省 inmail 展开)。
|
||||
$noticeId = NoticeService::send([$adminId], '业务自定义', '内容', ['scene' => 'totally_new_scene']);
|
||||
|
||||
$this->assertDatabaseHas('system_notice', ['id' => $noticeId, 'scene' => 'totally_new_scene']);
|
||||
$this->assertSame(1, Db::name('system_notice_target')->where('notice_id', $noticeId)->count());
|
||||
$this->assertDatabaseHas('system_notice_target', [
|
||||
'notice_id' => $noticeId,
|
||||
'channel' => 'inmail',
|
||||
'status' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 8. 无 email 跳过 email 渠道
|
||||
// =========================================================================
|
||||
|
||||
public function test_missing_email_skips_email_channel(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
// 显式请求 email 渠道但管理员无 email 值:跳过该渠道,不写行
|
||||
$noticeId = NoticeService::send([$adminId], '标题', '内容', ['scene' => 'common', 'channels' => ['email']]);
|
||||
|
||||
$this->assertDatabaseHas('system_notice', ['id' => $noticeId]);
|
||||
$this->assertSame(
|
||||
0,
|
||||
Db::name('system_notice_target')->where('notice_id', $noticeId)->count(),
|
||||
'无 email 值的管理员不应产生 email target 行'
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 9. inmail 全局总闸
|
||||
// =========================================================================
|
||||
|
||||
public function test_inmail_global_switch_off_skips_inmail(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
// 插入关闭配置行(value='0')并清 sysconfig 缓存(缓存不受事务回滚影响)
|
||||
$this->setInmailSwitch('0');
|
||||
|
||||
$noticeId1 = NoticeService::send([$adminId], '总闸关闭', '内容');
|
||||
$this->assertDatabaseHas('system_notice', ['id' => $noticeId1]);
|
||||
$this->assertSame(
|
||||
0,
|
||||
Db::name('system_notice_target')->where('notice_id', $noticeId1)->count(),
|
||||
'notify_inmail_enable=0 时 inmail 渠道应全局跳过'
|
||||
);
|
||||
|
||||
// 移除配置行:键不存在视为开(全局总闸默认放行)
|
||||
$this->clearInmailSwitch();
|
||||
|
||||
$noticeId2 = NoticeService::send([$adminId], '总闸恢复', '内容');
|
||||
$this->assertDatabaseHas('system_notice_target', [
|
||||
'notice_id' => $noticeId2,
|
||||
'channel' => 'inmail',
|
||||
'status' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 10. unreadCount / markRead / markAllRead
|
||||
// =========================================================================
|
||||
|
||||
public function test_unread_count_and_read_actions(): void
|
||||
{
|
||||
$adminId = $this->createAdmin();
|
||||
|
||||
NoticeService::send([$adminId], '第一条', '内容');
|
||||
NoticeService::send([$adminId], '第二条', '内容');
|
||||
NoticeService::send([$adminId], '第三条', '内容');
|
||||
$this->assertSame(3, NoticeService::unreadCount($adminId));
|
||||
|
||||
$targetIds = Db::name('system_notice_target')
|
||||
->where('admin_id', $adminId)
|
||||
->where('read_time', 0)
|
||||
->column('id');
|
||||
|
||||
// 标记两条已读
|
||||
$marked = NoticeService::markRead([$targetIds[0], $targetIds[1]], $adminId);
|
||||
$this->assertSame(2, $marked);
|
||||
$this->assertSame(1, NoticeService::unreadCount($adminId));
|
||||
$this->assertGreaterThan(0, (int) Db::name('system_notice_target')->where('id', $targetIds[0])->value('read_time'));
|
||||
|
||||
// 重复标记不再增加
|
||||
$this->assertSame(0, NoticeService::markRead([$targetIds[0]], $adminId), '已读行不应被重复标记');
|
||||
|
||||
// 全部已读
|
||||
$this->assertSame(1, NoticeService::markAllRead($adminId));
|
||||
$this->assertSame(0, NoticeService::unreadCount($adminId));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 11. markRead 越权防护
|
||||
// =========================================================================
|
||||
|
||||
public function test_mark_read_cannot_touch_other_admins_rows(): void
|
||||
{
|
||||
$adminA = $this->createAdmin();
|
||||
$adminB = $this->createAdmin();
|
||||
|
||||
NoticeService::send([$adminA], 'A的通知', '内容');
|
||||
NoticeService::send([$adminB], 'B的通知', '内容');
|
||||
$this->assertSame(1, NoticeService::unreadCount($adminA));
|
||||
$this->assertSame(1, NoticeService::unreadCount($adminB));
|
||||
|
||||
$bTargetId = (int) Db::name('system_notice_target')
|
||||
->where('admin_id', $adminB)
|
||||
->value('id');
|
||||
|
||||
// adminA 尝试读 adminB 的行:影响 0 行,adminB 数据不受影响
|
||||
$this->assertSame(0, NoticeService::markRead([$bTargetId], $adminA), 'markRead 不得触碰他人行');
|
||||
$this->assertSame(0, (int) Db::name('system_notice_target')->where('id', $bTargetId)->value('read_time'));
|
||||
$this->assertSame(1, NoticeService::unreadCount($adminB));
|
||||
|
||||
// markAllRead 同样带 admin_id 条件:只影响自己
|
||||
NoticeService::markAllRead($adminA);
|
||||
$this->assertSame(0, NoticeService::unreadCount($adminA));
|
||||
$this->assertSame(1, NoticeService::unreadCount($adminB), 'markAllRead 不得触碰他人行');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// fixture 工厂(事务内运行,tearDown 自动回滚)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 造启用管理员.
|
||||
*
|
||||
* @param array $overrides 字段覆盖(email/status 等)
|
||||
* @return int 管理员ID
|
||||
*/
|
||||
private function createAdmin(array $overrides = []): int
|
||||
{
|
||||
$data = array_merge([
|
||||
'username' => 'ntc_' . uniqid(),
|
||||
'password' => 'not-a-real-password',
|
||||
'nickname' => '通知测试用户',
|
||||
'email' => '',
|
||||
'status' => 1,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
'delete_time' => 0,
|
||||
], $overrides);
|
||||
|
||||
return (int) Db::name('system_admin')->insertGetId($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入订阅矩阵行.
|
||||
*/
|
||||
private function insertSubscribe(int $adminId, string $scene, string $channel, int $status): void
|
||||
{
|
||||
Db::name('system_notice_subscribe')->insert([
|
||||
'admin_id' => $adminId,
|
||||
'scene' => $scene,
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写 notify 组 inmail 总闸配置行并清缓存.
|
||||
*
|
||||
* sysconfig 缓存不受事务回滚影响:非 '0' 值会缓存 3600 秒,
|
||||
* 必须显式清 tag 才能让后续读取看到事务内的新值。
|
||||
*/
|
||||
private function setInmailSwitch(string $value): void
|
||||
{
|
||||
Db::name('system_config')->insert([
|
||||
'name' => 'notify_inmail_enable',
|
||||
'group' => 'notify',
|
||||
'value' => $value,
|
||||
'remark' => 'NoticeServiceTest fixture',
|
||||
'sort' => 100,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Cache::tag('sysconfig')->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 notify 组 inmail 总闸配置行并清缓存.
|
||||
*/
|
||||
private function clearInmailSwitch(): void
|
||||
{
|
||||
Db::name('system_config')
|
||||
->where('group', 'notify')
|
||||
->where('name', 'notify_inmail_enable')
|
||||
->delete();
|
||||
Cache::tag('sysconfig')->clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user