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, ]); } } /** * 宿主被删除时,把它的通知一并清掉。 * ⚠️ 不做外键(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); } /** 文案截断(多字节安全) */ 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'); } }