跳到主要内容

React SPA 集成 Google Analytics 4 完整指南

· 阅读需 3 分钟

TL;DR

React SPA 集成 GA4 的关键点:1) 禁用 send_page_view: false 避免重复追踪;2) 用 useLocation 监听路由变化手动发送 pageview;3) 登录后设置 user_id 实现跨设备追踪。

问题现象

在 React SPA 中直接使用 GA4 默认配置会导致:

  1. 首次加载时 page_view 重复计数
  2. 路由切换时不触发 page_view
  3. 无法追踪登录用户的跨设备行为

根因

GA4 默认在脚本加载时自动发送一次 page_view 事件。但 SPA 的路由切换不刷新页面,GA4 无法感知 URL 变化。同时,User-ID 需要在用户登录后手动设置,默认配置无法关联用户身份。

解决方案

1. 禁用自动 page_view

index.html 中加载 GA4 时,设置 send_page_view: false

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
</script>

2. 创建 Analytics 组件追踪路由

// src/components/Analytics.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetIdOrDate: string | Date,
params?: Record<string, unknown>
) => void
}
}

export function Analytics() {
const location = useLocation()
const user = useAuthStore((state) => state.user)

useEffect(() => {
if (typeof window.gtag === 'function') {
const params: Record<string, unknown> = {
page_path: location.pathname + location.search,
}
// 已登录用户添加 user_id
if (user?.id) {
params.user_id = user.id
}
window.gtag('config', 'G-XXXXXXXXXX', params)
}
}, [location, user?.id])

return null
}

3. 包裹路由根节点

// src/app/routes.tsx
import { Outlet } from 'react-router-dom'
import { Analytics } from '@/components/Analytics'

function RootLayout() {
return (
<>
<Analytics />
<Outlet />
</>
)
}

export const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
// 你的路由配置...
],
},
])

4. 登录时设置 User-ID(可选增强)

// src/hooks/useAuth.ts
import { supabase } from '@/services/supabase'

export function useAuth() {
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN' && session) {
// 设置 GA4 User-ID
if (typeof window.gtag === 'function') {
window.gtag('config', 'G-XXXXXXXXXX', {
user_id: session.user.id
})
}
}
}
)
return () => subscription.unsubscribe()
}, [])
}

FAQ

Q: React SPA 中 GA4 为什么不追踪路由变化?

A: GA4 默认只在页面加载时发送 page_view。SPA 路由切换不刷新页面,需要手动调用 gtag('config', ...) 发送 pageview。

Q: GA4 User-ID 有什么用?

A: User-ID 可以关联同一用户在不同设备上的行为,用于跨设备分析、用户留存分析等高级功能。需要在 GA4 后台开启 User-ID 功能视图。

Q: 如何验证 GA4 配置是否正确?

A: 使用 Chrome 扩展 "Google Tag Assistant" 或 GA4 DebugView(需开启 debug_mode)。检查每次路由切换是否触发 page_view 事件,以及 user_id 是否正确设置。

修复 Tailwind Preflight 重置 Docusaurus 面包屑样式

· 阅读需 2 分钟

TL;DR

Docusaurus 引入 Tailwind 后,Preflight 的 CSS Reset 会重置 <ul> 元素的 list-stylemarginpadding,导致面包屑导航样式丢失。解决方法是在 custom.css 中添加显式覆盖样式。

问题现象

