一次连表把两者一起取回
GetSessionUser 用一条 JOIN 查询同时取回用户、角色与班级,较两次独立查询少一次往返,也避免两处读到不一致的快照。
认证回答「你是谁」,授权回答「你能做什么」,二者由不同部件承担。Cookie 是浏览器侧的凭据载体;Session 指服务端保存的会话状态,一条会话对应存储中的一条记录。
users 表里改一次该用户所在的行(如改 role 或 class_id),下一次请求就按新值判定权限,不必等会话过期。GetSessionUser 用一条 JOIN 查询同时取回用户、角色与班级,较两次独立查询少一次往返,也避免两处读到不一致的快照。
一次登录在两处各留一笔:浏览器侧收到一枚 Cookie,服务端新增一条会话状态。Cookie 里携带的那串值即会话令牌,它只负责被带回服务端;会话是否有效、属于谁、何时过期,全由服务端那一侧决定。会话状态放在哪里没有唯一定式,先看可选项,再看本课采用的一种。
会话状态放在哪里
本课取第四种:会话行与业务数据同在一个 MySQL 库里,不额外引入缓存组件,重启服务也不丢登录态,排查时能直接用 SQL 核对。代价同样具体——每个受保护请求多一次库往返,登录高峰时这张表会成为写热点,因此生产上更常见的是换成集中式缓存,流程其余部分不变。
本课的生命周期
| Cookie 属性 | 取值 | 挡住什么 |
|---|---|---|
HttpOnly | true | 页面脚本无法读取,XSS 取不到会话凭据 |
SameSite | Lax | 跨站的子资源请求与 POST 表单不携带(顶级 GET 导航除外),缩小 CSRF 面 |
Path | /week04 | 令牌不会带到同域下的其他挂载路径 |
MaxAge | SESSION_TTL(默认 86400 秒) | 到期由浏览器丢弃,不依赖服务端清理 |
SESSION_SECRET 未设置时进程启动即失败,不回落内置默认值——否则所有部署将共用同一把密钥。口令用 bcrypt 加盐后入库,代价因子 12(db.BcryptCost)。受保护的请求都要先过 RequireAuth,按序四步:取令牌、查会话、判定、注入。前两步是两次校验;第三步根据失败原因决定返回 401 还是 503;第四步把通过校验的身份交给处理器。
| 保护级别 | 中间件 | 入口 |
|---|---|---|
| 公开 | 无 | GET /health、POST /api/login |
| 登录即可 | RequireAuth | /api/me、POST /api/logoutGET /api/materials…、POST /api/search、POST /api/askGET /api/prompt、GET /api/skills、/api/conversations… |
| 教师专属 | RequireAuth + RequireTeacher | POST /api/materials、/api/materials/{id}/reindexPUT /api/prompt、POST / PUT / DELETE /api/skills… |
班级决定一次请求可见的数据范围。第 5 课的提示词、技能与检索均按班级切分,这条线须先理清。
| 环节 | 班级取自 | 越界时的表现 |
|---|---|---|
检索 POST /api/search | user.ClassID;请求体中的 class_id 读入后丢弃 | 返回 200 与空 hits,不返回 403 |
问答 POST /api/ask | 同上 | 同上;无依据时返回固定文案与空引用 |
向量视图 GET /api/materials/{id}/vectors | 先取材料,再比对 material.ClassID | 跨班与不存在均返回 404,响应完全一致 |
| 提示词与技能(第 5 课新增) | 同样来自 user.ClassID,写操作按班级限定 | 跨班改写与删除一律按资源不存在处理,返回 404 |
前端同样遵循这一约定:credentials: 'same-origin' 使 Cookie 自动携带,角色与班级既不写入本地存储,也不回传服务端。GET /api/me 只决定页面上显示哪些入口(可发现性),不构成授权依据。
前四节为 Cookie + Session 版。后续迭代将凭据改为 JWT(JSON Web Token,一种由头部、载荷与签名三段拼成、自身携带信息的令牌),以 Authorization: Bearer 传递。认证目标不变,变化的是凭据形态与存放位置。本节对应归档变更 2026-10-06-use-jwt-bearer-auth。
| 维度 | Cookie + Session 版 | Bearer JWT 版 |
|---|---|---|
| 凭据形态 | 32 字节随机数经 Base64URL 编码,自身不含信息 | HS256 签名的 JWT,自带声明 |
| 服务端状态 | sessions 表每会话一行 | token_revocations 表只记录已吊销的 jti |
| 传递与存放 | 浏览器按域与路径自动携带,前端不存储 | 前端显式附加 Authorization 头,凭据存于 localStorage |
| 角色与班级 | JOIN users 读取 | 同样 JOIN users 读取,不采信声明 |
| 登出即时性 | 删除会话行即失效 | 须写入吊销行才失效 |
| 主要风险 | 依赖 SameSite 与同源策略缓解 CSRF | 凭据存于 localStorage,页面脚本可读,须防 XSS |
令牌里装什么
签名算法写作 HS256,即 HMAC-SHA256:签发与验证用同一把密钥,属对称签名。它契合本课的部署形态——服务端既签发也验证,无需分发公钥;若要让第三方在不持有密钥的前提下验签,则须改用非对称算法(如 RS256,私钥签发、公钥验证)。
golang-jwt/jwt/v5 完成,不自行拼装头部与签名。被否决的方案正是「把角色与班级写入声明并据此直接授权」。一次请求的校验顺序
登出与换发:吊销表
jti 再签发新令牌。这与 Cookie + Session 版防会话固定是同一意图:避免同一用户同时持有两枚可用凭据。Bearer 版没有 Cookie 可清,登出改为把 jti 写入吊销表;同一令牌再次使用,会在鉴权阶段直接返回 401。可见「无状态」并不是真的没有状态:一旦要求立即失效或封禁某账号,仍要有一张表记着已作废的凭据,只是从「每次请求都查」变成「只在作废时写一次」。
localStorage 后,要防的是跨站脚本(XSS)——一段注入的脚本就能把它取走。因此材料正文仍不得以未净化 HTML 渲染,前端收到 401 时须清除凭据并回到登录页。sessions 表保留但不参与新路径)。切片正文与偏移量存于 MySQL,向量存于 Qdrant(向量数据库),两侧以同一个切片编号对齐。这一拆分决定了后续每一步的实现方式。
knowledge_index_state 里记录的值比对,据此判断是否需要重建。策略为空时使用服务端默认窗口:CHUNK_MAX_CHARS 默认 800 字符,相邻窗口重叠 CHUNK_OVERLAP_CHARS 默认 80 字符。第 4 课规约的三种取值为 auto(默认窗口)、custom(指定窗口)与 hierarchy(按标题层级)。切片表上设有联合唯一约束 (knowledge_entry_id, chunk_index),重复入库不会产生两份记录。
关键字路径与向量路径各自排序,再按名次融合为一份结果。融合只使用名次,不使用两路的原始分数——两个分数不在同一尺度上,无法直接比较或相加。
| 模式 | 执行路径 | 外部依赖 | 特点 |
|---|---|---|---|
keyword | MySQL 全文索引 | 仅数据库 | 字面重合度高时排序靠前;同义替换与改写无法召回 |
vector | 问句嵌入 + Qdrant | 嵌入网关与 Qdrant | 语义相近的表达可召回;专有名词与数值的字面精确匹配弱于关键字路径 |
hybrid | 两条路径均执行 | 三者都需依赖 | 默认模式,召回率最高;代价是两条路径任一失败即整次检索失败 |
RRF(Reciprocal Rank Fusion,倒数名次融合)把多路检索结果合成一份排序:某条目在某一路上排第 r 名,这一路就为它贡献 1/(k + r) 分,各路的贡献相加即为融合分。k 是把曲线压平的常数——取值越大,靠前名次之间的优势越弱;本课取 rrfK = 60,是该方法的常用取值。
它只用到名次,因而在两路分数不可比时依然可用。某条目只被一路召回时,缺席的那一路不贡献分数,不影响它参与排序;两路都命中的条目两头得分,于是排得更靠前。融合分只表示名次次序,不是相关性阈值——判断「有没有依据」另由过滤条件决定,见本节末。
两条路径的过滤方式不同:关键字路径取 MATCH … AGAINST 得分最高的至多 SEARCH_MAX_RESULTS(默认 20)条,不设绝对分下限;向量路径在 Qdrant 侧即按 payload 中的 class_id 过滤,并要求余弦相似度(两个向量的夹角余弦,取值越大越接近)不低于 SEARCH_VECTOR_MIN(默认 0.35)。两条路径随后都回查 MySQL,仅取班级一致且状态为 ready 的切片正文。融合公式取 rrfK = 60:
limit 默认 10、上限 SEARCH_MAX_RESULTS(默认 20),二者为负时返回 400;offset 越界则返回空数组而 status 仍为 ok。只有当两条路径均无命中时,status 才是 no_evidence。区分这两种情形,调用方才可辨别「无命中」与「偏移量越界」。这是第 4 课已建成的问答接口:先检索、后生成,检索无命中即不调用模型。第 5 课的流式回答在这条链路旁边另起一条,两者的差别集中在「检索落空怎么办」,见本节末。
[n] 编号。生成侧参数固定为 temperature 0.2、max_tokens 500、超时 50 秒。非 200 响应、空正文与网络失败统一折算为 ErrUnavailable,对外返回 503——错误信息不携带密钥,也不带回上游响应正文。第 4 课的历史由客户端回传,因此要在服务端逐条清洗:角色不是 user 或 assistant 的消息一律丢弃,客户端塞进来的 system 消息不会生效。
citations 数组按顺序一一对应,模型写 [2] 即指向该数组的第 2 个元素。