账号、配额、管理后台:一份需求拆成九步的落地过程
这篇按实际动手顺序记录这一段:五张表怎么加、密码和会话用什么、真实客户端 IP 为什么不能取 request.client.host、隔离放在存储层之后 19 个既有测试为什么挂、两个跨账号测试暴露出的越权、匿名身份怎么处理,以及管理后台和前端各做了什么。
关键词:请求上下文、contextvar、行级归属、越权防护、Cloudflare 真实 IP
需求原文
「需要。同时我需要用户机制+admin 后台。可以不登录就生成,但是有次数限制,同 ip 一天 1 次,同时要防止/检测对话被 prompt 注入用于反代,封禁 ip。登录后 次数为一天 10 次(admin 可以设置磨个用户豁免),数据按账号隔离。」
我把这段拆成九项:数据层、鉴权、配额、真实 IP、隔离、匿名身份、后台接口、前端、测试。反注入那部分单独写了一篇。
一、表结构
新增五张表:users、sessions、ip_bans、usage_log、security_events。itineraries 加一列 user_id,先用 PRAGMA table_info 查一下再 ALTER,老库能直接升级。
usage_log 记 (主体, 类型, 哪一天),主体是 user:<id> 或 ip:<ip>。「天」按东八区切,服务端跑在 UTC 的时候直接用 time.time() 会差 8 小时,午夜前后额度会提前重置。
二、密码与会话
密码用标准库的 scrypt,16 字节 salt,存成 scrypt$n$r$p$salt$hash。没有引第三方依赖。
会话是 32 字节随机串,写进 httpOnly、SameSite=Lax 的 cookie,有效期 30 天。登录失败时「用户不存在」和「密码错」返回同一句话,避免被拿来撞库探测。
三、真实客户端 IP
写限流之前先去确认了一件事:服务在 Cloudflare 隧道后面,容器监听本地端口,request.client.host 拿到的是网关地址。用它当 key,全站共用一个额度。
按 cf-connecting-ip、x-real-ip、x-forwarded-for、client.host 的顺序取。cf-connecting-ip 放在最前,因为这一项由 Cloudflare 写入并覆盖,客户端改不了。
写死的数字都提成了配置项,前缀 TRIPSMITH_:
| 配置 | 默认 | 作用 |
|---|---|---|
ANON_DAILY_LIMIT | 1 | 匿名每个 IP 每天可生成次数 |
USER_DAILY_LIMIT | 10 | 登录用户默认次数 |
ANON_HOURLY_CHAT | 10 | 匿名每小时对话条数 |
USER_HOURLY_CHAT | 60 | 登录用户每小时对话条数 |
ADMIN_USERNAME / ADMIN_PASSWORD | admin / 空 | 首次启动建管理员 |
SESSION_TTL_DAYS | 30 | 会话有效期 |
ABUSE_BAN_THRESHOLD / ABUSE_BAN_HOURS | 3 / 24 | 命中几次自动封、封多久 |
四、隔离放在存储层
路由 36 个,store 调用 45 处。如果每处写一遍归属判断,漏掉任何一处都会变成越权,而且这类代码长得都一样,review 时很难发现。
改成中间件解析身份写进 contextvar,store.get、store.list、store.save 内部按上下文过滤。get 返回 None 的时候,路由原有的 404 分支自然生效,36 个路由的代码没改。
def get(self, itinerary_id):
row = self.conn.execute("SELECT data, user_id FROM itineraries WHERE id=?", ...)
if not row:
return None
if not can_access(str(row["user_id"] or "")):
return None
return Itinerary.model_validate_json(row["data"])