在 Docusaurus 项目中引入 Tailwind CSS 后,文档页的面包屑导航(Breadcrumbs)样式异常:

  • 列表样式丢失(list-style 被重置为 none
  • 间距消失(marginpadding 被重置为 0)
  • 布局可能错乱(display 可能被影响)

查看浏览器开发者工具,发现 .breadcrumbs 的计算样式中,这些属性被 Preflight 重置:

/* Tailwind Preflight 重置 */
ul, ol {
list-style: none;
margin: 0;
padding: 0;
}

根因

Tailwind Preflight 是一套基于 modern-normalize 的 CSS Reset,它在 @tailwind base 阶段注入,目的是提供一致的跨浏览器样式基准。

问题在于:Docusaurus 的 .breadcrumbs 组件使用 <ul> 元素,依赖浏览器默认的 flex 布局和间距。Preflight 的重置规则优先级较高,覆盖了 Docusaurus 的默认样式。

由于 Preflight 是全局注入的,任何使用 <ul>/<ol> 的第三方组件都可能受影响。

解决方案

src/css/custom.css 中添加显式覆盖样式,使用 !important 确保优先级:

/* ========== Breadcrumbs 面包屑 ========== */
.theme-doc-breadcrumbs {
margin-bottom: 1.5rem;
}

.breadcrumbs {
display: flex !important;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}

.breadcrumbs__item {
display: flex !important;
align-items: center;
gap: 0.5rem;
}

关键点

  1. .breadcrumbs 使用 display: flex !important 确保水平布局
  2. list-style: none 是预期行为(面包屑不需要圆点)
  3. .breadcrumbs__item 添加 gap: 0.5rem 控制元素间距

FAQ

Q: 为什么需要 !important?

Tailwind Preflight 在 @tailwind base 阶段注入,其选择器权重可能与 Docusaurus 默认样式相当。使用 !important 可以确保自定义样式生效,避免优先级战争。

Q: 除了面包屑,还有哪些组件可能受影响?

任何使用 <ul>/<ol> 的组件都可能受影响,例如:

  • 导航菜单
  • 分页组件
  • 自定义列表

检查方法:在浏览器开发者工具中搜索 list-style: none 的来源,确认是否来自 Preflight。

Q: 可以禁用 Preflight 吗?

可以,但不推荐。在 tailwind.config.js 中设置:

module.exports = {
corePlugins: {
preflight: false,
},
}

禁用后需要自行处理跨浏览器样式一致性,可能导致更多问题。

修复 httpx async with client.post() 的隐藏坑

· 阅读需 2 分钟

在构建多服务协作的 SaaS 系统时遇到此问题,记录根因与解法。

TL;DR

httpx.AsyncClient 不要用 async with client.post() 模式,应该先创建 client 再调用方法:response = await client.post()

问题现象

import httpx

async def call_api():
async with httpx.AsyncClient() as client:
async with client.post(url, json=data) as response: # 问题代码
return response.json()

这段代码有时正常,有时报错:

httpx.RemoteProtocolError: cannot write to closing transport
RuntimeError: Session is closed

根因

async with client.post() 的陷阱

client.post() 返回的是 Response 对象,不是上下文管理器。用 async with 包装会导致:

  1. 连接过早关闭async with 块结束时立即关闭连接,但响应可能还在读取
  2. 资源竞争:多个并发请求时,连接池状态混乱

正确理解 httpx 上下文管理器

# ✅ 正确:client 是上下文管理器
async with httpx.AsyncClient() as client:
response = await client.post(url, json=data)
return response.json()

# ❌ 错误:把 response 当上下文管理器
async with client.post(url) as response:
...

解决方案

方案 1:单次请求(推荐简单场景)

async def call_api(url: str, data: dict) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=data)
response.raise_for_status()
return response.json()

方案 2:复用 client(推荐高频请求)

# 全局或依赖注入
_client = httpx.AsyncClient(timeout=30.0)

async def call_api(url: str, data: dict) -> dict:
response = await _client.post(url, json=data)
response.raise_for_status()
return response.json()

# 应用关闭时
async def shutdown():
await _client.aclose()

方案 3:FastAPI 依赖注入

from fastapi import Depends
from httpx import AsyncClient

async def get_http_client() -> AsyncClient:
async with AsyncClient(timeout=30.0) as client:
yield client

@router.post("/proxy")
async def proxy(
data: dict,
client: AsyncClient = Depends(get_http_client)
):
response = await client.post("https://external.api/endpoint", json=data)
return response.json()

FAQ

Q: httpx async with 怎么用才对?

A: async with 只用于管理 AsyncClient 生命周期,不是包装单个请求。正确模式:async with AsyncClient() as client: response = await client.post(...)

Q: 为什么有时 async with client.post() 也能跑?

A: 单线程、低并发时可能碰巧正常,但高并发或网络延迟时会暴露问题。这是隐藏 bug,不要侥幸。

