Update app.py
Browse files
app.py
CHANGED
|
@@ -14,6 +14,8 @@ FSD Simulator 缓存中间层 (Hugging Face Space + Docker)
|
|
| 14 |
import os
|
| 15 |
import time
|
| 16 |
import random
|
|
|
|
|
|
|
| 17 |
from typing import Any, Optional
|
| 18 |
|
| 19 |
import httpx
|
|
@@ -30,6 +32,9 @@ CACHE_TTL = 12 * 60 * 60
|
|
| 30 |
# 抖动范围 ±30 分钟,避免所有 key 同一刻过期造成"缓存雪崩"
|
| 31 |
CACHE_JITTER = 30 * 60
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
app = FastAPI(title="FSD Cache Layer")
|
| 34 |
|
| 35 |
# ---------- 内存缓存(进程一重启就清空,所以只当加速层,不当唯一数据源)----------
|
|
@@ -113,11 +118,29 @@ def parse_rows(execute_response: dict) -> list[dict]:
|
|
| 113 |
return rows_out
|
| 114 |
|
| 115 |
|
| 116 |
-
# ---------- 鉴权:校验
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
# ==================== 路由 ====================
|
|
@@ -141,10 +164,11 @@ class LogPayload(BaseModel):
|
|
| 141 |
|
| 142 |
@app.post("/log")
|
| 143 |
async def log_events(payload: LogPayload,
|
| 144 |
-
|
|
|
|
| 145 |
"""【写勤】Roblox 批量上报驾驶行为,直通写入 Turso(不缓存)。
|
| 146 |
玩家数据必须尽快落库,不能压在缓存里等 12 小时——Space 一重启就丢了。"""
|
| 147 |
-
|
| 148 |
if not payload.events:
|
| 149 |
return {"written": 0}
|
| 150 |
|
|
@@ -159,10 +183,11 @@ async def log_events(payload: LogPayload,
|
|
| 159 |
|
| 160 |
@app.get("/stats")
|
| 161 |
async def scene_stats(scene: str = Query(...),
|
| 162 |
-
|
|
|
|
| 163 |
"""【读懒】查某个场景下各操作的人气统计,结果缓存 12 小时。
|
| 164 |
NPC 决策大量调这个,能命中缓存就绝不打扰 Turso。"""
|
| 165 |
-
|
| 166 |
|
| 167 |
cache_key = f"stats:{scene}"
|
| 168 |
cached = cache_get(cache_key)
|
|
|
|
| 14 |
import os
|
| 15 |
import time
|
| 16 |
import random
|
| 17 |
+
import hashlib
|
| 18 |
+
import hmac
|
| 19 |
from typing import Any, Optional
|
| 20 |
|
| 21 |
import httpx
|
|
|
|
| 32 |
# 抖动范围 ±30 分钟,避免所有 key 同一刻过期造成"缓存雪崩"
|
| 33 |
CACHE_JITTER = 30 * 60
|
| 34 |
|
| 35 |
+
# 签名有效时间窗(秒):容忍 Roblox 与服务端 5 分钟时钟差,同时限制重放窗口
|
| 36 |
+
SIGNATURE_WINDOW = 300
|
| 37 |
+
|
| 38 |
app = FastAPI(title="FSD Cache Layer")
|
| 39 |
|
| 40 |
# ---------- 内存缓存(进程一重启就清空,所以只当加速层,不当唯一数据源)----------
|
|
|
|
| 118 |
return rows_out
|
| 119 |
|
| 120 |
|
| 121 |
+
# ---------- 鉴权:时间戳签名校验 ----------
|
| 122 |
+
# Roblox 端发送两个请求头:
|
| 123 |
+
# X-Timestamp = 当前 Unix 秒(Luau 的 os.time())
|
| 124 |
+
# X-Signature = sha256(APP_SECRET + X-Timestamp) 的十六进制小写
|
| 125 |
+
# 好处:不再明文传 APP_SECRET;签名随时间变化,超出时间窗即失效。
|
| 126 |
+
def check_signature(x_timestamp: Optional[str], x_signature: Optional[str]):
|
| 127 |
+
# 没设 APP_SECRET 就跳过(方便本地测试);线上务必设置!
|
| 128 |
+
if not APP_SECRET:
|
| 129 |
+
return
|
| 130 |
+
if not x_timestamp or not x_signature:
|
| 131 |
+
raise HTTPException(401, "缺少 X-Timestamp 或 X-Signature")
|
| 132 |
+
try:
|
| 133 |
+
t = int(x_timestamp)
|
| 134 |
+
except ValueError:
|
| 135 |
+
raise HTTPException(401, "X-Timestamp 格式错误")
|
| 136 |
+
# 超出时间窗 → 视为过期或重放,拒绝
|
| 137 |
+
if abs(int(time.time()) - t) > SIGNATURE_WINDOW:
|
| 138 |
+
raise HTTPException(401, "签名已过期(检查两端时钟)")
|
| 139 |
+
# 用同样的 APP_SECRET + 原始时间戳字符串重算,必须和客户端逐字符一致
|
| 140 |
+
expected = hashlib.sha256((APP_SECRET + x_timestamp).encode()).hexdigest()
|
| 141 |
+
# 恒定时间比较,防时序攻击(timing attack)
|
| 142 |
+
if not hmac.compare_digest(expected, x_signature):
|
| 143 |
+
raise HTTPException(401, "签名无效")
|
| 144 |
|
| 145 |
|
| 146 |
# ==================== 路由 ====================
|
|
|
|
| 164 |
|
| 165 |
@app.post("/log")
|
| 166 |
async def log_events(payload: LogPayload,
|
| 167 |
+
x_timestamp: Optional[str] = Header(default=None),
|
| 168 |
+
x_signature: Optional[str] = Header(default=None)):
|
| 169 |
"""【写勤】Roblox 批量上报驾驶行为,直通写入 Turso(不缓存)。
|
| 170 |
玩家数据必须尽快落库,不能压在缓存里等 12 小时——Space 一重启就丢了。"""
|
| 171 |
+
check_signature(x_timestamp, x_signature)
|
| 172 |
if not payload.events:
|
| 173 |
return {"written": 0}
|
| 174 |
|
|
|
|
| 183 |
|
| 184 |
@app.get("/stats")
|
| 185 |
async def scene_stats(scene: str = Query(...),
|
| 186 |
+
x_timestamp: Optional[str] = Header(default=None),
|
| 187 |
+
x_signature: Optional[str] = Header(default=None)):
|
| 188 |
"""【读懒】查某个场景下各操作的人气统计,结果缓存 12 小时。
|
| 189 |
NPC 决策大量调这个,能命中缓存就绝不打扰 Turso。"""
|
| 190 |
+
check_signature(x_timestamp, x_signature)
|
| 191 |
|
| 192 |
cache_key = f"stats:{scene}"
|
| 193 |
cached = cache_get(cache_key)
|