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