Q: httpx 超时怎么配置?

A: AsyncClient(timeout=30.0)AsyncClient(timeout=httpx.Timeout(connect=5.0, read=30.0))

解决 Pydantic v2 ORM mode 报错 model_config 被覆盖

· 阅读需 2 分钟

TL;DR

Pydantic v2 不再支持 class Config,需要用 model_config = ConfigDict(from_attributes=True)。如果你的模型有 model_config 字段,必须重命名避免与保留字冲突。

问题现象

报错 1:class Config 不生效

from pydantic import BaseModel

class AgentResponse(BaseModel):
id: str
name: str

class Config:
orm_mode = True # v1 写法
PydanticUserError: `orm_mode` is not a valid config option. Did you mean `from_attributes`?

报错 2:model_config 字段冲突

class Agent(BaseModel):
id: str
model_config: dict # 业务字段,存储 LLM 配置

model_config = ConfigDict(from_attributes=True)
# TypeError: 'dict' object is not callable

模型中有个业务字段叫 model_config(存储 LLM 配置),与 Pydantic v2 保留字冲突。

根因

1. Pydantic v2 配置语法变化

Pydantic v2 使用 model_config 作为配置属性名,不再支持嵌套的 class Config

Pydantic v1Pydantic v2
class Config: orm_mode = Truemodel_config = ConfigDict(from_attributes=True)
class Config: schema_extra = {...}model_config = ConfigDict(json_schema_extra={...})

2. model_config 是保留字

model_config 在 Pydantic v2 中是特殊属性,不能同时作为业务字段名使用。

解决方案

1. 更新 ORM mode 配置

from pydantic import BaseModel, ConfigDict

class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # 新写法

id: str
name: str

2. 重命名冲突字段

将业务字段 model_config 改为 llm_config(或任意非保留名):

# models/agent.py
class Agent(BaseModel):
__tablename__ = "agent_agents"

id: str
llm_config: dict # 改名,避免冲突

# schemas/agent.py
class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

agent_id: str
llm_config: LlmConfig # 与模型保持一致

3. 数据库迁移(如需要)

如果数据库字段也要改:

# alembic/versions/xxx_rename_model_config.py
def upgrade():
op.alter_column('agent_agents', 'model_config', new_column_name='llm_config')

def downgrade():
op.alter_column('agent_agents', 'llm_config', new_column_name='model_config')

FAQ

Q: Pydantic v2 的 orm_mode 改成什么了?

A: 改为 from_attributes=True,配置方式从 class Config 变成 model_config = ConfigDict(...)

Q: 为什么 model_config 字段报错?

A: model_config 是 Pydantic v2 的保留属性名,用于配置模型行为。如果业务代码中有同名字段,需要重命名。

Q: ConfigDict 还有哪些常用选项?

A: from_attributes (ORM mode)、json_schema_extra (schema 扩展)、str_strip_whitespace (自动去空格)、validate_assignment (赋值时验证)。

Vite 路径别名配置:改了两处才生效

· 阅读需 2 分钟

TL;DR

Vite 路径别名需要同时配置 vite.config.tstsconfig.json,缺一不可:Vite 负责打包时解析,TypeScript 负责类型检查和 IDE 提示。

问题现象

只配了 vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import path from 'path'

export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})

打包运行正常,但 IDE 报错:

Cannot find module '@/components/Button' or its corresponding type declarations.

只配了 tsconfig.json

// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

IDE 不报错了,但 Vite 构建时报错:

[vite] Internal server error: Failed to resolve import "@/services/api"

根因

两套配置,两个职责

配置文件负责方作用
vite.config.tsVite/esbuild构建时解析路径
tsconfig.jsonTypeScript类型检查、IDE 智能提示

只配置一处:

  • Vite 能打包,但 IDE 满屏红线,无法跳转
  • IDE 正常,但 vite dev / vite build 找不到模块

解决方案

完整配置(两处都要)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

验证配置生效

// src/services/api.ts
export const api = { ... }

// src/App.tsx - 应该能跳转、有提示、构建正常
import { api } from '@/services/api'

多个别名示例

