认证与知识库问答
公开教程 · 无需登录

预习内容:Cookie 与 Session、JWT 认证与知识库问答

1认证与授权的职责边界

认证回答「你是谁」,授权回答「你能做什么」,二者由不同部件承担。Cookie 是浏览器侧的凭据载体;Session 指服务端保存的会话状态,一条会话对应存储中的一条记录。

认证 · 你是谁 Cookie 携带的凭据 → sessions 行 → users.id 有效期与是否登出由 sessions 决定 回答不了「能做什么、能看哪个班」 授权 · 你能做什么 users.role:teacher / student users.class_id:可见数据范围 两者每次请求都从库里连表读,不缓存 业务代码只从 UserFrom(ctx) 取身份;请求头、查询参数、请求体里的角色与班级一律不采信
会话存储里通常只放标识与元数据,角色与班级这类权限要素留在业务表:只需在 users 表里改一次该用户所在的行(如改 role 或 class_id),下一次请求就按新值判定权限,不必等会话过期。

一次连表把两者一起取回

GetSessionUser 用一条 JOIN 查询同时取回用户、角色与班级,较两次独立查询少一次往返,也避免两处读到不一致的快照。

2Cookie 与 Session 的生命周期

一次登录在两处各留一笔:浏览器侧收到一枚 Cookie,服务端新增一条会话状态。Cookie 里携带的那串值即会话令牌,它只负责被带回服务端;会话是否有效、属于谁、何时过期,全由服务端那一侧决定。会话状态放在哪里没有唯一定式,先看可选项,再看本课采用的一种。

会话状态放在哪里

进程内存 单机最省事;进程重启后已登录状态全部失效 限单机 本地文件 一个会话一个文件,多机要挂共享盘 限单机 集中式缓存 过期时间原生支持,多副本共享,生产环境最常见 可多副本 关系库表 与业务同库,重启不丢,可直接用 SQL 核对 本课采用 签名令牌 状态放在令牌自身,服务端不存;要能立即失效仍得配一张表 无状态
五种选择没有绝对优劣,取舍看三件事:能否多副本共享、是否要求「登出即失效」、愿不愿意多引一个组件。前两种只适合单机,第四种与业务数据同库、最省组件,第五种把状态挪进凭据本身。

本课取第四种:会话行与业务数据同在一个 MySQL 库里,不额外引入缓存组件,重启服务也不丢登录态,排查时能直接用 SQL 核对。代价同样具体——每个受保护请求多一次库往返,登录高峰时这张表会成为写热点,因此生产上更常见的是换成集中式缓存,流程其余部分不变。

本课的生命周期

生成 32 字节随机源 Base64URL 无填充 → 43 个字符 下发 Set-Cookie HttpOnly · Lax Path=/week04 入库 sessions 新增一行 user_id + 到期时刻 存于 MySQL 校验 取回 Cookie 拿令牌定位该行 未过期即认作登录 失效 删除 sessions 行 MaxAge = -1 启动清过期行 Cookie 只携带令牌本身 · 会话状态、有效期与失效全在服务端 · 浏览器侧不含身份信息
令牌是 32 字节随机数,自身不含任何用户信息;会话表以它为查询入口并带唯一约束,同一枚令牌不会对应两行。上面选定的存放位置只影响这几步落在哪,不影响流程本身。
Cookie 属性取值挡住什么
HttpOnlytrue页面脚本无法读取,XSS 取不到会话凭据
SameSiteLax跨站的子资源请求与 POST 表单不携带(顶级 GET 导航除外),缩小 CSRF 面
Path/week04令牌不会带到同域下的其他挂载路径
MaxAgeSESSION_TTL(默认 86400 秒)到期由浏览器丢弃,不依赖服务端清理
SESSION_SECRET 未设置时进程启动即失败,不回落内置默认值——否则所有部署将共用同一把密钥。口令用 bcrypt 加盐后入库,代价因子 12(db.BcryptCost)。

3一次请求的身份校验流程

受保护的请求都要先过 RequireAuth,按序四步:取令牌、查会话、判定、注入。前两步是两次校验;第三步根据失败原因决定返回 401 还是 503;第四步把通过校验的身份交给处理器。

1 取令牌 从 Cookie 里读出会话令牌;读不到直接 401,不再往下走 不查库 2 查会话 按令牌取会话行,一条 JOIN 同时取回用户、角色与班级,并确认未过期 查库 3 判定 查不到会话行 → 401;查询出错 → 503 并记日志 不查库 4 注入 把用户写入请求上下文;业务代码只从上下文取身份,不采信请求参数 不查库
只有「查会话」一步访问数据库,其余三步都在进程内完成。判定区分的是故障出在哪一侧:会话查不到是凭据的问题(401),数据库查不动是服务端的问题(503)。两者混同,使用者只会反复重登而无从排查。
保护级别中间件入口
公开无GET /health、POST /api/login
登录即可RequireAuth/api/me、POST /api/logout
GET /api/materials…、POST /api/search、POST /api/ask
GET /api/prompt、GET /api/skills、/api/conversations…
教师专属RequireAuth + RequireTeacherPOST /api/materials、/api/materials/{id}/reindex
PUT /api/prompt、POST / PUT / DELETE /api/skills…
默认拒绝原则:每个入口在注册时即声明保护级别,不存在「先放行、再由处理器判定」的写法。上表按级别归并,同类路由合并为一行;教师专属接口遇非教师身份返回 403 并记日志。班级与资源归属不在中间件内判定,统一取自会话中的用户,见第 4 节。

