feat(notice): 投递明细/失败重发/只读场景总览

This commit is contained in:
augushong
2026-10-12 12:41:03 +00:00
parent ca16cd56de
commit cc1459383f
5 changed files with 247 additions and 6 deletions

View File

@@ -4,6 +4,7 @@ namespace base\admin\controller\system;
use app\admin\model\SystemAdmin;
use app\admin\model\SystemNotice;
use app\admin\model\SystemNoticeSubscribe;
use app\admin\model\SystemNoticeTarget;
use app\admin\service\annotation\ControllerAnnotation;
use app\admin\service\annotation\NodeAnotation;
@@ -110,12 +111,7 @@ class NoticeBase extends AdminController
$statusCount[$group['channel']][$group['status']] = (int) $group['cnt'];
}
$channelNames = [
NoticeService::CHANNEL_INMAIL => '站内信',
NoticeService::CHANNEL_EMAIL => '邮件',
NoticeService::CHANNEL_SMS => '短信',
NoticeService::CHANNEL_WECHAT => '公众号',
];
$channelNames = $this->channelNames();
$delivery = [];
foreach ($statusCount as $channel => $counts) {
$delivery[] = [
@@ -128,6 +124,41 @@ class NoticeBase extends AdminController
];
}
// 投递明细:全量加载(行数=接收方数,量可控)
$targets = SystemNoticeTarget::where('notice_id', $id)
->order('id')
->select();
// 接收人昵称:whereIn 单查映射(禁 withJoin,system_admin 与主表存在同名字段)
$adminIds = array_values(array_filter(array_unique($targets->column('admin_id'))));
$nicknames = [];
if (!empty($adminIds)) {
$nicknames = SystemAdmin::whereIn('id', $adminIds)->column('nickname', 'id');
}
$statusMeta = [
SystemNoticeTarget::STATUS_PENDING => ['name' => '待发', 'class' => 'layui-badge layui-bg-blue'],
SystemNoticeTarget::STATUS_SENDING => ['name' => '发送中', 'class' => 'layui-badge layui-bg-orange'],
SystemNoticeTarget::STATUS_SUCCESS => ['name' => '成功', 'class' => 'layui-badge layui-bg-green'],
SystemNoticeTarget::STATUS_FAILED => ['name' => '失败', 'class' => 'layui-badge'],
];
$targetRows = [];
foreach ($targets as $target) {
$status = (int) $target->status;
$targetRows[] = [
'id' => $target->id,
'admin_nickname' => $nicknames[$target->admin_id] ?? ('#' . $target->admin_id),
'target_addr' => $target->target_addr,
'channel_name' => $channelNames[$target->channel] ?? $target->channel,
'status' => $status,
'status_name' => $statusMeta[$status]['name'] ?? (string) $status,
'status_class' => $statusMeta[$status]['class'] ?? 'layui-badge layui-bg-gray',
'retry_count' => (int) $target->retry_count,
'error_message' => (string) $target->error_message,
'send_time' => (int) $target->send_time,
];
}
// 场景标题(scenes() 动态映射,未注册场景回退原值)
$sceneTitle = NoticeService::scenes()[$row->scene]['title'] ?? $row->scene;
@@ -136,6 +167,7 @@ class NoticeBase extends AdminController
$this->assign('sender_nickname', $senderNickname);
$this->assign('scene_title', $sceneTitle);
$this->assign('delivery', $delivery);
$this->assign('target_rows', $targetRows);
return $this->fetch();
}
@@ -182,4 +214,93 @@ class NoticeBase extends AdminController
}
$save ? $this->success('保存成功') : $this->error('未做变更');
}
/**
* @NodeAnotation(title="失败重发")
*
* POST notice_id(批量重置该通知全部失败投递)或 target_id(单行)。
* 原子条件 UPDATE:仅命中 status=3 的失败行(已成功/发送中行不受影响),
* 影响行数即真实重置数。重置后由 timer 消费者按退避策略重新消费。
*/
public function resend()
{
$this->checkPostRequest();
$noticeId = $this->request->post('notice_id/d', 0);
$targetId = $this->request->post('target_id/d', 0);
if (empty($noticeId) && empty($targetId)) {
$this->error('缺少参数:notice_id 或 target_id');
}
$query = SystemNoticeTarget::where('status', SystemNoticeTarget::STATUS_FAILED);
if ($noticeId > 0) {
$query->where('notice_id', $noticeId);
} else {
$query->where('id', $targetId);
}
$count = $query->update([
'status' => SystemNoticeTarget::STATUS_PENDING,
'retry_count' => 0,
'next_retry_time' => 0,
'error_message' => '',
]);
$count > 0
? $this->success('已将 ' . $count . ' 条失败投递重置为待发送')
: $this->error('没有可重发的失败投递(仅失败状态可重发)');
}
/**
* @NodeAnotation(title="场景总览")
*
* 只读渲染 scenes() 注册表:标题/渠道/是否必选/每场景订阅统计(分式展示)。
* 场景为代码级注册表,本页不提供任何写操作。
*/
public function sceneOverview()
{
// 每场景订阅统计:一条 group 查询(scene, count(distinct admin_id))
$subGroups = SystemNoticeSubscribe::where('status', 1)
->field('scene, count(distinct admin_id) as admin_cnt')
->group('scene')
->select();
$subscribeCounts = [];
foreach ($subGroups as $item) {
$subscribeCounts[$item['scene']] = (int) $item['admin_cnt'];
}
// 全体启用管理员数(软删自动排除)一次单查
$totalAdmins = SystemAdmin::where('status', 1)->count();
$channelNames = $this->channelNames();
$rows = [];
foreach (NoticeService::scenes() as $scene => $item) {
$rows[] = [
'scene' => $scene,
'title' => $item['title'],
'channel_names' => implode('、', array_map(function ($channel) use ($channelNames) {
return $channelNames[$channel] ?? $channel;
}, $item['channels'])),
'locked' => (int) $item['locked'],
'subscribed' => $subscribeCounts[$scene] ?? 0,
'total_admins' => $totalAdmins,
];
}
$this->assign('rows', $rows);
$this->assign('total_admins', $totalAdmins);
return $this->fetch();
}
/**
* 渠道标识到中文名的映射(详情汇总/明细/场景总览共用).
*/
private function channelNames(): array
{
return [
NoticeService::CHANNEL_INMAIL => '站内信',
NoticeService::CHANNEL_EMAIL => '邮件',
NoticeService::CHANNEL_SMS => '短信',
NoticeService::CHANNEL_WECHAT => '公众号',
];
}
}

