Skip to content

Commit f226fd3

Browse files
committed
chore: E 批地基(会话标识 contextvar + 签到定时与日志页热更项)
- request_context:session_id contextvar + 入站头白名单校验(x-session-id / x-opencode-session / x-session-affinity / conversation-id),按优先级取 第一个合法值;中间件设置并在请求结束后复原 - 热更项:checkin_times / checkin_jitter_minutes(签到多时点 + 抖动)、 log_buffer_lines(管理台日志页环形缓冲,0 关闭) - compose 透传三项;补 request_context 单测
1 parent bfabd90 commit f226fd3

6 files changed

Lines changed: 172 additions & 3 deletions

File tree

‎docker-compose.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ services:
158158
METRICS_ENABLED: ${METRICS_ENABLED:-true}
159159
# 请求级结构化日志(logfmt,只记 /api、/v1、/authorize;不写 query/正文/用户名)
160160
REQUEST_LOG_ENABLED: ${REQUEST_LOG_ENABLED:-true}
161+
# 签到定时:本地 HH:MM 逗号分隔,留空 = 全天每 10 分钟(旧行为);抖动分钟
162+
CHECKIN_TIMES: ${CHECKIN_TIMES:-}
163+
CHECKIN_JITTER_MINUTES: ${CHECKIN_JITTER_MINUTES:-0}
164+
# 管理台日志页环形缓冲行数(0 关闭该页)
165+
LOG_BUFFER_LINES: ${LOG_BUFFER_LINES:-500}
161166
# 出站剥离思考内容(只过滤响应、不改请求体);true 开启
162167
FORCE_DISABLE_THINKING: ${FORCE_DISABLE_THINKING:-false}
163168
# 出站 reasoning_effort 档位映射:渠道=from:to,from:to;*=from:to;留空原样透传

‎src/config.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ class Settings(BaseSettings):
136136
# 篡改活动数据(处罚为取消资格并追回礼品),开启前请评估账号风险;上游改版即失效。
137137
activity_report_enabled: bool = False
138138
activity_report_hour: int = 10 # 本地(北京)时间整点窗口内执行一次
139+
# 每日签到的执行时点(本地时间,HH:MM,逗号分隔;留空 = 全天每 10 分钟
140+
# 检查一次的旧行为)。命中时点才跑一轮:上游签到本身幂等(回 ALREADY),
141+
# 但每次都要打 status+claim+回查三次,全天轮询纯属白打。抖动见下一项。
142+
checkin_times: str = ""
143+
# 签到抖动(分钟,0 = 不抖):在时点**之前**提前一个随机偏移执行,避免
144+
# 一堆部署在同一分钟齐刷刷打上游。按「日期 + 时点」记去重键,抖动只改
145+
# 执行时刻、不改归属,不会让同一时点跑两次。
146+
checkin_jitter_minutes: int = 0
147+
# 服务端日志页(「任务与配置」→ 日志):进程内环形缓冲的保留行数,
148+
# 0 = 关闭该页(端点返回 404)。只收本进程 logging 记录,不读文件。
149+
log_buffer_lines: int = 500
139150
# 运维告警:周期评估四类风险,命中即落 alert_events 并可选推送
140151
# webhook。webhook 留空 = 只留站内记录、不外推。
141152
alert_enabled: bool = True