4班级来源:数据隔离的唯一依据

班级决定一次请求可见的数据范围。第 5 课的提示词、技能与检索均按班级切分,这条线须先理清。

环节班级取自越界时的表现
检索 POST /api/searchuser.ClassID;请求体中的 class_id 读入后丢弃返回 200 与空 hits,不返回 403
问答 POST /api/ask同上同上;无依据时返回固定文案与空引用
向量视图 GET /api/materials/{id}/vectors先取材料,再比对 material.ClassID跨班与不存在均返回 404,响应完全一致
提示词与技能(第 5 课新增)同样来自 user.ClassID,写操作按班级限定跨班改写与删除一律按资源不存在处理,返回 404
跨班检索表现为接口可访问但结果为空,而非 403:403 会确认资源存在,从而泄露他班材料的存在性。按编号定位的单个资源相反——跨班与不存在必须返回完全一致的 404,使调用方无法区分二者。

前端同样遵循这一约定:credentials: 'same-origin' 使 Cookie 自动携带,角色与班级既不写入本地存储,也不回传服务端。GET /api/me 只决定页面上显示哪些入口(可发现性),不构成授权依据。

5另一种实现:Bearer JWT

前四节为 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,私钥签发、公钥验证)。

header alg: HS256 · typ: JWT payload sub · jti · iat · exp signature 由服务端密钥生成 . . sub 是用户 ID,只用来回库读用户行;jti 是 16 字节随机的十六进制,作为吊销表的键 exp = 签发时刻 + SESSION_TTL_SECONDS;解析时限定算法为 HS256 且要求 exp 必填 none 与其他算法一律 401,且不访问数据库 角色与班级不写进声明:写进去就得等令牌过期才生效,且旧令牌会带着旧角色继续可用
签发与解析均由 golang-jwt/jwt/v5 完成,不自行拼装头部与签名。被否决的方案正是「把角色与班级写入声明并据此直接授权」。

一次请求的校验顺序

1 取 Bearer 没有 Authorization、格式不是 Bearer、令牌含空格 → 401 不查库 2 验签与有效期 parseClaims:算法、exp、sub 与 jti 是否齐备 → 失败 401 不查库 3 查吊销表 TokenRevoked(jti):已吊销 → 401;数据库错误 → 503 查库 4 读用户行 GetUserByID(sub) 连 classes:无此行 → 401;其他错误 → 503 查库 5 注入上下文 WithUser(ctx, user);RequireTeacher 仍只读这个上下文 不查库
前两步不访问数据库,非法令牌在校验阶段即被拒绝,不产生数据库负载;后两步的判定与第 3 节一致——「查不到行」才是 401,数据库不可用是 503。

登出与换发:吊销表

令牌有效期 iat → exp 签发 iat 到期 exp 吊销行:jti 记入,expires_at = 原 exp 登出 / 换发 吊销行仅在原令牌过期前有意义:过期后由清理任务删除,表不会无限增长
所谓换发,指登录请求若带着尚未过期的旧令牌,先吊销它的 jti 再签发新令牌。这与 Cookie + Session 版防会话固定是同一意图:避免同一用户同时持有两枚可用凭据。

Bearer 版没有 Cookie 可清,登出改为把 jti 写入吊销表;同一令牌再次使用,会在鉴权阶段直接返回 401。可见「无状态」并不是真的没有状态:一旦要求立即失效或封禁某账号,仍要有一张表记着已作废的凭据,只是从「每次请求都查」变成「只在作废时写一次」。

改为 Bearer 并非「更安全」,而是风险位置发生转移。Cookie 由浏览器按域自动附带,要防的是跨站请求伪造(CSRF)——别的站点能让浏览器替你发出带凭据的请求;令牌改存到页面脚本可读的 localStorage 后,要防的是跨站脚本(XSS)——一段注入的脚本就能把它取走。因此材料正文仍不得以未净化 HTML 渲染,前端收到 401 时须清除凭据并回到登录页。
未随之变更的部分:限流维度与阈值(用户名 + 来源地址,5 次 / 300 秒,锁定返回 401 而非 429)、角色与班级仍回库读取、无刷新令牌(sessions 表保留但不参与新路径)。

6知识库的双存储分工

切片正文与偏移量存于 MySQL,向量存于 Qdrant(向量数据库),两侧以同一个切片编号对齐。这一拆分决定了后续每一步的实现方式。