// vite.config.ts
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
}
}

// tsconfig.json
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"]
}

FAQ

Q: 为什么 Vite 路径别名要配两次?

A: Vite(基于 esbuild/rollup)和 TypeScript 是独立工具。Vite 负责打包时的模块解析,TypeScript 负责编译时类型检查和 IDE 支持。两者不共享配置。

Q: 配置后还是报错怎么办?

A: 重启 IDE 和 Vite dev server。VSCode 按 Cmd+Shift+P → "TypeScript: Restart TS Server",终端 Ctrl+C 重启 npm run dev

Q: path.resolve 的 __dirname 报错?

A: 确保是 ES Module 时导入:import path from 'path',或在 package.json 加 "type": "module"。或用 import.meta.url 替代 __dirname

启用 VSCode Copilot Agent Mode 实现自动化编程

· 阅读需 3 分钟

TL;DR

VSCode Copilot Agent Mode 是实验性功能,能让 AI 自动执行多步骤任务(包括编辑文件、运行终端命令)。在 settings.json 中添加 "github.copilot.chat.agent.enabled": true 即可启用,适合处理重复性重构、批量文件修改等场景。

问题现象

传统 Copilot Chat 只能建议代码片段,每次都要:

  1. 手动复制代码
  2. 切换到目标文件
  3. 粘贴并调整
  4. 重复以上步骤

遇到需要修改多个文件的任务时,这种模式效率极低。

根因

Copilot 的 Ask Mode 设计为「建议者」角色:只输出代码,不执行操作。这是安全设计,但对于信任 AI 的开发者来说,增加了大量手动操作。

Agent Mode 则是「执行者」角色:AI 可以直接编辑文件、运行命令,实现真正的自动化编程。

解决方案

1. 启用 Agent Mode

在 VSCode settings.json 中添加:

{
"github.copilot.chat.agent.enabled": true
}

或在设置界面搜索 @id:github.copilot.chat.agent.enabled 勾选启用。

2. 切换到 Agent Mode

在 Copilot Chat 面板中,点击模式下拉框,从「Ask」切换到「Agent」:

┌─────────────────────────────┐
│ Ask ▼ │ Agent ▼ │ Edit │
└─────────────────────────────┘

3. 使用示例

场景:批量重命名函数

将 src/utils 目录下所有文件中的 getUserName 改为 fetchUserProfile

Agent Mode 会自动:

  1. 扫描 src/utils 目录
  2. 找到所有包含 getUserName 的文件
  3. 逐个修改并保存

场景:添加 TypeScript 类型

