feat(notice): NoticeService 发送核心/场景注册/订阅过滤与站内信驱动及测试

This commit is contained in:
augushong
2026-10-12 11:13:02 +00:00
parent 05ed02db34
commit a7261d0c21
5 changed files with 773 additions and 0 deletions

View 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
{
}

View 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;
}

View 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;
}
}

View 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()]);
}
}

View 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();
}
}