‎src/request_context.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,58 @@ def set_api_key_id(value: str | None) -> None:
7777
def get_api_key_id() -> str | None:
7878
"""读当前上下文的 API Key id;无请求上下文(后台任务/脚本)返回 None。"""
7979
return _api_key_id.get()
80+
81+
82+
# 入站请求头带来的会话标识(E1):客户端(Codex / Claude Code / opencode 等)
83+
# 用 `x-session-id` 这类头声明「这是同一段对话」,比消息前缀兜底可靠得多。
84+
# 与 request_id 同一套纪律:**入站头是客户端可写输入**,按白名单字符集与长度
85+
# 校验后才采信,不合法一律丢弃;且只作会话粘性的**兜底键**(优先级低于请求体
86+
# 里的显式键),不会影响模型、路由或鉴权。
87+
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
88+
89+
# 接受的头名,按优先级排列。前三个是 opencode / Codex 生态实际在用的写法,
90+
# 后两个是 conversation_id 的头形式(部分客户端把它放头里)。
91+
SESSION_ID_HEADERS: tuple[bytes, ...] = (
92+
b"x-session-id",
93+
b"x-opencode-session",
94+
b"x-session-affinity",
95+
b"conversation-id",
96+
b"conversation_id",
97+
)
98+
99+
_session_id: ContextVar[str | None] = ContextVar("session_id", default=None)
100+
101+
102+
def sanitize_session_id(raw: str | None) -> str | None:
103+
"""校验会话标识;不合法(含换行/超长/空)返回 None,由调用方丢弃。"""
104+
if not raw or not raw.strip():
105+
return None
106+
text = raw.strip()
107+
return text if SESSION_ID_PATTERN.match(text) else None
108+
109+
110+
def session_id_from_headers(headers) -> str | None:
111+
"""从入站头取会话标识(`headers` 为 ASGI 的 [(bytes, bytes), ...])。
112+
113+
按 `SESSION_ID_HEADERS` 的顺序取第一个**合法**的非空值;全部不合法返回
114+
None。头名大小写不敏感(ASGI 里规范为小写,但仍统一 lower 一次防调用方
115+
传大小写混合的列表)。
116+
"""
117+
for name in SESSION_ID_HEADERS:
118+
for key, value in headers:
119+
if key.lower() == name:
120+
candidate = sanitize_session_id(value.decode("latin-1"))
121+
if candidate is not None:
122+
return candidate
123+
return None
124+
125+
126+
def set_session_id(value: str | None) -> None:
127+
"""在当前上下文设置会话标识(请求入口调用;None = 复原)。"""
128+
_session_id.set(value)
129+
130+
131+
def get_session_id() -> str | None:
132+
"""读当前上下文的会话标识;无请求上下文返回 None。"""
133+
return _session_id.get()
134+

