Last updated on

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

对应代码:

Terminal window
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-For
2. X-Real-IP
3. 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-For
  • X-Forwarded-Host
  • X-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 的情况

下面几种情况仍然是正常的:

  1. 用户就在服务器本机浏览器上操作
    这时真实来源本来就是本机,记录成 127.0.0.1 合理。

  2. 用户经过 NAT、VPN 或统一出口网关
    记录到的是出口 IP,不是他电脑自己的内网地址。

  3. 前面还有多层代理
    X-Forwarded-For 可能是一串 IP,当前逻辑取第一个。

对应代码:

return x_forwarded_for.split(",")[0].strip()

总结

这次 IP 显示问题可以归纳成四句话:

  1. 日志层没有算错,它只是把路由层拿到的 IP 写进文件。
  2. 原来的问题发生在开发态代理链,Vite 没有把真实来源 IP 透传给 Flask。
  3. 所以后端只能回退到 request.remote_addr,而在开发态里这通常就是 127.0.0.1
  4. 现在通过在 Vite 代理上开启 xfwd: true,开发态也能把真实来源 IP 带给后端,原有的 IP 解析逻辑就可以正常工作。