cloud: fill gaps (memory/KB/cost/models endpoints, WAL concurrency, OAuth scaffold, push subscriptions)

This commit is contained in:
host committed 2026-10-26 01:32:57 +08:00
1 parent b74d99e959
commit 076ba01c74
11 files changed
+2831 -28

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
"""推送订阅与分发模块(云端架构实施)。
零第三方依赖,只用 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