[ '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 的拼法); // 模板会把 `他的名字` 放在前面,所以这里把开头的"有人"去掉。 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'); } }