FastapiAdmin 实战:演示模式开关失效的排查记录

最近联调的时候发现一个问题:演示模式开关 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 不可用时,环境变量被兜底值覆盖

系统有两套配置来源:

  1. 静态配置:.env.dev / settings.DEMO_ENABLE: bool = False(部署时生效)
  2. 动态配置:数据库 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 解析,不行再按逗号、分号、换行分隔

这次的问题排查记录就到这里,主要是配置读取链路和字符串容错两个方向,遇到类似问题的同学可以参考下。

相关推荐
Gopher_HBo42 分钟前
zap日志 整体架构与数据流
后端
颜进强42 分钟前
09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙
前端·后端·ai编程
归鹭42 分钟前
spring外部化配置
后端
拖孩42 分钟前
一个人 + AI 做的小程序,一个半月把服务器钱赚回来一半了
前端·后端·微信小程序
风骏时光牛马42 分钟前
后端服务接口开发与业务逻辑实现
前端
京东云开发者43 分钟前
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
后端·架构·ai编程
SL_staff43 分钟前
JVS-Logic 实践:如何将‘需求→上线’从11天压缩至2小时?
java·后端·开源
编程老船长43 分钟前
权限不只是菜单按钮——QuickBlue 的 RBAC 与行级数据权限是怎么落地的
java·前端·后端
SL_staff43 分钟前
区域银行如何用JVS-BI两周打通异构数据库:面向开发者的低代码数据融合实践
java·后端·数据可视化