用户原话: ①「资源共享功能有问题,弹框中,点击共享给某个人后,弹框消失,但是并没有共享成功。」 ②「消息通知,在所有引用自己的地方,都通知,包括别人分享了日历本给自己, 分享了笔记给自己,分享了资源给自己等等。」 ③「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始, 提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」 ④「首页,近期任务,任务的文字和前面的圆点,颜色并没有跟看板颜色一致,这个需要修正。」 四条真根因: ① v28 把资源编辑改成三页签后,每个页签各有自己的 form 与 <input name=id>, 而设 id 的代码是 panel.querySelector() → 只命中文档序第一个(info 的 update), ⇒ share/att 表单的 id 恒空 ⇒ 提交 id=0 ⇒ Resource::find(familyId,0,me) 返回 null ⇒ 静默不共享。修:表单打 data-res-idform 标记 + querySelectorAll 逐个填。 ② v30 只在 saveAttendees 后调 syncForTarget,6 个共享入口一个都没接。 新增 Notification::syncShares(家庭,类型,宿主id,动作人) —— 照共享表现状重建差额 (写表后调用;漏调只会没通知,不会状态不一致)。SHARE_KINDS 抽常量。 ③ 完全没有浏览器通知。新增 Calendar::remindersBetween()(窗口留 -2 分钟防后台节流) + ?r=notifications/poll JSON 路由 + assets/js/notify.js(轮询 + Notification API + id@at 去重)+ 顶栏铃铛(权限只能用户点一下才要,自动申请会被 Chrome 静默拒绝)。 ④ v30 把用户那句和主题颜色一样理解成了**任务模块主题色**,用户要的是**看板色**。 新增 Task::boardColorById(家庭,看板id)(没设色按看板名派生稳定色),home.php 两处改 $tkColor。 验证:诊断探针 18/0、真机 25/0(真点共享→库里真的多一行;三类共享真发通知; 9:50 窗口真算出 10:00 那条;首页每条任务色 == 其看板色)、反向 5/0、 e2e 1502/0、升级 153/0、几何 428+680+92、城市 32/0。 部署脚本静态断言段本地空跑通过;线上站点自检 19 条全过,notifications/poll 未登录 302。
386 lines
17 KiB
PHP
386 lines
17 KiB
PHP
<?php
|
||
/**
|
||
* 通知消息(v30 第 5 条)。
|
||
*
|
||
* 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
|
||
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
|
||
* 加我的时候,发一个通知,通知出现在首页中,往年今天的前面。」
|
||
*
|
||
* ────────────────────────────────────────────────────────────────
|
||
* 五条设计判断(每条都对应一个"不这么做就会出的问题"):
|
||
*
|
||
* ① **不在 Module::LIST 注册**。它不是"一个可浏览的数据模块",而是"给我看的消息"。
|
||
* 注册进 LIST 就会被 `Module::navModules()` 拿去渲染顶栏(用户明确要求"不在导航显示"),
|
||
* 还会出现在个人设置的"模块显示与排序"里 —— 那里对它是没有意义的开关。
|
||
* ⇒ 它是一个**独立页面**(`?r=notifications`),入口只在头像下拉里。
|
||
*
|
||
* ② **只通知"别人把我也加进去了",不通知"我自己建的"**。
|
||
* 这是"通知"与"流水账"的分界:我自己建一条观影、列表里带上家人,
|
||
* 不该给自己发一条"有人加了你"。所以只对 `personId !== actorId` 的收件人写入。
|
||
*
|
||
* ③ **按"新增"发,按"删除"撤销**。整组替换(`saveAttendees` 先 DELETE 再 INSERT)
|
||
* 会产生一个很具体的坏情况:用户把参与人从 [A,B] 改成 [A],
|
||
* 如果不撤销,B 的通知还挂在那儿 —— "我明明被移出去了,怎么还说加了我"。
|
||
* ⇒ `syncForTarget()` 把"这次要保留的收件人"传进来,多出来的**按未读删掉**。
|
||
* ⚠️ 已读的**不删**(那是用户已经看过的历史,删了像"消息会自己消失");
|
||
* 未读的才是"还没送达的提醒",撤回它才是对的。
|
||
*
|
||
* ④ **一次编辑不重复发**。UNIQUE KEY (person_id, target_type, target_id)
|
||
* + INSERT IGNORE:同一件事反复保存只会有一条通知。
|
||
* 否则用户改三次标题就收到三条"加了你"。
|
||
*
|
||
* ⑤ **不做邮件/推送**。用户要的是"站内消息"("通知出现在首页中")。
|
||
* 发邮件要 SMTP 配置、还要防打扰,那是另一个模块的事,这里只做站内。
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class Notification
|
||
{
|
||
/**
|
||
* 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑)
|
||
*
|
||
* ⚠️ 这里的值有**两种用途**,别混:
|
||
* · 前两个(movie / trip)是 `syncForTarget()` 的"组"类型,
|
||
* 唯一键 (person_id, target_type, target_id) 靠它去重与撤销;
|
||
* · 后面几个是 v32 新增的**其它"别人给我"的场景**,各自对应一张共享表。
|
||
*/
|
||
public const MOVIE = 'movie';
|
||
public const TRIP = 'trip';
|
||
|
||
/** v32:别人把东西**共享**给我(日历本 / 笔记 / 资源)—— 各自的宿主类型 */
|
||
public const CALENDAR = 'calendar';
|
||
public const NOTE = 'note';
|
||
public const RESOURCE = 'resource';
|
||
|
||
/** 这几类宿主"保存后同步通知"的注册表:类型 → 表名/列名/跳转/词 */
|
||
public const SHARE_KINDS = [
|
||
self::CALENDAR => [
|
||
'table' => 'calendar_shares', 'col' => 'calendar_id',
|
||
'sub' => 'calendars', 'sub_id' => 'id', 'sub_name' => 'name',
|
||
'href' => 'calendar/calendars', 'word' => '日历本',
|
||
'icon' => '📅',
|
||
],
|
||
self::NOTE => [
|
||
'table' => 'note_shares', 'col' => 'note_id',
|
||
'sub' => 'notes', 'sub_id' => 'id', 'sub_name' => 'title',
|
||
'href' => 'notes/form', 'word' => '笔记',
|
||
'icon' => '📝',
|
||
],
|
||
self::RESOURCE => [
|
||
'table' => 'resource_shares', 'col' => 'resource_id',
|
||
'sub' => 'resources', 'sub_id' => 'id', 'sub_name' => 'title',
|
||
'href' => 'resources', 'word' => '资源',
|
||
'icon' => '📦',
|
||
],
|
||
];
|
||
|
||
/** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */
|
||
public const PAGE_SIZE = 50;
|
||
|
||
/**
|
||
* 同步某个宿主的"加人"通知。
|
||
*
|
||
* 调用时机:观影/旅程保存参与人**之后**(见 Movie::saveAttendees / Trip 的对应处)。
|
||
*
|
||
* @param int $familyId
|
||
* @param string $type 宿主类型('movie' / 'trip')
|
||
* @param int $targetId 宿主 id
|
||
* @param string $title 宿主标题(通知里要显示"《xxx》")
|
||
* @param int $actorId 动作发出者(谁把别人加进去的)
|
||
* @param int[] $keepIds 这次**保留**的参与人(总集)
|
||
* @param string $href 点通知跳哪儿(相对 url() 的 r 值,如 'movies/show')
|
||
* @param array $params href 的查询参数(如 ['id'=>12])
|
||
*/
|
||
public static function syncForTarget(int $familyId, string $type, int $targetId,
|
||
string $title, int $actorId, array $keepIds,
|
||
string $href, array $params = []): void
|
||
{
|
||
if ($targetId <= 0 || $familyId <= 0) {
|
||
return;
|
||
}
|
||
$db = Database::get();
|
||
|
||
// ---- ① 收件人 = 保留列表 - 动作发出者(不给自己发,见头部第 ②条)----
|
||
$keep = [];
|
||
foreach ($keepIds as $pid) {
|
||
$pid = (int)$pid;
|
||
if ($pid > 0 && $pid !== $actorId) {
|
||
$keep[$pid] = true;
|
||
}
|
||
}
|
||
|
||
// ---- ② 撤销:这次不在保留列表里的**未读**通知,删掉(见头部第 ③条)----
|
||
$st = $db->prepare(
|
||
'SELECT id, person_id FROM notifications
|
||
WHERE target_type = ? AND target_id = ? AND is_read = 0'
|
||
);
|
||
$st->execute([$type, $targetId]);
|
||
$del = $db->prepare('DELETE FROM notifications WHERE id = ?');
|
||
foreach ($st->fetchAll() as $row) {
|
||
if (!isset($keep[(int)$row['person_id']])) {
|
||
$del->execute([(int)$row['id']]);
|
||
}
|
||
}
|
||
|
||
// ---- ③ 新增:给每个收件人写一条(INSERT IGNORE 靠唯一键去重,见第 ④条)----
|
||
if (!$keep) {
|
||
return;
|
||
}
|
||
$ins = $db->prepare(
|
||
'INSERT IGNORE INTO notifications
|
||
(family_id, person_id, actor_id, kind, title, href, params,
|
||
target_type, target_id, is_read, created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,0,NOW())'
|
||
);
|
||
$kindWord = $type === self::MOVIE ? '观影' : ($type === self::TRIP ? '旅程' : '记录');
|
||
$body = '有人把你加进了' . $kindWord . '「' . self::clip($title, 60) . '」';
|
||
foreach (array_keys($keep) as $pid) {
|
||
$ins->execute([
|
||
$familyId, (int)$pid, $actorId, $type, $body,
|
||
$href, $params ? json_encode($params, JSON_UNESCAPED_UNICODE) : null,
|
||
$type, $targetId,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v32(用户 2026-10-08 第 2 条):「消息通知,在所有引用自己的地方,都通知,
|
||
* 包括别人分享了日历本给自己,分享了笔记给自己,分享了资源给自己等等。」
|
||
*
|
||
* 与 `syncForTarget()` 的分工:
|
||
* · syncForTarget:**参与人是"整组替换"**的场合(观影/旅程加人)——
|
||
* 每次保存都要"按差额撤销",所以要传全量 keepIds。
|
||
* · 本函数:共享表是**逐条增删**的(一次只动一个人),
|
||
* 所以不需要撤销逻辑 —— 谁多了就发一条,谁被移走就删他未读的那条。
|
||
*
|
||
* ⚠️ 直接**照共享表现状**重建通知,而不是"按调用方说的动作发":
|
||
* 共享的入口不止一个(列表页共享、详情页取消共享、看板/笔记页…),
|
||
* 每个入口都记得发通知是不可能的 —— 迟早漏一个(本批就是这么发现的:
|
||
* 观影/旅程有通知,日历本/笔记/资源没有)。
|
||
* ⇒ 在**共享表的每次写入之后**调一次本函数,它自己算差额。
|
||
*
|
||
* @param string $kind self::CALENDAR / self::NOTE / self::RESOURCE
|
||
* @param int $hostId 宿主 id
|
||
* @param int $actorId 谁共享的(**不给动作发出者自己发**,见头部第 ②条)
|
||
*/
|
||
public static function syncShares(int $familyId, string $kind, int $hostId, int $actorId): void
|
||
{
|
||
$cfg = self::SHARE_KINDS[$kind] ?? null;
|
||
if ($cfg === null || $hostId <= 0 || $familyId <= 0) {
|
||
return;
|
||
}
|
||
$db = Database::get();
|
||
|
||
// ---- 现在共享给了谁(共享表为准)----
|
||
$st = $db->prepare(
|
||
'SELECT ' . $cfg['col'] . ' AS hid, person_id, perm FROM ' . $cfg['table']
|
||
. ' WHERE ' . $cfg['col'] . ' = ?'
|
||
);
|
||
$st->execute([$hostId]);
|
||
$now = [];
|
||
foreach ($st->fetchAll() as $r) {
|
||
$pid = (int)$r['person_id'];
|
||
if ($pid > 0 && $pid !== $actorId) {
|
||
$now[$pid] = (string)$r['perm'];
|
||
}
|
||
}
|
||
|
||
// ---- 撤销:不在共享表里、且**未读**的,删掉(已读的是历史,见头部第 ③条)----
|
||
$st = $db->prepare(
|
||
'SELECT id, person_id FROM notifications
|
||
WHERE target_type = ? AND target_id = ? AND is_read = 0'
|
||
);
|
||
$st->execute([$kind, $hostId]);
|
||
$del = $db->prepare('DELETE FROM notifications WHERE id = ?');
|
||
foreach ($st->fetchAll() as $row) {
|
||
if (!isset($now[(int)$row['person_id']])) {
|
||
$del->execute([(int)$row['id']]);
|
||
}
|
||
}
|
||
if (!$now) {
|
||
return;
|
||
}
|
||
|
||
// ---- 宿主的名字(通知里要显示"「xxx」")----
|
||
$st = $db->prepare(
|
||
'SELECT ' . $cfg['sub_name'] . ' AS nm FROM ' . $cfg['sub']
|
||
. ' WHERE ' . $cfg['sub_id'] . ' = ? LIMIT 1'
|
||
);
|
||
$st->execute([$hostId]);
|
||
$hostName = (string)($st->fetchColumn() ?: '');
|
||
|
||
// ---- 新增(INSERT IGNORE 靠唯一键去重:反复保存只留一条,见头部第 ④条)----
|
||
// ⚠️ 正文要带**权限**:用户看到"只读 / 可读写"才知道自己能做什么。
|
||
$ins = $db->prepare(
|
||
'INSERT IGNORE INTO notifications
|
||
(family_id, person_id, actor_id, kind, title, href, params,
|
||
target_type, target_id, is_read, created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,0,NOW())'
|
||
);
|
||
foreach ($now as $pid => $perm) {
|
||
$permWord = $perm === 'write' ? '可读写' : '只读';
|
||
$body = '把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你('
|
||
. $permWord . ')';
|
||
$ins->execute([
|
||
$familyId, (int)$pid, $actorId, $kind, $body,
|
||
$cfg['href'],
|
||
$kind === self::NOTE ? json_encode(['id' => $hostId], JSON_UNESCAPED_UNICODE) : null,
|
||
$kind, $hostId,
|
||
]);
|
||
}
|
||
// ⚠️ 权限是"可变"的:用户把只读改成可读写时,上面那条 INSERT IGNORE
|
||
// 不会更新已有的正文 ⇒ 通知里还写着"只读"(与实际不符)。
|
||
// ⇒ 未读的那条**顺手把正文刷新一遍**(已读的不动:那是历史)。
|
||
$upd = $db->prepare(
|
||
'UPDATE notifications SET title = ? WHERE target_type = ? AND target_id = ?
|
||
AND person_id = ? AND is_read = 0'
|
||
);
|
||
foreach ($now as $pid => $perm) {
|
||
$permWord = $perm === 'write' ? '可读写' : '只读';
|
||
$upd->execute([
|
||
'把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你(' . $permWord . ')',
|
||
$kind, $hostId, (int)$pid,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 宿主被删除时,把它的通知一并清掉。
|
||
* ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清,
|
||
* 否则通知点进去 404(本项目在 attachments 上就是这么做的,同一约定)。
|
||
*/
|
||
public static function removeForTarget(string $type, int $targetId): void
|
||
{
|
||
if ($targetId <= 0) {
|
||
return;
|
||
}
|
||
Database::get()->prepare(
|
||
'DELETE FROM notifications WHERE target_type = ? AND target_id = ?'
|
||
)->execute([$type, $targetId]);
|
||
}
|
||
|
||
/**
|
||
* 我的通知(最新在前)。
|
||
*
|
||
* ⚠️ 「我叫他什么」在**另一张表**(`nicknames`,按 viewer+target 存),
|
||
* `persons` 上**没有** `my_nickname` 这一列 —— 必须 LEFT JOIN 取。
|
||
* (本项目所有"显示某个人的称呼"的地方都是这么做的,见 Movie::attendees。)
|
||
* ⚠️ join 条件里要带**收件人**($personId)当 viewer:称呼是"我对他"的,
|
||
* 不是"他对我"的 —— 传错方向会让通知里出现别人叫他的名字。
|
||
*/
|
||
public static function mine(int $personId, int $limit = self::PAGE_SIZE, bool $unreadOnly = false): array
|
||
{
|
||
$sql = 'SELECT n.*, p.name AS actor_name, p.avatar AS actor_avatar,
|
||
nk.nickname AS actor_nick
|
||
FROM notifications n
|
||
LEFT JOIN persons p ON p.id = n.actor_id
|
||
LEFT JOIN nicknames nk ON nk.target_id = n.actor_id AND nk.viewer_id = ?
|
||
WHERE n.person_id = ?';
|
||
$params = [$personId, $personId];
|
||
if ($unreadOnly) {
|
||
$sql .= ' AND n.is_read = 0';
|
||
}
|
||
$sql .= ' ORDER BY n.id DESC LIMIT ' . max(1, min(200, $limit));
|
||
$st = Database::get()->prepare($sql);
|
||
$st->execute($params);
|
||
return $st->fetchAll();
|
||
}
|
||
|
||
/** 未读数(首页那块与头像下拉的角标都用它) */
|
||
public static function unreadCount(int $personId): int
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'SELECT COUNT(*) FROM notifications WHERE person_id = ? AND is_read = 0'
|
||
);
|
||
$st->execute([$personId]);
|
||
return (int)$st->fetchColumn();
|
||
}
|
||
|
||
/** 全部标记已读(进通知页就自动全读 —— 与"消息中心"的通用做法一致) */
|
||
public static function markAllRead(int $personId): int
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'UPDATE notifications SET is_read = 1 WHERE person_id = ? AND is_read = 0'
|
||
);
|
||
$st->execute([$personId]);
|
||
return $st->rowCount();
|
||
}
|
||
|
||
/** 删除一条(只能删自己的 —— 与其它模块同一道越权栅栏) */
|
||
public static function delete(int $id, int $personId): bool
|
||
{
|
||
$st = Database::get()->prepare(
|
||
'DELETE FROM notifications WHERE id = ? AND person_id = ?'
|
||
);
|
||
$st->execute([$id, $personId]);
|
||
return $st->rowCount() > 0;
|
||
}
|
||
|
||
/** 清空我的(只删已读的?不 —— 用户点"清空"就是要清空,未读也清) */
|
||
public static function clearMine(int $personId): int
|
||
{
|
||
$st = Database::get()->prepare('DELETE FROM notifications WHERE person_id = ?');
|
||
$st->execute([$personId]);
|
||
return $st->rowCount();
|
||
}
|
||
|
||
/** 通知的跳转地址(params 是 json 存的,坏数据就当没有参数) */
|
||
public static function href(array $n): string
|
||
{
|
||
$params = [];
|
||
if (!empty($n['params'])) {
|
||
$dec = json_decode((string)$n['params'], true);
|
||
if (is_array($dec)) {
|
||
$params = $dec;
|
||
}
|
||
}
|
||
return url((string)$n['href'], $params);
|
||
}
|
||
|
||
/**
|
||
* 通知的图标(按 kind)。
|
||
*
|
||
* ⚠️ 抽成一处:通知页与首页那块**都**要画图标,两处各写一套 emoji 分支
|
||
* 迟早漂移(本项目的"设置下拉"就吃过这个亏)。模板里只调这个函数。
|
||
*/
|
||
public static function icon(string $kind): string
|
||
{
|
||
$map = [
|
||
self::MOVIE => '🎬',
|
||
self::TRIP => '🧭',
|
||
self::CALENDAR => '📅',
|
||
self::NOTE => '📝',
|
||
self::RESOURCE => '📦',
|
||
];
|
||
if (isset($map[$kind])) {
|
||
return $map[$kind];
|
||
}
|
||
// 旅程的宿主类型是 'trip_node' 之类(前缀匹配)
|
||
if (strpos($kind, 'trip') === 0) {
|
||
return '🧭';
|
||
}
|
||
return '🔔';
|
||
}
|
||
|
||
/**
|
||
* 通知里的动作词("有人**把你加进了**观影「x」" vs "有人**把笔记共享给你**")。
|
||
*
|
||
* ⚠️ 正文是**存库**的(`title`),这里只负责"把开头的『有人』换成实际名字"
|
||
* 这件事在模板里怎么做 —— 见 `bodyWithoutActor()`。
|
||
*/
|
||
public static function bodyWithoutActor(string $title): string
|
||
{
|
||
// 正文都是「有人…」开头(见 syncForTarget / syncShares 的拼法);
|
||
// 模板会把 `<b>他的名字</b>` 放在前面,所以这里把开头的"有人"去掉。
|
||
return (string)preg_replace('/^有人/u', '', $title);
|
||
}
|
||
|
||
/** 文案截断(多字节安全) */
|
||
private static function clip(string $s, int $max): string
|
||
{
|
||
$s = trim(preg_replace('/\s+/u', ' ', $s));
|
||
return $s === '' ? '(无标题)' : mb_substr($s, 0, $max, 'UTF-8');
|
||
}
|
||
} |