为 src/api/*.ts 中所有导出的函数添加返回类型注解

4. 工具权限控制

Agent Mode 执行敏感操作前会请求确认。可在设置中调整:

{
"github.copilot.chat.agent.autoToolConfirmation": {
"readFile": true, // 自动允许读文件
"editFile": false, // 编辑文件需确认
"runInTerminal": false // 运行命令需确认
}
}

5. 可用工具列表

Agent Mode 可调用以下工具:

工具功能
readFile读取文件内容
editFile编辑文件
createFile创建新文件
deleteFile删除文件
runInTerminal执行终端命令
listDirectory列出目录内容
search搜索代码

FAQ

Q: Agent Mode 和 Ask Mode 有什么区别?

Ask Mode 只建议代码,需要手动复制粘贴;Agent Mode 可以直接执行文件编辑和终端命令,实现自动化。

Q: Agent Mode 安全吗?

Agent Mode 在执行敏感操作(如删除文件、运行命令)前会请求确认。建议在版本控制的仓库中使用,便于回滚。

Q: 为什么找不到 Agent Mode 选项?

确保已安装最新版 Copilot Chat 扩展(v0.15+),并在设置中启用 github.copilot.chat.agent.enabled

Q: Agent Mode 能执行哪些终端命令?

理论上可以执行任何命令,但建议用于安全的开发命令(如 npm installnpm run build),避免执行删除、部署等高风险操作。

实现 React 级联选择下拉框

· 阅读需 4 分钟

TL;DR

级联选择的核心是:父级变化时,必须重置子级为有效值。使用 Record<string, Option[]> 类型映射数据,在 onValueChange 回调中同步更新子级状态。

问题现象

实现 Provider → Model 级联选择时,切换 Provider 后:

// 切换前:provider = "openai", model = "gpt-4o"
// 切换后:provider = "anthropic", model = "gpt-4o" ❌

// Model 下拉框显示为空,因为 "gpt-4o" 不在 anthropic 的模型列表中
<Select value={model}> // model 值不在 options 中,显示空白

或者提交表单时,Model 值是上一个 Provider 的模型,导致后端验证失败。

根因

React 受控组件的 value 必须存在于 options 中。当 Provider 变化时,Model 的 options 列表更新了,但 model state 仍保留旧值。如果旧值不在新的 options 中,Select 组件会显示为空。

关键问题:只更新了 options 数据,没有同步更新 state 值

解决方案

1. 定义数据结构

const AVAILABLE_PROVIDERS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
]

// 使用 Record 类型建立映射关系
const AVAILABLE_MODELS: Record<string, { value: string; label: string }[]> = {
deepseek: [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
],
openai: [
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
],
anthropic: [
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
],
}

2. State 初始化

const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat') // 初始值必须是 provider 对应的第一个模型

3. 关键:Provider 变化时重置 Model

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
// 核心:重置 model 到新 provider 的第一个选项
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

4. 完整组件示例

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

function CascadeSelect() {
const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat')

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

return (
<>
{/* Provider 选择 */}
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Model 选择 - 动态根据 provider 显示选项 */}
<Select value={model} onValueChange={(v) => v && setModel(v)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{(AVAILABLE_MODELS[provider] || []).map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)
}

5. 表单重置

关闭 Dialog 时重置表单,避免下次打开时保留旧状态:

const resetForm = () => {
setProvider('deepseek')
setModel('deepseek-chat') // 重置为 provider 对应的默认值
}

const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm()
}
onOpenChange(newOpen)
}

FAQ

Q: React 级联选择下拉框切换后子级显示为空怎么办?

A: 在父级 onValueChange 回调中,同步更新子级 state 为新选项列表的第一个值。受控组件的 value 必须存在于 options 中。

Q: 如何用 TypeScript 定义级联选择的数据类型?

A: 使用 Record<string, Option[]> 类型建立父级到子级的映射,例如 Record<string, { value: string; label: string }[]>,类型安全且易于扩展。

Q: Select 组件的 value 和 options 不匹配会怎样?

A: 大多数 UI 库(Radix、MUI、Ant Design)会显示空白或 placeholder,不会报错。这是受控组件的预期行为——确保 value 始终是有效的选项值。

集成 Supabase Auth 到 FastAPI 的三个坑

· 阅读需 4 分钟

在为客户构建 SaaS 认证系统时遇到此问题,记录根因与解法。

TL;DR

Supabase Auth + FastAPI 集成有三个常见坑:JWKS 路径不是标准路径、ES256 签名需转换为 DER 格式、用户首次登录时本地数据库无记录。本文提供完整解决方案。

问题现象

坑 1:JWKS 路径 404

GET https://xxx.supabase.co/.well-known/jwks.json
# 404 Not Found

所有 JWT 验证请求返回 401 Invalid Token。

坑 2:ES256 签名验证失败

from jose import jwt
payload = jwt.decode(token, key, algorithms=["ES256"])
# JWTError: Signature verification failed

明明公钥是对的,但签名验证总是失败。

坑 3:用户首次登录无本地记录

# 创建 Agent 时
agent = Agent(user_id=current_user["user_id"], ...)
db.add(agent)
# ForeignKeyViolation: user_id 不存在

Supabase Auth 用户通过了 JWT 验证,但本地 agent_users 表没有该用户记录。

根因

坑 1:Supabase 非标准 JWKS 路径

标准 OAuth/OIDC 服务器 JWKS 在 /.well-known/jwks.json,但 Supabase 把认证服务放在 /auth/v1/ 子路径下:

标准路径Supabase 路径
/.well-known/jwks.json/auth/v1/.well-known/jwks.json

坑 2:ES256 原始签名 vs DER 格式

