feat: add user notification center

This commit is contained in:
hajimi
2026-08-03 21:44:30 +08:00
parent 17720e86e2
commit 9df5f5f620
17 changed files with 1401 additions and 5 deletions
@@ -0,0 +1,64 @@
<?php
namespace app\adminapi\controller\notice;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\notice\UserNoticeLists;
use app\adminapi\validate\notice\UserNoticeValidate;
use app\common\enum\notice\UserNoticeEnum;
use app\common\model\notice\NoticeRecord;
use app\common\service\notice\UserNoticeService;
/**
* 站内消息管理。
*/
class UserNoticeController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new UserNoticeLists());
}
public function options()
{
return $this->data([
'scene_options' => UserNoticeEnum::getOptions(),
]);
}
public function send()
{
$params = (new UserNoticeValidate())->post()->goCheck('send');
$scope = (string) $params['recipient_scope'];
$userIds = $scope === 'specified'
? array_values(array_unique(array_map('intval', $params['user_ids'])))
: [];
$sentCount = UserNoticeService::sendSystem(
trim((string) $params['title']),
trim((string) $params['content']),
trim((string) ($params['target_url'] ?? '')),
$userIds
);
if ($sentCount === 0) {
return $this->fail('没有可接收消息的启用用户');
}
return $this->success("已发送给 {$sentCount} 位用户");
}
public function delete()
{
$ids = $this->request->post('ids/a', []);
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if (empty($ids)) {
return $this->fail('请选择需要删除的消息');
}
NoticeRecord::whereIn('id', $ids)
->whereIn('scene_id', UserNoticeEnum::getSceneIds())
->delete();
return $this->success('删除成功');
}
}
@@ -0,0 +1,85 @@
<?php
namespace app\adminapi\lists\notice;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\enum\notice\UserNoticeEnum;
use app\common\model\notice\NoticeRecord;
use app\common\model\user\User;
use app\common\service\notice\UserNoticeService;
/**
* 用户站内消息记录列表。
*/
class UserNoticeLists extends BaseAdminDataLists
{
public function lists(): array
{
$lists = $this->buildQuery()
->field('notice.id,notice.user_id,notice.title,notice.content,notice.scene_id,notice.read,notice.extra,notice.create_time,user.nickname AS user_nickname,user.account AS user_account')
->order('notice.id desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
$sourceUserIds = [];
foreach ($lists as $item) {
$extra = UserNoticeService::decodeExtra($item['extra'] ?? '');
if (!empty($extra['source_user_id'])) {
$sourceUserIds[] = (int) $extra['source_user_id'];
}
}
$sourceUsers = [];
$sourceUserIds = array_values(array_unique(array_filter($sourceUserIds)));
if (!empty($sourceUserIds)) {
$sourceUsers = User::whereIn('id', $sourceUserIds)->column('nickname', 'id');
}
foreach ($lists as &$item) {
$extra = UserNoticeService::decodeExtra($item['extra'] ?? '');
$meta = UserNoticeEnum::getMeta((int) $item['scene_id']);
$sourceUserId = (int) ($extra['source_user_id'] ?? 0);
$item['type_desc'] = $meta['type_desc'];
$item['source_user_id'] = $sourceUserId;
$item['source_nickname'] = $sourceUsers[$sourceUserId] ?? '';
$item['target_url'] = UserNoticeService::sanitizeTargetUrl((string) ($extra['target_url'] ?? ''));
$item['create_time'] = is_numeric($item['create_time'])
? date('Y-m-d H:i:s', (int) $item['create_time'])
: $item['create_time'];
}
unset($item);
return $lists;
}
public function count(): int
{
return $this->buildQuery()->count();
}
private function buildQuery()
{
$query = NoticeRecord::alias('notice')
->leftJoin('user user', 'user.id = notice.user_id')
->whereIn('notice.scene_id', UserNoticeEnum::getSceneIds());
$sceneId = (int) ($this->params['scene_id'] ?? 0);
if (in_array($sceneId, UserNoticeEnum::getSceneIds(), true)) {
$query->where('notice.scene_id', $sceneId);
}
if (array_key_exists('read', $this->params) && $this->params['read'] !== '') {
$query->where('notice.read', (int) $this->params['read']);
}
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$query->where(function ($query) use ($keyword) {
$query->whereLike('notice.title', '%' . $keyword . '%')
->whereOr('notice.content', 'like', '%' . $keyword . '%')
->whereOr('user.nickname', 'like', '%' . $keyword . '%')
->whereOr('user.account', 'like', '%' . $keyword . '%');
});
}
return $query;
}
}
@@ -16,6 +16,7 @@ namespace app\adminapi\logic\user;
use app\common\enum\user\AccountLogEnum;
use app\common\enum\user\UserTerminalEnum;
use app\common\logic\AccountLogLogic;
use app\common\service\notice\UserNoticeService;
use app\common\logic\BaseLogic;
use app\common\model\user\User;
use think\facade\Db;
@@ -178,6 +179,9 @@ class UserLogic extends BaseLogic
'',
$remark
);
if ($type === 'points' && $action == AccountLogEnum::INC) {
UserNoticeService::adminPoints((int) $user->id, (int) $num, $remark);
}
Db::commit();
return true;
@@ -0,0 +1,57 @@
<?php
namespace app\adminapi\validate\notice;
use app\common\validate\BaseValidate;
class UserNoticeValidate extends BaseValidate
{
protected $rule = [
'recipient_scope' => 'require|in:all,specified',
'user_ids' => 'checkUserIds',
'title' => 'require|max:50',
'content' => 'require|max:1000',
'target_url' => 'max:200|checkTargetUrl',
];
protected $message = [
'recipient_scope.require' => '请选择接收范围',
'recipient_scope.in' => '接收范围错误',
'title.require' => '请输入消息标题',
'title.max' => '消息标题不能超过50个字符',
'content.require' => '请输入消息内容',
'content.max' => '消息内容不能超过1000个字符',
'target_url.max' => '跳转路径不能超过200个字符',
];
protected function sceneSend()
{
return $this->only(['recipient_scope', 'user_ids', 'title', 'content', 'target_url']);
}
protected function checkUserIds($value, $rule, array $data)
{
if (($data['recipient_scope'] ?? '') !== 'specified') {
return true;
}
if (!is_array($value) || empty($value)) {
return '请至少填写一位指定用户ID';
}
foreach ($value as $userId) {
if (!is_numeric($userId) || (int) $userId <= 0) {
return '指定用户ID格式错误';
}
}
return true;
}
protected function checkTargetUrl($value)
{
if ($value === null || $value === '') {
return true;
}
return is_string($value) && str_starts_with($value, '/') && !str_starts_with($value, '//')
? true
: '跳转路径必须以单个 / 开头';
}
}
@@ -18,6 +18,7 @@ use app\common\service\ai\AiService;
use app\common\service\ai\KbSyncService;
use app\common\service\chat\PrivateChatService;
use app\common\service\FileService;
use app\common\service\notice\UserNoticeService;
use app\common\service\VipService;
use think\facade\Db;
@@ -416,6 +417,19 @@ class CommunityController extends BaseApiController
// 更新帖子评论数
CommunityPost::where('id', $postId)->inc('comment_count')->update();
if ($parentId > 0) {
$parentComment = CommunityComment::where('id', $parentId)
->where('post_id', $postId)
->where('status', 1)
->findOrEmpty();
$recipientUserId = $replyUserId > 0 ? $replyUserId : (int) ($parentComment->user_id ?? 0);
if (!$parentComment->isEmpty()) {
UserNoticeService::commentReply($recipientUserId, $this->userId, $postId, (int) $comment->id, $content);
}
} else {
UserNoticeService::postComment((int) $post->user_id, $this->userId, $postId, (int) $comment->id, $content);
}
return $this->data(['id' => $comment->id]);
}
@@ -495,6 +509,11 @@ class CommunityController extends BaseApiController
return $this->fail('不能关注自己');
}
$followUser = User::where('id', $followUserId)->findOrEmpty();
if ($followUser->isEmpty()) {
return $this->fail('用户不存在');
}
$exists = CommunityFollow::where([
'user_id' => $this->userId,
'follow_user_id' => $followUserId
@@ -506,6 +525,7 @@ class CommunityController extends BaseApiController
'follow_user_id' => $followUserId,
'create_time' => time(),
]);
UserNoticeService::followed($followUserId, $this->userId);
return $this->data(['is_followed' => true]);
} else {
$exists->delete();
@@ -1079,6 +1099,7 @@ class CommunityController extends BaseApiController
$userPoints = User::where('id', $userId)->value('user_points') ?: 0;
$chatUnreadCount = PrivateChatService::unreadCount($userId);
$noticeUnreadCount = UserNoticeService::unreadCount($userId);
return $this->data([
'post_count' => $postCount,
@@ -1087,6 +1108,8 @@ class CommunityController extends BaseApiController
'collect_count' => $collectCount,
'user_points' => $userPoints,
'chat_unread_count' => $chatUnreadCount,
'notice_unread_count' => $noticeUnreadCount,
'message_unread_count' => $chatUnreadCount + $noticeUnreadCount,
]);
}
@@ -0,0 +1,122 @@
<?php
namespace app\api\controller;
use app\common\enum\notice\UserNoticeEnum;
use app\common\enum\YesNoEnum;
use app\common\model\notice\NoticeRecord;
use app\common\model\user\User;
use app\common\service\chat\PrivateChatService;
use app\common\service\notice\UserNoticeService;
/**
* 用户站内消息中心。
*/
class NoticeController extends BaseApiController
{
public function lists()
{
$page = max(1, $this->request->get('page_no/d', 1));
$size = min(50, max(1, $this->request->get('page_size/d', 20)));
$query = NoticeRecord::where('user_id', $this->userId)
->whereIn('scene_id', UserNoticeEnum::getSceneIds());
$total = (clone $query)->count();
$records = $query->order('id desc')
->page($page, $size)
->select()
->toArray();
return $this->data([
'lists' => $this->formatRecords($records),
'count' => $total,
'page_no' => $page,
'page_size' => $size,
]);
}
public function summary()
{
$noticeUnreadCount = UserNoticeService::unreadCount($this->userId);
$chatUnreadCount = PrivateChatService::unreadCount($this->userId);
return $this->data([
'notice_unread_count' => $noticeUnreadCount,
'chat_unread_count' => $chatUnreadCount,
'unread_count' => $noticeUnreadCount + $chatUnreadCount,
]);
}
public function read()
{
$id = $this->request->post('id/d');
if ($id <= 0) {
return $this->fail('消息参数错误');
}
NoticeRecord::where('id', $id)
->where('user_id', $this->userId)
->whereIn('scene_id', UserNoticeEnum::getSceneIds())
->update([
'read' => YesNoEnum::YES,
'update_time' => time(),
]);
return $this->success('已读');
}
public function readAll()
{
NoticeRecord::where('user_id', $this->userId)
->whereIn('scene_id', UserNoticeEnum::getSceneIds())
->where('read', YesNoEnum::NO)
->update([
'read' => YesNoEnum::YES,
'update_time' => time(),
]);
return $this->success('全部消息已读');
}
private function formatRecords(array $records): array
{
$sourceUserIds = [];
foreach ($records as $record) {
$extra = UserNoticeService::decodeExtra($record['extra'] ?? '');
if (!empty($extra['source_user_id'])) {
$sourceUserIds[] = (int) $extra['source_user_id'];
}
}
$userMap = [];
$sourceUserIds = array_values(array_unique(array_filter($sourceUserIds)));
if (!empty($sourceUserIds)) {
$users = User::whereIn('id', $sourceUserIds)
->field('id,nickname,avatar')
->select()
->toArray();
foreach ($users as $user) {
$userMap[(int) $user['id']] = $user;
}
}
foreach ($records as &$record) {
$extra = UserNoticeService::decodeExtra($record['extra'] ?? '');
$meta = UserNoticeEnum::getMeta((int) $record['scene_id']);
$sourceUserId = (int) ($extra['source_user_id'] ?? 0);
$record = [
'id' => (int) $record['id'],
'title' => $record['title'],
'content' => $record['content'],
'is_read' => (int) $record['read'] === YesNoEnum::YES,
'create_time' => $record['create_time'],
'target_url' => UserNoticeService::sanitizeTargetUrl((string) ($extra['target_url'] ?? '')),
'source_user' => $sourceUserId > 0 ? ($userMap[$sourceUserId] ?? null) : null,
...$meta,
];
}
unset($record);
return $records;
}
}
@@ -0,0 +1,84 @@
<?php
namespace app\common\enum\notice;
/**
* 用户站内消息场景。
*
* 使用独立的场景编号,避免与验证码等已有通知场景混用。
*/
class UserNoticeEnum
{
public const POST_COMMENT = 1001;
public const COMMENT_REPLY = 1002;
public const FOLLOWED = 1003;
public const ADMIN_POINTS = 1004;
public const SYSTEM = 1005;
public static function getSceneIds(): array
{
return [
self::POST_COMMENT,
self::COMMENT_REPLY,
self::FOLLOWED,
self::ADMIN_POINTS,
self::SYSTEM,
];
}
public static function getMeta(int $sceneId): array
{
$map = [
self::POST_COMMENT => [
'type' => 'post_comment',
'type_desc' => '帖子评论',
'icon' => 'chat',
'color' => '#1468f5',
],
self::COMMENT_REPLY => [
'type' => 'comment_reply',
'type_desc' => '评论回复',
'icon' => 'chat',
'color' => '#7c4dff',
],
self::FOLLOWED => [
'type' => 'followed',
'type_desc' => '新增关注',
'icon' => 'account',
'color' => '#10b981',
],
self::ADMIN_POINTS => [
'type' => 'admin_points',
'type_desc' => '积分到账',
'icon' => 'coupon',
'color' => '#f59e0b',
],
self::SYSTEM => [
'type' => 'system',
'type_desc' => '系统消息',
'icon' => 'bell',
'color' => '#64748b',
],
];
return $map[$sceneId] ?? [
'type' => 'system',
'type_desc' => '系统消息',
'icon' => 'bell',
'color' => '#64748b',
];
}
public static function getOptions(): array
{
$options = [];
foreach (self::getSceneIds() as $sceneId) {
$meta = self::getMeta($sceneId);
$options[] = [
'value' => $sceneId,
'label' => $meta['type_desc'],
];
}
return $options;
}
}
@@ -0,0 +1,213 @@
<?php
namespace app\common\service\notice;
use app\common\enum\notice\NoticeEnum;
use app\common\enum\notice\UserNoticeEnum;
use app\common\enum\YesNoEnum;
use app\common\model\notice\NoticeRecord;
use app\common\model\user\User;
use think\facade\Db;
/**
* 用户站内消息服务。
*/
class UserNoticeService
{
public static function postComment(int $recipientUserId, int $sourceUserId, int $postId, int $commentId, string $content): bool
{
return self::create(
$recipientUserId,
UserNoticeEnum::POST_COMMENT,
'你的帖子收到了评论',
self::sourceName($sourceUserId) . ' 评论了你的帖子:' . self::shortContent($content),
[
'source_user_id' => $sourceUserId,
'target_type' => 'community_post',
'target_id' => $postId,
'comment_id' => $commentId,
'target_url' => "/packages_community/pages/post_detail?id={$postId}&comment_id={$commentId}",
]
);
}
public static function commentReply(int $recipientUserId, int $sourceUserId, int $postId, int $commentId, string $content): bool
{
return self::create(
$recipientUserId,
UserNoticeEnum::COMMENT_REPLY,
'你的评论收到了回复',
self::sourceName($sourceUserId) . ' 回复了你的评论:' . self::shortContent($content),
[
'source_user_id' => $sourceUserId,
'target_type' => 'community_comment',
'target_id' => $commentId,
'post_id' => $postId,
'target_url' => "/packages_community/pages/post_detail?id={$postId}&comment_id={$commentId}",
]
);
}
public static function followed(int $recipientUserId, int $sourceUserId): bool
{
return self::create(
$recipientUserId,
UserNoticeEnum::FOLLOWED,
'你有一位新关注者',
self::sourceName($sourceUserId) . ' 关注了你',
[
'source_user_id' => $sourceUserId,
'target_type' => 'user_profile',
'target_id' => $sourceUserId,
'target_url' => "/packages_community/pages/user_profile?user_id={$sourceUserId}",
]
);
}
public static function adminPoints(int $recipientUserId, int $points, string $remark = ''): bool
{
$content = '管理员为你增加了 ' . $points . ' 积分';
$remark = self::shortContent($remark, 80);
if ($remark !== '') {
$content .= ',备注:' . $remark;
}
return self::create(
$recipientUserId,
UserNoticeEnum::ADMIN_POINTS,
'积分到账',
$content,
[
'target_type' => 'points',
'target_url' => '/packages/pages/points_log/points_log',
]
);
}
/**
* 向全部启用用户或指定用户发布后台系统消息。
*/
public static function sendSystem(string $title, string $content, string $targetUrl = '', array $userIds = []): int
{
$query = User::where('is_disable', 0);
if (!empty($userIds)) {
$query->whereIn('id', $userIds);
}
$userIds = array_map('intval', $query->column('id'));
if (empty($userIds)) {
return 0;
}
$now = time();
$extra = self::encodeExtra([
'target_type' => 'system',
'target_url' => self::sanitizeTargetUrl($targetUrl),
]);
$sentCount = 0;
foreach (array_chunk($userIds, 300) as $chunk) {
$records = [];
foreach ($chunk as $userId) {
$records[] = self::recordData($userId, UserNoticeEnum::SYSTEM, $title, $content, $extra, $now);
}
Db::name('notice_record')->insertAll($records);
$sentCount += count($records);
}
return $sentCount;
}
public static function unreadCount(int $userId): int
{
if ($userId <= 0) {
return 0;
}
return NoticeRecord::where('user_id', $userId)
->whereIn('scene_id', UserNoticeEnum::getSceneIds())
->where('read', YesNoEnum::NO)
->count();
}
public static function decodeExtra($value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$data = json_decode($value, true);
return is_array($data) ? $data : [];
}
public static function sanitizeTargetUrl(string $targetUrl): string
{
$targetUrl = trim($targetUrl);
if ($targetUrl === '' || !str_starts_with($targetUrl, '/') || str_starts_with($targetUrl, '//')) {
return '';
}
return mb_substr($targetUrl, 0, 200);
}
private static function create(int $recipientUserId, int $sceneId, string $title, string $content, array $extra = []): bool
{
$sourceUserId = (int) ($extra['source_user_id'] ?? 0);
if ($recipientUserId <= 0 || $recipientUserId === $sourceUserId || !in_array($sceneId, UserNoticeEnum::getSceneIds(), true)) {
return false;
}
$now = time();
NoticeRecord::create(self::recordData(
$recipientUserId,
$sceneId,
$title,
$content,
self::encodeExtra($extra),
$now
));
return true;
}
private static function recordData(int $userId, int $sceneId, string $title, string $content, string $extra, int $now): array
{
return [
'user_id' => $userId,
'title' => mb_substr(trim($title), 0, 50),
'content' => mb_substr(trim($content), 0, 1000),
'scene_id' => $sceneId,
'read' => YesNoEnum::NO,
'recipient' => 1,
'send_type' => NoticeEnum::SYSTEM,
'notice_type' => NoticeEnum::BUSINESS_NOTIFICATION,
'extra' => $extra,
'create_time' => $now,
'update_time' => $now,
];
}
private static function encodeExtra(array $extra): string
{
if (isset($extra['target_url'])) {
$extra['target_url'] = self::sanitizeTargetUrl((string) $extra['target_url']);
}
$encoded = json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? mb_substr($encoded, 0, 255) : '';
}
private static function sourceName(int $userId): string
{
$name = User::where('id', $userId)->value('nickname');
return $name ? (string) $name : '一位用户';
}
private static function shortContent(string $content, int $length = 60): string
{
$content = trim(preg_replace('/\s+/u', ' ', strip_tags($content)) ?: '');
if ($content === '') {
return '';
}
return mb_strlen($content) > $length ? mb_substr($content, 0, $length) . '…' : $content;
}
}