第 4 篇:别把 API Key 暴露到浏览器:用 Express 做 Dify 代理层

4 阅读5分钟

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们用 Vite + React 做了一个最小 AI 聊天页面,实现了:

用户输入问题
  ↓
前端直接调用 Dify API
  ↓
展示 AI 回答

这个版本能跑,但有一个非常严重的问题:

Dify API Key 暴露在浏览器里。

只要打开浏览器 DevTools,就可能在 Network 请求里看到:

Authorization: Bearer app-xxxxxxxx

这在正式项目里是绝对不能接受的。

这一篇我们就来解决这个问题:

用 Express 做一个 BFF 代理层,把 Dify API Key 放到服务端。

最终调用链路会变成:

React 前端
  ↓
Express BFF
  ↓
Dify API

为什么不能把 API Key 放在前端?

很多刚开始做 AI 应用的人会直接在前端写:

fetch('https://api.dify.ai/v1/chat-messages', {
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
})

如果只是本地 Demo,这样可以快速验证。

但只要项目上线,这种写法就有风险。

原因是:

1. 前端代码会被打包到浏览器
2. 浏览器环境没有真正的秘密变量
3. 用户可以通过 DevTools 查看请求头
4. Key 泄露后,别人可以直接消耗你的额度
5. 你无法对请求做鉴权、限流、日志和风控

所以更合理的结构是:

浏览器只请求自己的后端接口
后端接口再携带 API Key 调用 Dify

这就是我们这一篇要做的 BFF 层。


什么是 BFF?

BFF 是 Backend For Frontend,直译就是“为前端服务的后端”。

在这个项目里,BFF 的职责很简单:

1. 接收前端问题
2. 从服务端环境变量读取 Dify API Key
3. 调用 Dify Chat API
4. 把 Dify 的回答返回给前端

也就是说,前端不再关心 Dify 的真实 Key 和真实接口细节。

前端只需要请求:

/api/chat

而不是直接请求:

https://api.dify.ai/v1/chat-messages

本篇目标

完成后项目会变成:

Vite React 前端
  ↓
/api/chat
  ↓
Express 服务端
  ↓
Dify Chat Messages API

并实现:

1. Dify API Key 不再暴露到浏览器
2. 前端调用自己的 /api/chat 接口
3. Express 负责请求 Dify
4. 支持 conversationId 多轮对话
5. 本地可以一键启动前后端

第一步:安装服务端依赖

在项目根目录执行:

npm install express cors dotenv
npm install -D tsx concurrently @types/express @types/cors

几个依赖的作用:

express:启动本地 API 服务
cors:处理跨域
 dotenv:读取 .env 环境变量
tsx:直接运行 TypeScript 服务端代码
concurrently:同时启动前端和后端

第二步:调整环境变量

上一篇我们在 .env.local 里写了:

VITE_DIFY_API_KEY=xxx

这一篇要改掉。

在项目根目录创建或修改:

.env

写入:

PORT=3001
DIFY_API_KEY=你的 Dify 应用 API Key
DIFY_API_URL=https://api.dify.ai/v1/chat-messages
DIFY_USER=vite-demo-user

注意,这里不要加 VITE_ 前缀。

因为我们不希望它暴露给浏览器。

同时确认 .gitignore 里有:

.env
.env.local

真实 Key 一定不要提交到 GitHub。


第三步:创建 Express 服务

新建:

server/index.ts

写入:

import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'

dotenv.config()

const app = express()

app.use(cors())
app.use(express.json())

const PORT = Number(process.env.PORT || 3001)
const DIFY_API_KEY = process.env.DIFY_API_KEY
const DIFY_API_URL =
  process.env.DIFY_API_URL || 'https://api.dify.ai/v1/chat-messages'
const DIFY_USER = process.env.DIFY_USER || 'vite-demo-user'

if (!DIFY_API_KEY) {
  throw new Error('Missing DIFY_API_KEY in .env')
}

app.get('/health', (_req, res) => {
  res.json({ ok: true })
})

app.post('/api/chat', async (req, res) => {
  try {
    const { message, conversationId } = req.body

    if (!message || typeof message !== 'string') {
      return res.status(400).json({ error: 'message is required' })
    }

    const difyResponse = await fetch(DIFY_API_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${DIFY_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        inputs: {},
        query: message,
        response_mode: 'blocking',
        conversation_id: conversationId || '',
        user: DIFY_USER,
      }),
    })

    if (!difyResponse.ok) {
      const errorText = await difyResponse.text()

      return res.status(difyResponse.status).json({
        error: 'Dify API request failed',
        detail: errorText,
      })
    }

    const data = await difyResponse.json()

    return res.json({
      answer: data.answer,
      conversationId: data.conversation_id,
      raw: data,
    })
  } catch (error) {
    console.error('[POST /api/chat]', error)

    return res.status(500).json({
      error: 'Internal server error',
      detail: error instanceof Error ? error.message : 'Unknown error',
    })
  }
})

app.listen(PORT, () => {
  console.log(`API server running at http://localhost:${PORT}`)
})

现在服务端提供了两个接口:

GET  /health      健康检查
POST /api/chat    聊天接口

第四步:修改 package.json scripts

打开 package.json,把 scripts 调整成:

{
  "scripts": {
    "dev": "vite",
    "server": "tsx watch server/index.ts",
    "dev:all": "concurrently "npm run server" "npm run dev"",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  }
}