‎src/runtime_settings.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ def env_name(self) -> str:
208208
HotSetting("activity_report_hour", int, "活跃上报时点(0-23)",
209209
"本地(北京)时间整点窗口;该小时内每 10 分钟检查一次,每号每天"
210210
"最多补发一条。", minimum=0, maximum=23, task="activity"),
211+
HotSetting("checkin_times", str, "签到时点",
212+
"本地时间 HH:MM,逗号分隔(如 `09:37,21:10`)。命中时点才跑一轮"
213+
"签到;留空 = 全天每 10 分钟检查一次(旧行为)。上游签到幂等,"
214+
"多设几个时点只是多几次重试机会。", task="checkin"),
215+
HotSetting("checkin_jitter_minutes", int, "签到抖动(分钟)",
216+
"在时点**之前**提前一个 0..N 分钟的随机偏移执行,避免多部署"
217+
"同一分钟齐打上游。按「日期 + 时点」去重,抖动不会让同一时点"
218+
"跑两次。0 = 不抖。", minimum=0, maximum=1440, task="checkin"),
219+
HotSetting("log_buffer_lines", int, "日志页保留行数",
220+
"「任务与配置」→ 日志页的进程内环形缓冲行数(只收本进程日志,"
221+
"不读文件)。0 = 关闭该页(端点返回 404)。日志行发出前经脱敏"
222+
"白名单过滤,含 token / secret / authorization / cookie 的行整行丢弃。",
223+
minimum=0, group="observability"),
211224
# 运维告警:全部归到「运维告警」任务卡片下——改阈值时人就在看
212225
# 这个任务最近报了什么。webhook 留空 = 只留站内记录、不外推。
213226
HotSetting("alert_enabled", bool, "运维告警开关",
@@ -743,6 +756,18 @@ def activity_report_enabled(self) -> bool:
743756
def activity_report_hour(self) -> int:
744757
return int(self.get("activity_report_hour"))
745758

759+
@property
760+
def checkin_times(self) -> str:
761+
return str(self.get("checkin_times"))
762+
763+
@property
764+
def checkin_jitter_minutes(self) -> int:
765+
return int(self.get("checkin_jitter_minutes"))
766+
767+
@property
768+
def log_buffer_lines(self) -> int:
769+
return int(self.get("log_buffer_lines"))
770+
746771
# ---------------------------------------------------------- 运维告警
747772

748773
@property

‎src/webapp/request_id.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from ..request_context import (
1616
REQUEST_ID_HEADER,
1717
resolve_request_id,
18+
session_id_from_headers,
1819
set_api_key_id,
1920
set_request_id,
21+
set_session_id,
2022
)
2123

2224

@@ -39,6 +41,9 @@ async def __call__(self, scope, receive, send) -> None:
3941
return
4042
request_id = resolve_request_id(_header(scope, b"x-request-id"))
4143
set_request_id(request_id)
44+
# 会话标识(E1):与请求 ID 同一中间件设置即可——它只被会话粘性读,
45+
# 且必须在校验之前落进上下文(executor 早于任何业务层)。
46+
set_session_id(session_id_from_headers(scope.get("headers", ())))
4247
header = (REQUEST_ID_HEADER.lower().encode("latin-1"),
4348
request_id.encode("latin-1"))
4449

@@ -51,7 +56,9 @@ async def send_with_header(message):
5156
try:
5257
await self.app(scope, receive, send_with_header)
5358
finally:
54-
# 复原 Key 归属:contextvar 由鉴权依赖设置,而流式响应体在依赖
55-
# 的 finally **之后**才发送,只靠依赖复原会让「响应体期间」的
56-
# 后台写入串到下一个请求。中间件包住整个 ASGI 调用,最可靠。
59+
# 复原 Key 归属与会话标识:contextvar 由鉴权依赖/本中间件设置,而
60+
# 流式响应体在依赖的 finally **之后**才发送,只靠依赖复原会让
61+
# 「响应体期间」的后台写入串到下一个请求。中间件包住整个 ASGI
62+
# 调用,最可靠。
5763
set_api_key_id(None)
64+
set_session_id(None)

‎tests/webapp/test_request_id.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,69 @@ def test_record_without_key_context_stores_null(db):
211211
collector.record(username="u", provider="trae", model="m", ok=True)
212212
assert db.connect().execute(
213213
"SELECT api_key_id FROM usage_events").fetchone()["api_key_id"] is None
214+
215+
216+
# ------------------------------------------------------- 会话标识(E1 的地基)
217+
218+
def test_session_id_defaults_none_and_round_trips():
219+
from src.request_context import get_session_id, set_session_id
220+
221+
assert get_session_id() is None
222+
set_session_id("sess-1")
223+
assert get_session_id() == "sess-1"
224+
set_session_id(None)
225+
assert get_session_id() is None
226+
227+
228+
def test_sanitize_session_id_accepts_and_rejects():
229+
from src.request_context import SESSION_ID_PATTERN, sanitize_session_id
230+
231+
for raw in ("abc", "a-b.c_d:e", "x" * 128):
232+
assert sanitize_session_id(raw) == raw
233+
for raw in (None, "", " ", "has space", "line\nbreak", "x" * 129):
234+
assert sanitize_session_id(raw) is None
235+
assert SESSION_ID_PATTERN.match("ok")
236+
237+
238+
def test_session_id_from_headers_prefers_earlier_header_names():
239+
"""按 SESSION_ID_HEADERS 顺序取第一个合法值(头名大小写不敏感)。"""
240+
from src.request_context import session_id_from_headers
241+
242+
headers = [(b"conversation-id", b"third"), (b"x-session-id", b"first"),
243+
(b"x-opencode-session", b"second")]
244+
assert session_id_from_headers(headers) == "first"
245+
only_later = [(b"conversation-id", b"conversation")]
246+
assert session_id_from_headers(only_later) == "conversation"
247+
# 大小写混合的头名同样识别
248+
assert session_id_from_headers([(b"X-Session-Id", b"mixed")]) == "mixed"
249+
250+
251+
def test_session_id_from_headers_skips_invalid_and_returns_none():
252+
from src.request_context import session_id_from_headers
253+
254+
assert session_id_from_headers([]) is None
255+
assert session_id_from_headers([(b"x-session-id", b"bad id")]) is None
256+
# 第一个头非法时继续看下一个头,而不是整体放弃
257+
assert session_id_from_headers([(b"x-session-id", b"bad id"),
258+
(b"x-opencode-session", b"good")]) == "good"
259+
260+
261+
def test_middleware_sets_and_clears_session_id():
262+
"""中间件从入站头设置会话标识,请求结束后复原。"""
263+
from fastapi import FastAPI as _FastAPI
264+
from fastapi.testclient import TestClient as _TestClient
265+
266+
from src.request_context import get_session_id
267+
268+
app = _FastAPI()
269+
app.add_middleware(RequestIdMiddleware)
270+
271+
@app.get("/sess")
272+
async def sess():
273+
return {"session": get_session_id()}
274+
275+
with _TestClient(app) as client:
276+
response = client.get("/sess", headers={"x-session-id": "abc-123"})
277+
assert response.json()["session"] == "abc-123"
278+
# 复原:下一个请求不带头时读不到上一次的值
279+
assert client.get("/sess").json()["session"] is None

0 commit comments

Comments
 (0)