Files
FamilyBook/app/Notification.php
T
laoyang df6fe894d0 v32 第 32 批(4 条):资源共享弹框真落库 / 消息通知覆盖全部共享入口 / 浏览器通知(站内消息+日程到期提醒) / 首页任务随看板色
用户原话:
①「资源共享功能有问题,弹框中,点击共享给某个人后,弹框消失,但是并没有共享成功。」
②「消息通知,在所有引用自己的地方,都通知,包括别人分享了日历本给自己,
   分享了笔记给自己,分享了资源给自己等等。」
③「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了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。
2026-10-09 07:25:08 +08:00

386 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}