362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""推送订阅与分发模块(云端架构实施)。
|
||
|
||
零第三方依赖,只用 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
|
||
from cloud.db import connect as _db_connect
|
||
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 = _db_connect(str(db))
|
||
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
|