IP 显示问题解析
这份文档不依赖当前环境的文件跳转。
所有涉及的源码位置,都直接写成:
- 文件路径
- 对应代码片段
- 代码在调用链里的作用
这样把 md 单独拿出去看,也能完整理解问题和修复方式。
问题背景
本次问题出现在“数据导入日志”的 browser_ip 字段上。
现象是:
- 在
start.sh启动的开发态里执行导入 - 日志里看到的 IP 基本都是
127.0.0.1 - 这和预期的“记录真实用户来源 IP”不一致
这个问题本质上不是日志展示错误,也不是日志存储层计算错误,而是开发态代理链没有把真实来源 IP 透传给后端。
原来的数据链
原来的数据流转路径如下:
浏览器 -> 前端 axios 请求 /api -> Vite dev server 代理 -> Flask /api/data-import/jobs/<job_id>/execute -> service.execute_import(...) -> record_execute_import_log(...) -> record_import_log(...) -> imported_log.jsonl下面按链路展开。
1. 前端请求统一打到 /api
文件路径:
frontend/src/utils/axios.js对应代码:
import axios from 'axios'
const config = { baseURL: '/api', timeout: 15000, headers: { 'Content-Type': 'application/json;charset=utf-8' }}
const api = axios.create(config)
api.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'api.defaults.headers.post['Content-Type'] = 'application/json;charset=utf-8'
api.post = function (url, params = {}) { return new Promise((resolve, reject) => { axios({ method: 'post', url: `${config.baseURL}${url}`, data: params, headers: { 'Content-Type': 'application/json;charset=utf-8' } }).then( (response) => { if (response.status === 200) { resolve(response.data) } else { reject(response) } }, (error) => { reject(error) } ) })}这意味着浏览器不是直接请求 Flask,而是先请求前端服务,再由前端代理转发。
2. start.sh 同时启动后端 Flask 和前端 Vite
文件路径:
start.sh对应代码:
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"BACKEND_PORT="${BACKEND_PORT:-5000}"FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"FRONTEND_PORT="${FRONTEND_PORT:-20201}"
echo -e "\n${GREEN}[1/2] Starting Backend Service...${NC}"ensure_port_free "$BACKEND_PORT" "Backend"
nohup bash -lc " set -a source '$ENV_FILE' set +a source '$VENV_ACTIVATE' cd '$PROJECT_ROOT/backend' exec python app.py" > "$BACKEND_LOG" 2>&1 &
echo -e "\n${GREEN}[2/2] Starting Frontend Service...${NC}"ensure_port_free "$FRONTEND_PORT" "Frontend"
nohup bash -lc " set -a source '$ENV_FILE' set +a cd '$PROJECT_ROOT/frontend' exec npm run dev -- --host ${FRONTEND_HOST} --port ${FRONTEND_PORT} --strictPort" > "$FRONTEND_LOG" 2>&1 &在这个运行模式下,实际链路是:
浏览器 -> Vite -> Flask也就是说,Flask 在开发态看到的“直接上游”,不是浏览器,而是 Vite。
3. 导入执行时,路由层计算 browser_ip
文件路径:
backend/api/data_import/routes.py对应代码:
@data_import_bp.route("/jobs/<job_id>/execute", methods=["POST"])def execute_import(job_id): """执行已经完成预览的导入任务。""" try: payload = data_import_service.execute_import( job_id, browser_ip=resolve_request_ip(), ) return success(payload)这里说明:
- 真正写入日志前,IP 是在路由层算出来的
- 传入 service 的时候已经是一个字符串了
4. 原来的 IP 解析逻辑
同一个文件:
backend/api/data_import/routes.py对应代码:
def resolve_request_ip() -> str: """解析发起导入请求的浏览器 IP。
优先读取反向代理透传的真实 IP,最后回退到 Flask 的 remote_addr。 """ x_forwarded_for = request.headers.get("X-Forwarded-For", "").strip() if x_forwarded_for: return x_forwarded_for.split(",")[0].strip()
x_real_ip = request.headers.get("X-Real-IP", "").strip() if x_real_ip: return x_real_ip
remote_addr = request.remote_addr or "" return remote_addr.strip()它的优先级是:
1. X-Forwarded-For2. X-Real-IP3. request.remote_addr这套逻辑本身没有问题,属于标准的“先信代理透传头,再回退到直连地址”。
5. Service 层只是把 browser_ip 原样传给日志层
文件路径:
backend/service/data_import/service.py对应代码:
def record_execute_import_log(job: dict, module_label: str, payload: dict, browser_ip: str) -> None: """把一次成功执行的导入结果写入日志。
日志失败不应影响主流程,所以这里吞掉异常并仅记录错误日志。 """ try: created_at = datetime.now().astimezone().isoformat(timespec="milliseconds") for log_entry in build_execute_import_log_entries(job, module_label, payload): record_import_log( module_label=log_entry["module_label"], browser_ip=browser_ip, imported_name=log_entry["imported_name"], excel_name=normalize_text(job.get("file_name")), created_at=created_at, ) except Exception as exc: logger.error("写入导入日志失败: %s", exc)这里没有任何 IP 重算逻辑,只是透传。
6. 日志存储层也不参与 IP 判断
文件路径:
backend/service/data_import/logs.py对应代码:
def record_import_log( module_label: str, browser_ip: str, imported_name: str, excel_name: str, created_at: str = "",) -> None: """追加一条导入成功日志。""" ensure_log_store() payload = { "module": normalize_text(module_label), "browser_ip": normalize_text(browser_ip) or "未知IP", "created_at": ( normalize_text(created_at) or datetime.now().astimezone().isoformat(timespec="milliseconds") ), "imported_name": normalize_text(imported_name) or "-", "excel_name": normalize_text(excel_name) or "-", } with LOG_FILE.open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload, ensure_ascii=False)) handle.write("\n")这里的职责非常单一:
- 接到什么
browser_ip - 就把什么
browser_ip落到 JSONL
所以问题不在日志页,也不在日志存储层。
原来的完整数据链结论
到这里可以得到一个明确结论:
- 路由层负责取 IP
- service 层负责传递 IP
- logs 层负责写入 IP
因此,如果日志里出现 127.0.0.1,根因一定发生在:
请求到达 Flask 之前也就是代理链的问题。
为什么原来几乎只有 127.0.0.1
根本原因是:
开发态下,Flask 的直接客户端不是浏览器,而是 Vite 代理也就是:
浏览器 -> Vite -> Flask在这条链路里:
- Flask 的直接连接来源是 Vite
- Vite 和 Flask 运行在同一台机器
- 所以
request.remote_addr常常就是127.0.0.1
再结合前面的 resolve_request_ip():
x_forwarded_for = request.headers.get("X-Forwarded-For", "").strip()if x_forwarded_for: return x_forwarded_for.split(",")[0].strip()
x_real_ip = request.headers.get("X-Real-IP", "").strip()if x_real_ip: return x_real_ip
remote_addr = request.remote_addr or ""return remote_addr.strip()如果前两个头都没有,最后就只能落到:
request.remote_addr于是结果就变成:
browser_ip = 127.0.0.1修复前的代理等价行为
修复前,Vite 代理的关键问题不是“没有代理”,而是“没有开启转发头透传”。
可以把修复前的关键行为理解成下面这段等价配置:
proxy: { '/api': { target: env.VITE_API_BASE || 'http://127.0.0.1:5000', changeOrigin: true, }}在这种情况下,后端通常看到的是:
X-Forwarded-For = 空X-Real-IP = 空remote_addr = 127.0.0.1所以后端最终只能得到:
browser_ip = 127.0.0.1这不是用户真实 IP,而是:
Vite -> Flask 这一跳的来源地址为什么部署态没那么容易暴露这个问题
因为部署态的 nginx 本来就会设置这些转发头。
文件路径:
frontend/nginx.conf对应代码:
server { listen 80; server_name _;
root /usr/share/nginx/html; index index.html;
location / { try_files $uri $uri/ /index.html; }
location /api/ { proxy_pass http://backend:20200; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}部署态里:
- nginx 会把真实来源写进
X-Real-IP - nginx 也会把代理链写进
X-Forwarded-For - 后端现有的
resolve_request_ip()可以正常取到真实来源
所以这次暴露出来的问题,本质上是:
开发态代理行为和部署态代理行为不一致这次是如何修复的
修复点放在开发态代理,也就是:
frontend/vite.config.js修复后的实际代码如下:
import { defineConfig, loadEnv } from 'vite'import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '')
return { plugins: [vue()], server: { host: env.FRONTEND_HOST || '0.0.0.0', port: Number(env.FRONTEND_PORT || 20201), proxy: { '/api': { target: env.VITE_API_BASE || 'http://127.0.0.1:5000', changeOrigin: true, xfwd: true, } } }, }})这次新增的关键配置是:
xfwd: true它的作用是让 Vite 在代理请求时自动带上这些转发头:
X-Forwarded-ForX-Forwarded-HostX-Forwarded-Proto
于是开发态链路就变成:
浏览器 -> Vite(xfwd: true) -> Flask此时 Flask 可以收到:
X-Forwarded-For = 用户来源 IP而后端原本就已经有读取它的逻辑:
x_forwarded_for = request.headers.get("X-Forwarded-For", "").strip()if x_forwarded_for: return x_forwarded_for.split(",")[0].strip()所以整个修复的关键不是改日志展示,而是让开发态代理补齐真实来源头。
修复前后的行为对比
修复前
浏览器 -> Vite -> Flask
Flask 收到: X-Forwarded-For = 空 X-Real-IP = 空 remote_addr = 127.0.0.1
结果: browser_ip = 127.0.0.1修复后
浏览器 -> Vite(xfwd: true) -> Flask
Flask 收到: X-Forwarded-For = 用户真实来源 IP remote_addr = 127.0.0.1
结果: browser_ip = X-Forwarded-For 的第一个 IP对应后端代码:
def resolve_request_ip() -> str: x_forwarded_for = request.headers.get("X-Forwarded-For", "").strip() if x_forwarded_for: return x_forwarded_for.split(",")[0].strip()
x_real_ip = request.headers.get("X-Real-IP", "").strip() if x_real_ip: return x_real_ip
remote_addr = request.remote_addr or "" return remote_addr.strip()修复后仍然可能出现 127.0.0.1 的情况
下面几种情况仍然是正常的:
-
用户就在服务器本机浏览器上操作
这时真实来源本来就是本机,记录成127.0.0.1合理。 -
用户经过 NAT、VPN 或统一出口网关
记录到的是出口 IP,不是他电脑自己的内网地址。 -
前面还有多层代理
X-Forwarded-For可能是一串 IP,当前逻辑取第一个。
对应代码:
return x_forwarded_for.split(",")[0].strip()总结
这次 IP 显示问题可以归纳成四句话:
- 日志层没有算错,它只是把路由层拿到的 IP 写进文件。
- 原来的问题发生在开发态代理链,Vite 没有把真实来源 IP 透传给 Flask。
- 所以后端只能回退到
request.remote_addr,而在开发态里这通常就是127.0.0.1。 - 现在通过在 Vite 代理上开启
xfwd: true,开发态也能把真实来源 IP 带给后端,原有的 IP 解析逻辑就可以正常工作。