最近联调的时候发现一个问题:演示模式开关 DEMO_ENABLE 有时不生效。.env.dev 里改成 True 没用,数据库后台把参数改成 "true" 也没用,非 GET 请求照样能正常访问,演示模式等于形同虚设。
顺着配置读取的链路排查了一轮,一共找出四个问题,这里记录一下排查过程和修复方案。
演示模式是怎么实现的
后台系统一般都有演示模式:允许浏览(GET),拦截增删改(POST/PUT/DELETE),防止外部用户或投资人把核心数据改坏。我们的实现放在中间件 RequestLogMiddleware 里:
css
flowchart TD
A[客户端请求] --> B{是否在 IP 黑名单?}
B -- 是 --> C[直接拦截 403 / 拒绝]
B -- 否 --> D{是否开启演示模式 DEMO_ENABLE?}
D -- 否 --> E[放行, 执行业务逻辑]
D -- 是 --> F{是否为 GET 请求 或 白名单 IP / 路径?}
F -- 是 --> E
F -- 否 --> G[拦截请求: 提示 演示环境禁止操作]
核心判断逻辑:
vbscript
in_demo = (
config.get(SysParamKey.DEMO_ENABLE, False)
and request.method != "GET"
and client_ip not in ip_white_list
and request.url.path not in white_api_list
)
按 环境变量 → 系统参数表 sys_param → Redis 缓存 → ASGI 中间件 这条链路逐段排查,问题出在中间件读取配置这块。
坑 1:字典 key 不统一,枚举实例和字符串混用
为了方便管理参数名,代码里定义了一个字符串枚举:
ini
class SysParamKey(str, Enum):
DEMO_ENABLE = "demo_enable"
IP_WHITE_LIST = "ip_white_list"
IP_BLACK_LIST = "ip_black_list"
问题出在配置字典的读写上。初始化默认值用的是枚举实例 SysParamKey.DEMO_ENABLE,从 Redis 写入的时候用的却是字符串 key.value(即 "demo_enable"),中间件读取时又用回枚举实例:
vbnet
# 初始化默认字典:使用的 Key 是【枚举实例 SysParamKey.DEMO_ENABLE】
result: dict[str, Any] = {
SysParamKey.DEMO_ENABLE: False,
SysParamKey.IP_WHITE_LIST: (),
SysParamKey.IP_BLACK_LIST: ()
}
# 从 Redis 读取数据后写入:使用的 Key 变成了【字符串 key.value】
for raw, key in (...):
# key 是 SysParamKey.DEMO_ENABLE,key.value 是 "demo_enable"
result[key.value] = cv in (True, "true", "1", "yes", "on")
# 中间件 dispatch 中读取配置:使用的 Key 又是【枚举实例 SysParamKey.DEMO_ENABLE】
in_demo = config.get(SysParamKey.DEMO_ENABLE, False)
虽然 SysParamKey 继承了 str,枚举实例和字符串在不少操作里表现一致,但在字典里它们是不同的 key。只要写入用 key.value、读取用 key,config.get(SysParamKey.DEMO_ENABLE) 就永远只能拿到初始化的 False,Redis 里的动态配置等于没读到。
坑 2:布尔判断区分大小写,"True" 直接判成关闭
sys_param 表里的 config_value 字段是通用的 VARCHAR/TEXT,值都是字符串。用户在前端或者直接改数据库的时候,填的内容五花八门:
- 布尔值:
true/false - 首字母大写:
"True"/"False" - 数字:
"1"/"0" - 带回车空格:
"true\n"
原来的判断写的是:
sql
result[key.value] = cv in (True, "true", "1", "yes", "on")
in 是严格匹配,区分大小写,也不处理空白。"True" 不在集合里,直接判成 False;"true " 也一样。一个首字母大写的 "True",整个演示模式就失效了。
坑 3:Redis 不可用时,环境变量被兜底值覆盖
系统有两套配置来源:
- 静态配置:
.env.dev/settings.DEMO_ENABLE: bool = False(部署时生效) - 动态配置:数据库
sys_param同步到 Redis(运行期可以动态开关)
旧代码加载配置的时候,Redis 没就绪就直接返回硬编码的 False:
python
redis = getattr(request.app.state, "redis", None)
if not redis:
# Redis 未就绪或异常时,直接写死了 False!
return {SysParamKey.DEMO_ENABLE: False, ...}
结果就是:即使 .env.prod 里明确写了 DEMO_ENABLE = True,只要 Redis 没启用,环境变量也会被这个兜底值盖掉。
正确的取值顺序应该是:动态配置(Redis/DB) > 静态环境变量(.env / settings) > 框架默认兜底。
坑 4:IP 名单按 JSON 解析,非标准输入直接抛异常
黑白名单的配置读取也有一段问题代码:
scss
result[key.value] = json.loads(cv) if isinstance(cv, str) else cv
管理员在后台填 IP 的时候,习惯用换行、逗号或者分号分隔,比如 127.0.0.1, 192.168.1.100。这段文本不是合法的 JSON,json.loads() 直接抛 JSONDecodeError,异常被外层捕获后整个名单配置被丢弃,黑白名单功能就瘫痪了。
修复:重构 RequestLogMiddleware
针对上面四个问题,把 RequestLogMiddleware 整体重写了一遍:
python
class RequestLogMiddleware(BaseHTTPMiddleware):
"""演示模式 & IP黑名单拦截中间件"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
client_ip = get_client_ip(request)
try:
path = request.url.path
config = await self._load_config(request)
# 统一使用 .value (字符串键名) 读取
black_list = config.get(SysParamKey.IP_BLACK_LIST.value, ())
white_list = config.get(SysParamKey.IP_WHITE_LIST.value, ())
is_blacklisted = bool(client_ip and client_ip in black_list)
in_demo = (
config.get(SysParamKey.DEMO_ENABLE.value, False)
and request.method != "GET"
and (client_ip is None or client_ip not in white_list)
and not any(
path.startswith(item.rstrip("*")) if item.endswith("*") else path == item
for item in settings.WHITE_API_LIST_PATH
if isinstance(item, str) and item
)
)
if is_blacklisted or in_demo:
logger.warning(
"请求被拦截: {} {} | ip={} | 原因={}",
request.method,
path,
client_ip,
"IP黑名单" if is_blacklisted else "演示模式",
)
return ErrorResponse(msg="IP已被黑名单" if is_blacklisted else "演示环境,禁止操作")
return await call_next(request)
except CustomException as e:
logger.exception(f"中间件异常: {e!s}")
return ErrorResponse(msg="系统异常,请联系管理员", data=str(e))
@staticmethod
async def _load_config(request: Request) -> dict:
"""加载中间件配置:
1. 默认值优先采用 settings (.env) 作为兜底
2. 动态值优先从 Redis 读取
3. 严格做字符串清洗与多格式容错
"""
# 第一层防护:以环境变量 settings 规范初始化
default_config: dict[str, Any] = {
SysParamKey.DEMO_ENABLE.value: bool(getattr(settings, "DEMO_ENABLE", False)),
SysParamKey.IP_WHITE_LIST.value: (),
SysParamKey.IP_BLACK_LIST.value: (),
}
redis = getattr(request.app.state, "redis", None)
if not redis:
return default_config
try:
config_keys = [
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.DEMO_ENABLE.value}",
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.IP_WHITE_LIST.value}",
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{SysParamKey.IP_BLACK_LIST.value}",
]
config_values = await RedisCURD(redis).mget(config_keys)
result = default_config.copy()
raw_demo, raw_white, raw_black = config_values
for raw, key in (
(raw_demo, SysParamKey.DEMO_ENABLE),
(raw_white, SysParamKey.IP_WHITE_LIST),
(raw_black, SysParamKey.IP_BLACK_LIST),
):
if not raw:
continue
try:
payload = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(payload, dict) or payload.get("status", 0) != 0:
continue
cv = payload.get("config_value")
if cv is None:
continue
if key == SysParamKey.DEMO_ENABLE:
# 第二层防护:统一 strip() 和 lower(),兼容各种奇葩布尔字符串
result[key.value] = str(cv).strip().lower() in ("true", "1", "yes", "on")
else:
# 第三层防护:同时兼容 JSON 数组、逗号、分号、换行符分隔的 IP 列表
if isinstance(cv, (list, tuple, set)):
result[key.value] = tuple(str(x).strip() for x in cv if str(x).strip())
elif isinstance(cv, str):
try:
parsed = json.loads(cv)
if isinstance(parsed, list):
result[key.value] = tuple(str(x).strip() for x in parsed if str(x).strip())
continue
except json.JSONDecodeError:
pass
# 兼容普通分隔符
ips = [x.strip() for x in cv.replace(";", ",").replace("\n", ",").split(",") if x.strip()]
result[key.value] = tuple(ips)
return result
except Exception as e:
logger.error(f"加载中间件配置异常: {e}")
return default_config
改动要点:读取统一走 SysParamKey.xxx.value 字符串键;布尔值先 strip() 再 lower(),兼容各种写法;IP 名单优先按 JSON 解析,失败再按逗号、分号、换行分隔;Redis 拿不到配置时用环境变量兜底,而不是硬编码 False。
几点经验
1. 配置分层要有明确的优先级
系统里有静态配置(环境变量)和动态配置(数据库/配置中心)两套来源,写兜底逻辑的时候不要盲目硬编码常量,按这个顺序取值:
Redis/配置中心动态值 → .env 静态环境变量 → 默认兜底
2. 字典的 key 统一用 Enum.value 字符串
跨模块传递的字典(dict、JSON)里,统一用 Enum.value 字符串做 key。就算用了 StrEnum,统一成字符串键也能避免序列化、反序列化和维护时理解偏差带来的隐性 bug。
3. 对外部输入的字符串保持防御
从数据库或外部接口读出来的字符串(比如 "True"、" 127.0.0.1, 192.168.1.1 "),不要假设它一定符合标准格式:
- 布尔值:统一经过
.strip().lower() in ("true", "1", "yes", "on")解析 - 列表/集合:先尝试 JSON 解析,不行再按逗号、分号、换行分隔
这次的问题排查记录就到这里,主要是配置读取链路和字符串容错两个方向,遇到类似问题的同学可以参考下。