之后本地开发就可以执行:

npm run dev:all

它会同时启动:

Vite 前端:http://localhost:5173
Express 后端:http://localhost:3001

第五步:配置 Vite 代理

现在前端运行在 5173,后端运行在 3001。

前端请求 /api/chat 时,需要代理到后端。

打开:

vite.config.ts

改成:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3001',
        changeOrigin: true,
      },
    },
  },
})

这样前端请求:

/api/chat

实际会被 Vite 转发到:

http://localhost:3001/api/chat

浏览器里看到的仍然是 /api/chat,不会看到 Dify 官方地址。


第六步:修改前端 API 调用

上一篇的 src/api/dify.ts 是直接调用 Dify。

现在改成调用自己的后端:

export interface ChatResponse {
  answer: string
  conversationId: string
}

export async function sendMessageToDify(
  message: string,
  conversationId?: string
): Promise<ChatResponse> {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      message,
      conversationId,
    }),
  })

  if (!response.ok) {
    const errorText = await response.text()
    throw new Error(errorText || '请求失败')
  }

  return response.json()
}

注意:

现在前端代码里已经没有:

DIFY_API_KEY
Authorization
https://api.dify.ai

这说明 API Key 已经从浏览器环境移到了服务端。


第七步:启动并测试

启动项目:

npm run dev:all

先测试后端健康检查:

http://localhost:3001/health

应该返回:

{"ok":true}

然后打开前端:

http://localhost:5173

输入:

前端架构主要包括哪些内容?

如果能正常回答,说明链路跑通:

React 页面
  ↓
/api/chat
  ↓
Express
  ↓
Dify
  ↓
知识库 + LLM

第八步:在浏览器里确认 Key 没有暴露

打开浏览器 DevTools,进入 Network。

发送一次问题。

你应该看到请求地址是:

/api/chat

而不是:

https://api.dify.ai/v1/chat-messages

请求头里也不应该出现:

Authorization: Bearer app-xxx

因为 Authorization 现在是在 Express 服务端发给 Dify 的,浏览器看不到。

这是这一篇最重要的完成标准。


当前版本架构

现在项目架构变成了:

Browser
  ↓
Vite React App
  ↓
/api/chat
  ↓
Express BFF
  ↓
Dify Chat API
  ↓
Knowledge Retrieval + LLM

相比上一篇,最大的变化是增加了:

Express BFF

它让我们获得了几个能力:

隐藏 Dify API Key
统一错误处理
后续支持日志
后续支持限流
后续支持鉴权
后续支持数据库
后续支持流式代理

所以 BFF 不只是“转发接口”,它是项目后续工程化的基础。


为什么还要保留 conversationId?

Dify 的多轮对话依赖 conversation_id

第一次请求时,我们传:

conversation_id: ''

Dify 会返回一个新的:

conversation_id

前端保存后,下一次请求继续带给后端:

body: JSON.stringify({
  message,
  conversationId,
})

后端再转成 Dify 需要的字段:

conversation_id: conversationId || ''

这样多轮对话能力就保留下来了。


这一版还有哪些不足?

现在 API Key 安全问题解决了,但还有几个明显不足:

1. 回答仍然是 blocking 模式

当前服务端传的是:

response_mode: 'blocking'

用户必须等 Dify 完整生成回答后才能看到结果。

下一篇会改成:

response_mode: 'streaming'

实现像 ChatGPT 一样逐字输出。

2. 服务端代码还比较粗糙

现在所有逻辑都在 server/index.ts 里。

后面会拆成:

server/config/env.ts
server/routes/chat.ts
server/services/dify.ts
server/utils/errors.ts

3. 没有请求取消

如果用户想停止生成,目前还做不到。

后面会用 AbortController 来实现。

4. 没有 Markdown 和引用来源

这些还是后续文章的内容。


常见问题

1. 报 Missing DIFY_API_KEY

说明 .env 里没有配置:

DIFY_API_KEY=xxx

或者你修改 .env 后没有重启服务端。

2. 前端请求 404

检查 Vite 代理是否配置:

server: {
  proxy: {
    '/api': {
      target: 'http://localhost:3001',
      changeOrigin: true,
    },
  },
}

3. Dify 返回鉴权失败

检查 DIFY_API_KEY 是否是应用 API Key,不是 DeepSeek API Key。

这里很容易搞混:

Dify 应用 API Key:给我们的程序调用 Dify 应用
DeepSeek API Key:配置在 Dify 后台,给 Dify 调模型

4. CORS 报错

本地开发时前端走 Vite proxy,一般不会有 CORS 问题。

如果你直接从 localhost:5173 请求 localhost:3001,才需要 CORS。

当前服务端已经加了:

app.use(cors())

本篇总结

这一篇我们完成了一个非常关键的架构升级:

前端直连 Dify
  ↓
前端请求 Express BFF
  ↓
Express 调用 Dify

这一步解决了 Dify API Key 暴露问题,也为后续扩展打好了基础。

现在项目已经具备了一个 AI 应用最基本的安全结构:

浏览器不保存敏感 Key
服务端统一代理外部 AI API
前端只调用自己的业务接口

下一篇我们继续升级体验:

把 blocking 模式改成 streaming,让 AI 回答像 ChatGPT 一样逐字输出。