MySQL · knowledge_chunks id · class_id · material_id · chunk_index chunk_text 正文,MEDIUMTEXT start_offset / end_offset 可回原文定位 index_status pending / ready / failed FULLTEXT ft_chunk_text WITH PARSER ngram Qdrant · campusclaw_chunks point id = 切片 id vector dim = EMBEDDING_DIM,Cosine payload:class_id / material_id / chunk_id payload:knowledge_entry_id / chunk_index 载荷字段须与 MySQL 侧的切片行逐一对上,才算两侧对齐 同一个 id 写入顺序:先写 MySQL 取得自增切片 id → 嵌入 → 用同一 id 写 Qdrant → 标 ready 嵌入或写入失败:标 failed、回滚已写向量,对外统一 503
先写 MySQL 后写 Qdrant,是为取得自增主键作为向量主键;顺序相反则需额外维护一张映射表。此外每次重建索引前,都会拿正文重新算一遍,与 knowledge_index_state 里记录的值比对,据此判断是否需要重建。

切分策略随材料元数据给出

策略为空时使用服务端默认窗口:CHUNK_MAX_CHARS 默认 800 字符,相邻窗口重叠 CHUNK_OVERLAP_CHARS 默认 80 字符。第 4 课规约的三种取值为 auto(默认窗口)、custom(指定窗口)与 hierarchy(按标题层级)。切片表上设有联合唯一约束 (knowledge_entry_id, chunk_index),重复入库不会产生两份记录。

7三种检索模式与 RRF 融合

关键字路径与向量路径各自排序,再按名次融合为一份结果。融合只使用名次,不使用两路的原始分数——两个分数不在同一尺度上,无法直接比较或相加。

模式执行路径外部依赖特点
keywordMySQL 全文索引仅数据库字面重合度高时排序靠前;同义替换与改写无法召回
vector问句嵌入 + Qdrant嵌入网关与 Qdrant语义相近的表达可召回;专有名词与数值的字面精确匹配弱于关键字路径
hybrid两条路径均执行三者都需依赖默认模式,召回率最高;代价是两条路径任一失败即整次检索失败

RRF 是什么

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:

切片 关键字名次 向量名次 计算式 融合分 切片 A 第 1 名 第 3 名 1/(60+1) + 1/(60+3) 0.03227 切片 B 第 2 名 未命中 1/(60+2) 0.01613 切片 C 未命中 第 1 名 1/(60+1) 0.01639 排序结果:A 0.03227 > C 0.01639 > B 0.01613 两路都命中者排到最前 融合分只表示名次,不表示相关程度;同分时取切片编号小者
关键字路径的第 2 名反而低于向量路径的第 1 名——两条路径的原始分数不在同一尺度上,无法直接比较,这正是融合只取名次的原因;若要给某一路加权,调整的是名次项前的系数。
分页作用于融合之后:limit 默认 10、上限 SEARCH_MAX_RESULTS(默认 20),二者为负时返回 400;offset 越界则返回空数组而 status 仍为 ok。只有当两条路径均无命中时,status 才是 no_evidence。区分这两种情形,调用方才可辨别「无命中」与「偏移量越界」。

8有依据方才生成

这是第 4 课已建成的问答接口:先检索、后生成,检索无命中即不调用模型。第 5 课的流式回答在这条链路旁边另起一条,两者的差别集中在「检索落空怎么办」,见本节末。

POST /api/ask q 长度 2–500 字 班级取自 user.ClassID 本班混合检索 mode=hybrid,limit=4,offset=0 命中数为 0 ? 无依据 status = no_evidence answer = 资料中未找到相关内容 citations = [] 模型调用 0 次 有依据 再到 MySQL 取切片全文,按名次编号 [1]…[n] 每条正文截取前 800 字,拼入 system 消息 历史只留 user/assistant:最近 6 条消息、每条 500 字 chat/completions → answer + citations
无依据分支同样返回 200:是否流式只决定回答如何送达,不决定生成什么。第 5 课的流式回答另有一条口径——检索落空时它仍调用模型,只是引用列表为空、正文不带编号。
两条路径并存,口径不同:本接口在检索无命中时直接回固定文案,模型调用 0 次;第 5 课的流式回答在落空或该智能体未绑定材料时仍调用模型,回答不得整段等于「资料中未找到相关内容」,也不得出现 [n] 编号。

生成侧参数固定为 temperature 0.2、max_tokens 500、超时 50 秒。非 200 响应、空正文与网络失败统一折算为 ErrUnavailable,对外返回 503——错误信息不携带密钥,也不带回上游响应正文。第 4 课的历史由客户端回传,因此要在服务端逐条清洗:角色不是 user 或 assistant 的消息一律丢弃,客户端塞进来的 system 消息不会生效。

引用编号在组装提示词时按顺序即时生成,仅计入确实取到正文的切片;编号自 1 起,与返回的 citations 数组按顺序一一对应,模型写 [2] 即指向该数组的第 2 个元素。