Supabase JWT 使用 ES256(P-256 曲线)签名。JWT 中的签名是 raw 格式r || s 拼接,64 字节),但 Python cryptography 库的 verify() 方法需要 DER-encoded ASN.1 格式

Raw:     r (32 bytes) || s (32 bytes) = 64 bytes
DER: 0x30 <len> 0x02 <r_len> <r> 0x02 <s_len> <s>

python-josejwt.decode() 在处理 ES256 时有兼容性问题,需要手动验证签名。

坑 3:认证与数据分离

Supabase Auth 是独立服务,用户注册/登录后只存在于 Supabase 的 auth.users 表。本地数据库的 agent_users 表需要手动同步。

解决方案

1. 正确的 JWKS URL

# config.py
class Settings(BaseSettings):
supabase_url: str = "https://xxx.supabase.co"

@property
def jwks_url(self) -> str:
# 关键:/auth/v1/ 前缀
return f"{self.supabase_url}/auth/v1/.well-known/jwks.json"

2. ES256 签名验证(完整代码)

import json
import base64
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

def _base64url_decode(data: str) -> bytes:
"""Base64url 解码,自动补 padding"""
rem = len(data) % 4
if rem > 0:
data += "=" * (4 - rem)
return base64.urlsafe_b64decode(data)

def _raw_to_der_signature(raw_sig: bytes) -> bytes:
"""将 raw ECDSA 签名 (r||s) 转为 DER 格式"""
# P-256: r 和 s 各 32 字节
r = int.from_bytes(raw_sig[:32], "big")
s = int.from_bytes(raw_sig[32:], "big")
return encode_dss_signature(r, s)