中间件本身很短:解析会话、读 guest cookie、写三个 contextvar,finally 里复位。
@app.middleware("http")
async def _identity(request, call_next):
user = current_user(request)
guest = request.cookies.get("ts_guest") or ""
new_guest = ""
if not guest and not user:
guest = secrets.token_urlsafe(12)
new_guest = guest
tokens = set_ctx(user_id, is_admin, guest)
try:
resp = await call_next(request)
finally:
reset_ctx(tokens)
if new_guest:
resp.set_cookie("ts_guest", new_guest, max_age=180 * 24 * 3600,
httponly=True, samesite="lax", path="/")
return resp
改完之后 19 个既有测试挂了。逐个看下去,它们都在请求之外直接调 store.get,那时上下文是空的。加了一个 in_request 标记,不在请求里就不做过滤,CLI、后台任务、测试都走这条路。
五、两个跨账号测试暴露的问题
第一个:B 账号能删掉 A 的行程。DELETE 路由直接调了 store.delete(id),绕开了 get 的过滤。改成先 get 再删,回滚版本那条路由同样处理。
第二个更隐蔽。跨账号测试发现 alice 生成的行程归属是空的。原因是 SSE 生成器在中间件返回之后才执行,那时 contextvar 已经重置,owner_for_new() 拿到空串,登录用户的行程被存成了匿名。
修法是在进生成器之前把归属算好,当参数传进任务:
owner_id = owner_for_new() # 此刻上下文还在
...
async def gen():
store.save(it, owner_id=owner_id)
六、匿名身份
配额按 IP 算,cookie 清一下就重置了,所以配额不能拿 cookie 当依据。列表需要身份,于是加了 ts_guest cookie,用来认「这是同一个浏览器」,行程归属存成 guest:<token>。
登录和注册时,把这个 guest 名下的行程改到账号上。用户先试一条、觉得不错再去注册,东西还在。
认领的 SQL 一开始写成 WHERE user_id='',跑测试发现没生效,因为匿名行程的归属是 guest:xxx。改成同时匹配空归属和 guest:%。
七、管理后台
#/admin 需要 role=admin,前端隐藏入口,后端 403 兜底。三块内容:用户(每日额度、豁免、停用、删除)、IP 封禁、安全事件与统计。
豁免是 users 表的一个布尔列,配额判断时最先读它:
def quota_limit(user):
if user and (user["role"] == "admin" or user["is_exempt"]):
return None
...
初始化时如果 users 表里没有 admin,用 TRIPSMITH_ADMIN_PASSWORD 建一个;没配就随机生成并打到日志。
接口六个:GET /api/admin/users、PATCH 与 DELETE 单个用户、GET/POST/DELETE 封禁、GET /api/admin/events、GET /api/admin/stats。改自己和删自己都会被拒。

八、前端
登录弹窗、账号区(剩余次数、用户名、退出、后台入口)、管理页。
改动落在这些文件上:stores/auth.ts 存登录态和配额文案,components/account/LoginDialog.tsx、AccountChip.tsx,pages/Admin.tsx 加 styles/admin.css,App.tsx 挂弹窗,AppRouter 加 /admin 路由。
弹窗里我一开始写了一段额度说明,你指出登录页不该解释这种东西,删掉了,并把这条写进仓库根目录的 AGENTS.md。后台保留「每日额度」「豁免」这些词,那里是管理员在配置系统。
九、测试与上线
新增 15 个测试:注册登录登出、用户名和密码校验、匿名第 2 次返回 429、登录后额度独立计算、豁免连跑 3 次、跨账号 404(列表、详情、对话、删除)、匿名换浏览器看不到别人的行程、guest 行程注册后认领、admin 路由 403 与 401、封禁 IP 403、注入拦截与正常语句放行、安全事件落库。
后端总数 185。
前端用 playwright 过了一遍:未登录时首页右上角是「登录」按钮,点开是弹窗;注册之后账号区显示「今日剩余 10 次」和用户名;普通账号打开 #/admin 看到的是「当前账号不是管理员」;换成 admin 登录后用户表、封禁、事件三块都有数据。
线上实测:匿名 limit 为 1,admin 为 None。