"""推送订阅与分发模块(云端架构实施)。 零第三方依赖,只用 Python 标准库。本模块只做两件事: 1. 推送订阅的增删查(SQLite 表 ``push_subscriptions``)。 2. 状态变化事件的分发(只发状态变化,不发进行中状态)。 边界(不要超出本模块宣称的范围): - 合法事件类型与 ``webhooks.ALLOWED_NOTIFICATION_EVENTS`` 一致: ``completed``、``failed``、``goal-unmet``、``merge-failed``、 ``review-requested``。其他事件类型拒绝或忽略,并写明原因, 绝不静默丢弃。 - 偏好过滤:``silent``(静默)不发;失败类事件(``failed``、 ``goal-unmet``、``merge-failed``)即使偏好是 ``digest``(摘要) 也即时发;非失败类(``completed``、``review-requested``)在 ``digest`` 偏好下本次不立即发,返回结果里明确列入 ``skipped_for_digest``(待摘要列表),不是静默丢弃。 - 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。 ``sender`` 为 ``None`` 时对这两个通道也返回“无 sender”错误, 不静默成功。``sender`` 可以是可调用对象(收到一个 dict,见 :func:`dispatch_event`),也可以是按通道分的 dict (``{"webhook": callable, "slack": callable}``),或带 ``send`` 方法的对象。 - ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端: 一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造 真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发 任何请求。真实凭证与真实发送端是后续待接线的工作,见模块 末尾的缺口说明。 - 本模块不联网、不调用真实推送服务、不跑真实模型回合。 密钥、token、证书一律不由本模块读取、写入或返回。 """ import sqlite3 import time import uuid from contextlib import contextmanager __all__ = [ "CHANNELS", "PREFERENCES", "ALLOWED_PUSH_EVENTS", "FAILURE_EVENTS", "MISSING_CREDENTIAL_REASONS", "init_push_db", "create_subscription", "delete_subscription", "list_subscriptions", "dispatch_event", ] CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm") PREFERENCES = ("instant", "digest", "silent") # 只发状态变化的事件集合,与 webhooks.ALLOWED_NOTIFICATION_EVENTS 一致。 ALLOWED_PUSH_EVENTS = frozenset( {"completed", "failed", "goal-unmet", "merge-failed", "review-requested"} ) # 失败类事件:即使偏好是 digest 也即时发。 FAILURE_EVENTS = frozenset({"failed", "goal-unmet", "merge-failed"}) # webpush/apns/fcm 缺凭证时的明确报错文案(不含任何真实凭证)。 MISSING_CREDENTIAL_REASONS = { "webpush": "missing credential: VAPID keys not configured", "apns": "missing credential: APNs certificate not configured", "fcm": "missing credential: FCM key not configured", } def _is_connection(db): return isinstance(db, sqlite3.Connection) @contextmanager def _connect(db): """按 db 类型取连接:Connection 直接用(不关闭);路径则新建并关闭。""" if _is_connection(db): yield db elif isinstance(db, (str, bytes)) or hasattr(db, "__fspath__"): conn = sqlite3.connect(str(db), timeout=30) try: yield conn finally: conn.close() else: raise TypeError("db must be a sqlite3 connection or a db path") def init_push_db(db): """创建 push_subscriptions 表(已存在则不动)。""" with _connect(db) as conn: conn.execute( "CREATE TABLE IF NOT EXISTS push_subscriptions(" "id TEXT PRIMARY KEY, " "project_id TEXT NOT NULL, " "user_id TEXT NOT NULL, " "channel TEXT NOT NULL, " "target TEXT NOT NULL, " "preference TEXT NOT NULL, " "created_at REAL, " "UNIQUE(project_id, user_id, channel, target))" ) conn.commit() def _row_to_dict(row): return { "id": row[0], "project_id": row[1], "user_id": row[2], "channel": row[3], "target": row[4], "preference": row[5], "created_at": row[6], } def _validate_subscription_fields(project_id, user_id, channel, target, preference): if not project_id or not isinstance(project_id, str): raise ValueError("project_id required") if not user_id or not isinstance(user_id, str): raise ValueError("user_id required") if channel not in CHANNELS: raise ValueError("channel must be one of %s" % (CHANNELS,)) if not target or not isinstance(target, str): raise ValueError("target required") if preference not in PREFERENCES: raise ValueError("preference must be one of %s" % (PREFERENCES,)) def create_subscription(db, project_id, user_id, channel, target, preference="instant"): """创建一条推送订阅,返回订阅 dict。 同一 (project_id, user_id, channel, target) 已存在时返回已有订阅, 不建重复行。channel 取值 webhook/slack/webpush/apns/fcm; preference 取值 instant/digest/silent;非法取值抛 ValueError。 """ _validate_subscription_fields( project_id, user_id, channel, target, preference) init_push_db(db) with _connect(db) as conn: existing = conn.execute( "SELECT id, project_id, user_id, channel, target, preference," " created_at FROM push_subscriptions" " WHERE project_id = ? AND user_id = ? AND channel = ?" " AND target = ?", (project_id, user_id, channel, target), ).fetchone() if existing is not None: return _row_to_dict(existing) sub_id = uuid.uuid4().hex now = time.time() conn.execute( "INSERT INTO push_subscriptions(id, project_id, user_id," " channel, target, preference, created_at)" " VALUES(?, ?, ?, ?, ?, ?, ?)", (sub_id, project_id, user_id, channel, target, preference, now), ) conn.commit() row = conn.execute( "SELECT id, project_id, user_id, channel, target, preference," " created_at FROM push_subscriptions WHERE id = ?", (sub_id,), ).fetchone() return _row_to_dict(row) def delete_subscription(db, subscription_id=None, *, project_id=None, user_id=None, channel=None, target=None): """删除订阅,返回 True(删到至少一条)或 False(无匹配)。 优先按 subscription_id 删除;未传 id 时按给出的 project_id/user_id/channel/target 过滤删除(至少要给一个过滤条件, 否则不删任何行并返回 False,避免误删整表)。 """ init_push_db(db) with _connect(db) as conn: if subscription_id is not None: cur = conn.execute( "DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,), ) else: conditions = [] params = [] for column, value in ( ("project_id", project_id), ("user_id", user_id), ("channel", channel), ("target", target), ): if value is not None: conditions.append("%s = ?" % column) params.append(value) if not conditions: return False cur = conn.execute( "DELETE FROM push_subscriptions WHERE " + " AND ".join(conditions), params, ) conn.commit() return cur.rowcount > 0 def list_subscriptions(db, project_id, user_id=None): """列出某项目的订阅;传 user_id 时只列该用户的。 返回按 created_at、id 排序的订阅 dict 列表。只查本项目, 跨项目订阅绝不会混入(见单测)。 """ if not project_id or not isinstance(project_id, str): raise ValueError("project_id required") init_push_db(db) with _connect(db) as conn: if user_id is None: rows = conn.execute( "SELECT id, project_id, user_id, channel, target," " preference, created_at FROM push_subscriptions" " WHERE project_id = ? ORDER BY created_at, id", (project_id,), ).fetchall() else: rows = conn.execute( "SELECT id, project_id, user_id, channel, target," " preference, created_at FROM push_subscriptions" " WHERE project_id = ? AND user_id = ?" " ORDER BY created_at, id", (project_id, user_id), ).fetchall() return [_row_to_dict(row) for row in rows] def _resolve_channel_sender(sender, channel): """从注入的 sender 里取出某通道的发送函数,取不到返回 None。""" if sender is None: return None if isinstance(sender, dict): candidate = sender.get(channel) if candidate is None: return None if callable(candidate): return candidate send = getattr(candidate, "send", None) return send if callable(send) else None if callable(sender): return sender send = getattr(sender, "send", None) return send if callable(send) else None def dispatch_event(db, event_type, project_id, payload, sender=None): """分发一次状态变化事件,只发本项目的订阅。 参数: db: sqlite3 连接或数据库路径。 event_type: 合法类型见 ALLOWED_PUSH_EVENTS;其他类型拒绝, 返回 ``{"sent": [], ..., "reason": "event not allowed"}`` 且不调用任何 sender。 project_id: 只查该项目的订阅,跨项目不混。 payload: 原事件载荷(dict 或其他),原样放入发送给 sender 的消息 dict 的 ``payload`` 字段。 sender: 可注入发送端(见模块 docstring)。webhook/slack 通道在 sender 为 None 时返回“无 sender”错误,不静默成功; webpush/apns/fcm 通道不走 sender,一律返回缺凭证错误。 返回 dict,键固定为:: { "event_type": ..., "project_id": ..., "sent": [{"subscription_id", "user_id", "channel", "target"}], "skipped_for_digest": [订阅 dict(待摘要,本次未立即发)], "skipped_silent": [订阅 dict(静默偏好,本次未发)], "errors": [{"subscription_id", "user_id", "channel", "target", "reason": ...}], } 非法事件类型时额外含 ``"reason": "event not allowed"``。 sender 抛异常时记入 errors,不中断其他订阅的分发。 """ result = { "event_type": event_type, "project_id": project_id, "sent": [], "skipped_for_digest": [], "skipped_silent": [], "errors": [], } if event_type not in ALLOWED_PUSH_EVENTS: result["reason"] = "event not allowed" result["errors"] = [{ "subscription_id": None, "user_id": None, "channel": None, "target": None, "reason": "event not allowed: %r not in %s" % (event_type, sorted(ALLOWED_PUSH_EVENTS)), }] return result if not project_id or not isinstance(project_id, str): result["reason"] = "project_id required" result["errors"] = [{ "subscription_id": None, "user_id": None, "channel": None, "target": None, "reason": "project_id required", }] return result subscriptions = list_subscriptions(db, project_id) is_failure = event_type in FAILURE_EVENTS for sub in subscriptions: base = { "subscription_id": sub["id"], "user_id": sub["user_id"], "channel": sub["channel"], "target": sub["target"], } # 偏好过滤:silent 永远不发;digest 只对非失败类延迟。 if sub["preference"] == "silent": result["skipped_silent"].append(sub) continue if sub["preference"] == "digest" and not is_failure: result["skipped_for_digest"].append(sub) continue # webpush/apns/fcm:只有接口,缺凭证,明确报错,不走 sender、 # 不联网、不编造推送。 if sub["channel"] in MISSING_CREDENTIAL_REASONS: result["errors"].append( dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]])) continue # webhook/slack:必须有可注入 sender,否则明确报错。 channel_sender = _resolve_channel_sender(sender, sub["channel"]) if channel_sender is None: result["errors"].append( dict(base, reason="no sender for channel %s" % sub["channel"])) continue message = { "event_type": event_type, "project_id": project_id, "payload": payload, "channel": sub["channel"], "target": sub["target"], "user_id": sub["user_id"], "subscription_id": sub["id"], } try: channel_sender(message) except Exception as exc: # noqa: BLE001 -- 分发失败记 errors,不中断 result["errors"].append( dict(base, reason="sender failed: %s" % exc)) continue result["sent"].append(base) return result