def verify_es256_signature(token: str, public_key_jwk: dict) -> dict:
"""验证 ES256 JWT 签名,返回 payload"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Invalid JWT format")

header_b64, payload_b64, signature_b64 = parts

# 1. 构建 EC 公钥
x = _base64url_decode(public_key_jwk["x"])
y = _base64url_decode(public_key_jwk["y"])
x_int = int.from_bytes(x, "big")
y_int = int.from_bytes(y, "big")

public_key = ec.EllipticCurvePublicNumbers(
x_int, y_int, ec.SECP256R1()
).public_key(default_backend())

# 2. 验证签名
message = f"{header_b64}.{payload_b64}".encode()
raw_signature = _base64url_decode(signature_b64)
der_signature = _raw_to_der_signature(raw_signature)

public_key.verify(
der_signature,
message,
ec.ECDSA(hashes.SHA256())
)

# 3. 返回 payload
return json.loads(_base64url_decode(payload_b64))

3. 用户同步服务

# app/services/user_service.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import AgentUser

async def ensure_user_exists(
db: AsyncSession,
user_id: str,
email: str,
plan: str = "free"
) -> AgentUser:
"""确保用户存在于本地数据库(从 Supabase Auth 同步)"""
# 检查是否存在
result = await db.execute(
select(AgentUser).where(AgentUser.user_id == user_id)
)
user = result.scalar_one_or_none()

if user:
return user

# 创建新用户
user = AgentUser(
user_id=user_id,
email=email,
plan=plan,
role="user"
)
db.add(user)
await db.commit()
await db.refresh(user)
return user

4. 在创建资源前调用

# app/routers/agents.py
@router.post("/")
async def create_agent(
input: CreateAgentInput,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
# 关键:确保用户存在
user = await ensure_user_exists(
db,
user_id=current_user["user_id"],
email=current_user["email"],
plan=current_user["plan"]
)

# 现在可以安全创建 Agent
agent = Agent(
user_id=user.user_id,
name=input.name,
llm_config=input.llm_config.model_dump()
)
...

FAQ

Q: Supabase JWT 验证返回 404 怎么办?

A: Supabase 的 JWKS 路径是 /auth/v1/.well-known/jwks.json,不是标准的 /.well-known/jwks.json。检查你的 JWKS URL 配置。

Q: python-jose 验证 ES256 签名失败怎么解决?

A: python-jose 对 ES256 支持不完善。使用 cryptography 库手动验证,需要将 JWT 的 raw 签名(r||s 64字节)转换为 DER 格式。

Q: FastAPI 如何同步 Supabase Auth 用户到本地数据库?

A: 在需要用户记录的 API(如创建资源)入口处调用 ensure_user_exists(),从 JWT 提取用户信息并同步到本地表。

Q: Supabase JWT 中的 user_id 在哪个字段?

A: sub 字段包含用户 UUID,email 字段包含邮箱,app_metadata.plan 包含订阅计划(自定义字段)。

修复 Milvus 混合检索 RRF 分数与相似度阈值不兼容

· 阅读需 3 分钟

在 RAG 知识库项目中调试混合检索评分问题,以下是完整排查过程。

TL;DR

Milvus 混合检索的加权融合分数 = 0.7 * dense_score + 0.3 * sparse_score,理论最大值约 0.7。如果用 min_similarity=0.7 过滤,结果几乎全被剔除。解决方案:将阈值降到 0.3,或根据融合策略动态调整。

问题现象

混合检索返回空结果,即使数据库中明确存在相关文档:

# 调用混合检索
results = await milvus_service.hybrid_search(
collection_name="knowledge_base",
query_dense=dense_vector,
query_sparse=sparse_vector,
top_k=5,
min_similarity=0.7 # 问题根源
)

# 返回空数组 []
print(results) # {"documents": [[]], "metadatas": [[]], "distances": [[]]}

日志显示检索到了结果,但过滤后为空:

fused_results before filter: 10, scores: [0.52, 0.48, 0.45, ...]
min_similarity threshold: 0.7
fused_results after filter: 0, scores: []

根因

混合检索使用加权融合(Weighted Fusion)而非 Reciprocal Rank Fusion(RRF):

def _fuse_and_rank(self, dense_results, sparse_results, top_k):
semantic_weight = 0.7 # 语义权重
keyword_weight = 0.3 # 关键词权重

for result in dense_results:
similarity = 1 - distance
score = similarity * semantic_weight # 0.7 * score

for result in sparse_results:
similarity = 1 - distance
score = similarity * keyword_weight # 0.3 * score

# 同一文档的分数相加
final_score = dense_score + sparse_score

数学分析

  • 假设 dense 和 sparse 的相似度最大值都是 1.0
  • 融合分数最大值 = 0.7 * 1.0 + 0.3 * 1.0 = 1.0
  • 但实际中 sparse 分数通常较低(0.3-0.5),因为关键词很难完全匹配
  • 实际最大分数约 0.5-0.7

min_similarity=0.7 过滤,相当于要求"完美匹配",结果自然为空。

解决方案

方案一:降低阈值(推荐)

# config.py
class Settings(BaseSettings):
rag_min_similarity: float = 0.3 # 混合搜索分数阈值(加权分数通常较低)

方案二:动态阈值

根据检索类型使用不同阈值:

# 混合检索用较低阈值
if search_type == "hybrid":
min_similarity = 0.3
else:
min_similarity = 0.7 # 纯语义检索可用较高阈值

方案三:归一化融合分数

将融合分数归一化到 [0, 1]:

def _fuse_and_rank(self, dense_results, sparse_results, top_k):
# ... 融合逻辑 ...

# 归一化:除以权重和
max_possible_score = self.semantic_weight + self.keyword_weight # 1.0
for doc in doc_scores.values():
doc["score"] = doc["score"] / max_possible_score

return sorted_docs[:top_k]

FAQ

Q: 为什么混合检索分数比纯语义检索低?

A: 混合检索的分数是加权和,而非单纯的相似度。语义检索返回的是 0-1 的余弦相似度,而混合检索的分数是 0.7*dense + 0.3*sparse,即使两部分都是 1.0,最终也只有 1.0。但实际中 sparse 分数通常较低,导致总分偏低。

Q: RRF(Reciprocal Rank Fusion)和加权融合有什么区别?

A: RRF 基于排名位置计算:score = 1/(k+rank),与原始相似度无关。加权融合直接用相似度分数加权,更直观但需要调整阈值。Milvus 原生支持加权融合,RRF 需要自己实现。

Q: 阈值设为 0.3 会不会引入低质量结果?

A: 需要结合业务场景测试。0.3 是一个经验值,如果发现结果质量下降,可以:

  1. 提高到 0.4-0.5
  2. 在应用层做二次过滤
  3. 使用 LLM 对结果做相关性打分

修复 RAG 查询返回的 sources 缺少 similarity 字段

· 阅读需 3 分钟

在 RAG 知识库项目中调试查询结果返回格式问题,以下是完整排查过程。

TL;DR

RAG /query 接口返回的 sources 字段只包含 metadata,没有每条来源的 similarity 分数。解决方案:在组装响应时,将 metadatasdistances 合并,计算 similarity = 1 - distance

问题现象

调用 RAG 查询接口,返回的 sources 缺少相似度信息:

{
"answer": "根据文档...",
"sources": [
{"doc_id": "doc_001", "title": "API 文档", "source": "github"},
{"doc_id": "doc_002", "title": "开发指南", "source": "github"}
],
"similarity": 0.85
}

问题:

  • sources 数组中的每个对象没有 similarity 字段
  • 只有顶层的 similarity(最高相似度),无法知道每条来源的相关性
  • 前端无法按相似度排序或高亮显示

根因

原始代码直接返回 metadata,忽略了 distances 信息:

# 问题代码
result = {
"answer": answer,
"sources": search_results.get("metadatas", [[]])[0], # 只有 metadata
"collection": collection,
"similarity": max_similarity # 只有最高分
}

向量数据库(如 Milvus、Chroma)的检索结果通常包含三个数组:

  • documents: 文本内容
  • metadatas: 元数据
  • distances: 距离分数(越小越相似)

疏漏:只传递了 metadata,没有把 distance 转换为 similarity 并合并到 sources 中。

解决方案

合并 metadatasdistances,计算每条来源的相似度:

# 修复代码
metadatas = search_results.get("metadatas", [[]])[0]
distances = search_results.get("distances", [[]])[0]

sources = [
{**meta, "similarity": round(1 - dist, 3)}
for meta, dist in zip(metadatas, distances)
]

result = {
"answer": answer,
"sources": sources, # 现在包含 similarity
"collection": collection,
"similarity": max_similarity
}

修复后返回:

{
"answer": "根据文档...",
"sources": [
{"doc_id": "doc_001", "title": "API 文档", "similarity": 0.85},
{"doc_id": "doc_002", "title": "开发指南", "similarity": 0.72}
],
"similarity": 0.85
}

完整代码示例

async def query_handler(request):
# 1. 执行向量检索
search_results = await milvus_service.query(
collection_name=collection,
query_embeddings=[query_embedding],
n_results=5
)

# 2. 生成答案
answer = await llm.generate(context, question)

# 3. 组装 sources(合并 metadata 和 similarity)
metadatas = search_results.get("metadatas", [[]])[0]
distances = search_results.get("distances", [[]])[0]

sources = [
{**meta, "similarity": round(1 - dist, 3)}
for meta, dist in zip(metadatas, distances)
]

# 4. 计算最高相似度
max_similarity = max(s["similarity"] for s in sources) if sources else 0

return {
"answer": answer,
"sources": sources,
"similarity": max_similarity
}

FAQ

Q: 为什么 similarity = 1 - distance?

A: 向量数据库通常返回距离(distance)而非相似度(similarity)。对于余弦距离,cosine_distance = 1 - cosine_similarity,所以 similarity = 1 - distance。对于欧氏距离,需要用 similarity = 1 / (1 + distance) 等公式转换。

Q: 顶层 similarity 和 sources 中的 similarity 有什么区别?

A: 顶层 similarity 是最高相似度(最相关的那条来源),用于判断整体回答质量。sources 中每条记录的 similarity 表示该来源的相关性,用于排序、高亮或过滤。

Q: 如果 distance 不是余弦距离怎么办?

A: 需要根据距离类型调整公式:

  • 余弦距离:similarity = 1 - distance
  • 欧氏距离:similarity = 1 / (1 + distance)
  • 内积:similarity = distance(已经是相似度)

检查你的向量数据库配置,确认使用的是哪种距离度量。