TonyD365 commited on
Commit
5e3c7c4
·
verified ·
1 Parent(s): 6ec3272

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +179 -0
app.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FSD Simulator 缓存中间层 (Hugging Face Space + Docker)
3
+ =====================================================
4
+ 作用:挡在 Roblox 和 Turso 之间,做两件事
5
+ 1) 写勤:接收 Roblox 批量上报的驾驶行为,直通写入 Turso(不缓存)
6
+ 2) 读懒:统计查询结果缓存 12 小时,未命中才回源 Turso
7
+
8
+ 所有密钥从【环境变量】读取(在 HF Space 的 Settings -> Secrets 里填):
9
+ TURSO_URL 形如 https://yourdb-yourorg.turso.io/v2/pipeline
10
+ TURSO_TOKEN Turso 的 Bearer Token(建议用 INSERT/SELECT 权限的,不要全权)
11
+ APP_SECRET 保护本服务自己的口令,Roblox 请求时放进 X-App-Key 请求头
12
+ """
13
+
14
+ import os
15
+ import time
16
+ import random
17
+ from typing import Any, Optional
18
+
19
+ import httpx
20
+ from fastapi import FastAPI, Header, HTTPException, Query
21
+ from pydantic import BaseModel
22
+
23
+ # ---------- 从环境变量读配置(绝不把密钥硬编码进代码!)----------
24
+ TURSO_URL = os.environ.get("TURSO_URL", "")
25
+ TURSO_TOKEN = os.environ.get("TURSO_TOKEN", "")
26
+ APP_SECRET = os.environ.get("APP_SECRET", "")
27
+
28
+ # 缓存基础有效期:12 小时(单位:秒)
29
+ CACHE_TTL = 12 * 60 * 60
30
+ # 抖动范围 ±30 分钟,避免所有 key 同一刻过期造成"缓存雪崩"
31
+ CACHE_JITTER = 30 * 60
32
+
33
+ app = FastAPI(title="FSD Cache Layer")
34
+
35
+ # ---------- 内存缓存(进程一重启就清空,所以只当加速层,不当唯一数据源)----------
36
+ # 结构: { 缓存键: (数据, 过期时间戳) }
37
+ _cache: dict[str, tuple[Any, float]] = {}
38
+
39
+
40
+ def cache_get(key: str):
41
+ item = _cache.get(key)
42
+ if not item:
43
+ return None
44
+ value, expiry = item
45
+ if time.time() > expiry: # 过期了
46
+ _cache.pop(key, None)
47
+ return None
48
+ return value
49
+
50
+
51
+ def cache_set(key: str, value: Any):
52
+ jitter = random.randint(-CACHE_JITTER, CACHE_JITTER)
53
+ _cache[key] = (value, time.time() + CACHE_TTL + jitter)
54
+
55
+
56
+ # ---------- Turso HTTP 封装 ----------
57
+ def _to_arg(v: Any) -> dict:
58
+ """把 Python 值转成 Turso (Hrana) 参数格式。
59
+ 注意:整数用字符串传,避免超大整数精度丢失。"""
60
+ if v is None:
61
+ return {"type": "null", "value": None}
62
+ if isinstance(v, bool): # SQLite 没有 bool,存成 0/1
63
+ return {"type": "integer", "value": str(int(v))}
64
+ if isinstance(v, int):
65
+ return {"type": "integer", "value": str(v)}
66
+ if isinstance(v, float):
67
+ return {"type": "float", "value": v}
68
+ return {"type": "text", "value": str(v)}
69
+
70
+
71
+ async def turso_batch(statements: list[tuple[str, list]]) -> list:
72
+ """一次 HTTP 请求里执行多条 SQL(批量,省请求次数)。
73
+ statements: [(sql, [参数...]), ...]
74
+ 返回:每条语句对应的 response 对象列表。"""
75
+ if not TURSO_URL or not TURSO_TOKEN:
76
+ raise HTTPException(500, "服务端未配置 TURSO_URL / TURSO_TOKEN")
77
+
78
+ requests = []
79
+ for sql, args in statements:
80
+ requests.append({
81
+ "type": "execute",
82
+ "stmt": {"sql": sql, "args": [_to_arg(a) for a in args]},
83
+ })
84
+ requests.append({"type": "close"})
85
+
86
+ async with httpx.AsyncClient(timeout=15) as client:
87
+ resp = await client.post(
88
+ TURSO_URL,
89
+ headers={
90
+ "Authorization": f"Bearer {TURSO_TOKEN}",
91
+ "Content-Type": "application/json",
92
+ },
93
+ json={"requests": requests},
94
+ )
95
+ resp.raise_for_status()
96
+ data = resp.json()
97
+
98
+ results = []
99
+ for item in data.get("results", []):
100
+ if item.get("type") == "error":
101
+ raise HTTPException(502, f"Turso 报错: {item.get('error')}")
102
+ results.append(item.get("response", {}))
103
+ return results
104
+
105
+
106
+ def parse_rows(execute_response: dict) -> list[dict]:
107
+ """把 Turso 的 execute 结果转成 [{列名: 值}, ...] 这种好用的格式。"""
108
+ result = execute_response.get("result", {})
109
+ cols = [c.get("name") for c in result.get("cols", [])]
110
+ rows_out = []
111
+ for row in result.get("rows", []):
112
+ rows_out.append({col: cell.get("value") for col, cell in zip(cols, row)})
113
+ return rows_out
114
+
115
+
116
+ # ---------- 鉴权:校验 Roblox 传来的口令 ----------
117
+ def check_app_key(x_app_key: Optional[str]):
118
+ # 没设 APP_SECRET 就跳过校验(方便本地测试);线上务必设置!
119
+ if APP_SECRET and x_app_key != APP_SECRET:
120
+ raise HTTPException(401, "X-App-Key 无效")
121
+
122
+
123
+ # ==================== 路由 ====================
124
+
125
+ @app.get("/")
126
+ def health():
127
+ """保活端点:给 UptimeRobot 每 5 分钟 ping 一次,保持 Space 不休眠。
128
+ 故意做得极轻——不碰 Turso、不碰缓存。"""
129
+ return {"status": "ok"}
130
+
131
+
132
+ class Event(BaseModel):
133
+ user_id: int
134
+ scene: str
135
+ action: str
136
+
137
+
138
+ class LogPayload(BaseModel):
139
+ events: list[Event]
140
+
141
+
142
+ @app.post("/log")
143
+ async def log_events(payload: LogPayload,
144
+ x_app_key: Optional[str] = Header(default=None)):
145
+ """【写勤】Roblox 批量上报驾驶行为,直通写入 Turso(不缓存)。
146
+ 玩家数据必须尽快落库,不能压在缓存里等 12 小时——Space 一重启就丢了。"""
147
+ check_app_key(x_app_key)
148
+ if not payload.events:
149
+ return {"written": 0}
150
+
151
+ statements = [
152
+ ("INSERT INTO driving_events (user_id, scene, action) VALUES (?, ?, ?)",
153
+ [e.user_id, e.scene, e.action])
154
+ for e in payload.events
155
+ ]
156
+ await turso_batch(statements)
157
+ return {"written": len(statements)}
158
+
159
+
160
+ @app.get("/stats")
161
+ async def scene_stats(scene: str = Query(...),
162
+ x_app_key: Optional[str] = Header(default=None)):
163
+ """【读懒】查某个场景下各操作的人气统计,结果缓存 12 小时。
164
+ NPC 决策大量调这个,能命中缓存就绝不打扰 Turso。"""
165
+ check_app_key(x_app_key)
166
+
167
+ cache_key = f"stats:{scene}"
168
+ cached = cache_get(cache_key)
169
+ if cached is not None:
170
+ return {"scene": scene, "stats": cached, "cached": True}
171
+
172
+ # 未命中 → 回源 Turso 查一次,再写回缓存
173
+ responses = await turso_batch([
174
+ ("SELECT action, COUNT(*) AS cnt FROM driving_events "
175
+ "WHERE scene = ? GROUP BY action ORDER BY cnt DESC", [scene])
176
+ ])
177
+ rows = parse_rows(responses[0])
178
+ cache_set(cache_key, rows)
179
+ return {"scene": scene, "stats": rows, "cached": False}