View File

@@ -79,6 +79,62 @@
</div>
</div>
<div class="detail-field-item detail-field-full">
<div class="detail-field-label">投递明细</div>
<div class="detail-field-value">
{notempty name="target_rows"}
<table class="layui-table" style="margin:0;">
<thead>
<tr>
<th>明细ID</th>
<th>接收人</th>
<th>渠道</th>
<th>状态</th>
<th>重试次数</th>
<th>错误信息</th>
<th>发送时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="target_rows" id="tvo"}
<tr>
<td>{$tvo.id}</td>
<td>{$tvo.admin_nickname}</td>
<td>{$tvo.channel_name}</td>
<td><span class="{$tvo.status_class}">{$tvo.status_name}</span></td>
<td>{$tvo.retry_count}</td>
<td>
{notempty name="tvo.error_message"}
{$tvo.error_message|htmlspecialchars}
{else/}
-
{/notempty}
</td>
<td>
{if condition="$tvo.send_time gt 0"}
{$tvo.send_time|date="Y-m-d H:i:s"}
{else/}
-
{/if}
</td>
<td>
{if condition="$tvo.status eq 3"}
<button type="button" class="layui-btn layui-btn-warm layui-btn-xs notice-resend-btn" data-target-id="{$tvo.id}">重发</button>
{else/}
-
{/if}
</td>
</tr>
{/volist}
</tbody>
</table>
{else/}
<span class="layui-text-em">暂无投递明细</span>
{/notempty}
</div>
</div>
</div>
</div>
<!-- 右侧基础信息 -->

View File

@@ -1,3 +1,21 @@
$(function(){
ua.listen();
// 无 resend 权限时隐藏行内重发按钮(与框架按钮保守隐藏口径一致)
if (ua.auth('resend') === false) {
$('.notice-resend-btn').remove();
}
// 失败投递行内重发:POST target_id,服务端原子重置 status=3 行后刷新明细
$(document).on('click', '.notice-resend-btn', function () {
var btn = $(this);
ua.request.post({
url: ua.url('system.notice/resend'),
data: {target_id: btn.data('target-id')},
}, function (res) {
ua.msg.success(res.msg || '已重发', function () {
location.reload();
});
});
});
})

View File

@@ -0,0 +1,43 @@
<div class="layuimini-container detail-container">
<div class="layuimini-main">
<div class="layui-card detail-card">
<div class="layui-card-header detail-header">
<h2 class="detail-title">通知场景总览</h2>
</div>
<div class="layui-card-body detail-content">
<table class="layui-table">
<thead>
<tr>
<th>场景</th>
<th>投递渠道</th>
<th>订阅要求</th>
<th>订阅统计</th>
</tr>
</thead>
<tbody>
{volist name="rows" id="vo"}
<tr>
<td>
{$vo.title}
<div style="color:#999;font-size:12px;">{$vo.scene}</div>
</td>
<td>{$vo.channel_names}</td>
<td>
{if condition="$vo.locked eq 1"}
<span class="layui-badge">必选</span>
{else/}
<span class="layui-badge layui-bg-blue">自愿</span>
{/if}
</td>
<td>{$vo.subscribed} / {$vo.total_admins} 位管理员</td>
</tr>
{/volist}
</tbody>
</table>
<div style="color:#999;font-size:12px;margin-top:8px;">
订阅统计口径:该场景订阅状态为启用(status=1)的去重管理员数 / 全体启用管理员数。场景由代码注册,本页仅展示。
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
$(function(){
ua.listen();
})