ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SurfSense 后端 E2E 测试基座全解:sys.modules 劫持、确定性 Fake 与 Playwright 无网环境实战

SurfSense 后端 E2E 测试基座全解:sys.modules 劫持、确定性 Fake 与 Playwright 无网环境实战 SurfSense 后端 E2E 测试基座全解sys.modules 劫持、确定性 Fake 与 Playwright 无网环境实战【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense本文是 SurfSense 开源仓库中 surfsense_backend/tests/e2e/README.md 的深度展开。SurfSense 是一个连接 Google Drive、Gmail、Notion、Confluence、Linear、Jira、Slack、ClickUp、Dropbox、OneDrive 等众多外部服务的开源研究平台其后端 E2E端到端测试面临一个棘手问题测试必须跑通真实的生产路由代码却又不能真的去调用这些第三方 SDK 与 LLM 服务。本文围绕这套 Backend E2E Harness 展开讲解它如何通过sys.modules劫持与严格的确定性 Fake让 Playwright 在完全离线、无外部依赖的环境下完成全链路验证并给出从零搭建到 CI 复现的完整实操流程。读完你不仅能直接跑起 SurfSense 的 E2E 套件还能把同样的测试专用入口 模块级注入 严格失败模式复用到自己的多 SDK 项目中。一、这套 E2E 基座解决什么问题在介绍细节之前先理解 SurfSense 后端 E2E 的两条硬约束要测真实生产代码E2E 的价值在于从 Playwright 浏览器一直打到 FastAPI 路由、Celery 异步任务、索引管线解析、分块、向量化、入库的完整链路因此不能像单元测试那样 mock 掉路由层绝不能碰真实外部服务Composio、Notion、Confluence、Google 系工具、各种 LLM 提供商既不允许真实出网成本、稳定性、认证都不可控也不应该在 CI 里被意外打到。SurfSense 的答案就是surfsense_backend/tests/e2e/这个测试专用后端基座——它包含测试专属的启动入口和一组替换外部 SDK 的 Fake仅由 Playwright 使用不属于生产镜像本目录包含 Playwright 使用的仅测试后端入口和 Fake。它们不是生产镜像的一部分.dockerignore排除了tests/E2E Docker 阶段通过单独的构建上下文复制该目录。目录文件清单路径用途run_backend.py先把测试 Fake 装进sys.modules再启动 FastAPI生产main.py完全不变run_celery.py以同样的 Fake 配置启动 Celery workermiddleware/scenario.py读取X-E2E-Scenario请求头写入请求作用域的 context varfakes/composio_module.py供连接器流程使用的composio包 Fakefakes/llm.py假聊天模型工厂fakes/embeddings.py确定性 embedding 辅助函数fakes/fixtures/drive_files.jsonDrive 夹具数据与 canary 文件内容实际仓库中fakes/目录远不止文档列出的这几个文件还包括chat_llm.py模拟带工具调用行为的聊天模型、notion_module.py、confluence_indexer.py、docling_service.py、dropbox_api.py、jira_module.py、linear_module.py、mcp_runtime.py、mcp_oauth_runtime.py、native_google.py、onedrive_graph.py、slack_module.py、clickup_module.py等以及按连接器组织的 JSON 夹具gmail_messages.json、calendar_events.json、confluence_pages.json、jira_issues.json、linear_issues.json、notion_pages.json、onedrive_files.json、slack_messages.json、dropbox_files.json、clickup_tasks.json和二进制 PDF 夹具fakes/fixtures/binary/。详见 surfsense_backend/tests/e2e/fakes/。二、为什么需要 import hook模块加载时机问题核心难点在文档中有明确的解释一些生产模块在模块加载时就会导入 SDK 客户端例如from composio import Composio。到app.app被导入时这些绑定已经固定下来。这是 Python 里很常见的时机陷阱如果等生产模块已经执行完from composio import Composio再去 patch绑定已经固化在模块命名空间里unittest.mock.patch只能从外部替换属性无法挽回sys.modules缓存造成的早期绑定。所以 E2E 入口必须在任何app.*模块被导入之前先把 Fake 模块装进sys.modules# tests/e2e/run_backend.py def _hijack_external_sdks() - None: import tests.e2e.fakes.composio_module as _fake_composio import tests.e2e.fakes.notion_module as _fake_notion sys.modules[composio] _fake_composio sys.modules[notion_client] _fake_notion sys.modules[notion_client.errors] _fake_notion.errors此后生产代码里任何from composio import Composio、import notion_client都会解析到本地 Fake而生产代码本身一行不改——文档强调生产代码在有无此文件时字节级一致。引导bootstrap顺序是严格有讲究的run_backend.py的_bootstrap()明确标注了每一步的顺序是load-bearing承重墙级别在sys.modules中劫持composio与notion_client加载.env并设置环境变量默认值app.config在导入时会读环境变量配置日志物化一份合成的global_llm_config.yaml保证 Auto 模式的模型固定解析能找到候选导入生产应用app.app它传递性地导入已假化的外部 SDK并读取环境默认值与 YAML在所有消费点 patch LLM / embedding 绑定把测试专用中间件与/__e2e__路由挂到应用上。从源码结构看这种先劫持模块 → 再设环境 → 后导入生产代码 → 最后补 patch的顺序保证了生产代码在导入那一刻看到的就是 Fake 世界这是整个方案成立的前提。环境变量默认值sentinel 哨兵机制_load_dotenv_and_set_env_defaults()是一个值得借鉴的细节它用setdefault给每个生产配置需要的环境变量设置默认值与 docker/docker-compose.deps-only.yml 中db/redis的默认端口、账号完全对齐postgresqlasyncpg://postgres:postgreslocalhost:5432/surfsense、redis://localhost:6379/0因此本地跑 E2E不需要.env文件。更有意思的是 sentinel 哨兵键os.environ.setdefault(COMPOSIO_API_KEY, local-deny-real-call-sentinel) os.environ.setdefault(COMPOSIO_ENABLED, TRUE) os.environ.setdefault(OPENAI_API_KEY, local-deny-real-call-sentinel) os.environ.setdefault(ANTHROPIC_API_KEY, local-deny-real-call-sentinel) os.environ.setdefault(LITELLM_API_KEY, local-deny-real-call-sentinel)Fake 从不读取这些值它们的意义在于一旦有任何泄漏的真实调用逃出 Fake 层就会带着无效 key 打到真实提供商并收到 401从而大声失败。这是文档提到的纵深防御第一层配合 Fake 内部的严格__getattr__第二层和 CI 里的网络拒绝第三层见第五节。加载顺序还有一个优先级设计load_dotenv()在最后调用且python-dotenv不会覆盖已存在的环境变量因此E2E 默认值 开发者.env而显式 export 的 shell 变量 两者——本地调试时不会被.env里的AUTH_TYPEGOOGLE之类配置意外干扰。合成全局 LLM 配置_install_synthetic_global_llm_config()解决的是 CI 全新检出场景真实的app/config/global_llm_config.yaml被 gitignore由生产运维自带真实 key新检出目录里没有这个文件会导致 Auto 模式在每次聊天流式请求时报 No usable global LLM configs are available for Auto mode。方案是把tests/e2e/fixtures/global_llm_config.yaml拷到生产期望的位置仅当目标不存在时纯属测试期脚手架不碰生产代码。LLM 绑定 patchpatch 在导入之后、每个消费点由于unittest.mock.patch无法挽回导入期的from ... import ...绑定_patch_llm_bindings()的做法是逐个消费点显式 patchtargets [ app.services.llm_service.get_agent_llm, app.tasks.connector_indexers.confluence_indexer.get_agent_llm, app.tasks.connector_indexers.google_drive_indexer.get_agent_llm, app.tasks.connector_indexers.google_gmail_indexer.get_agent_llm, app.tasks.connector_indexers.notion_indexer.get_agent_llm, app.tasks.connector_indexers.onedrive_indexer.get_agent_llm, app.tasks.connector_indexers.dropbox_indexer.get_agent_llm, app.tasks.connector_indexers.local_folder_indexer.get_agent_llm, app.tasks.document_processors._save.get_agent_llm, app.tasks.document_processors.markdown_processor.get_agent_llm, ]同时还有一组 chat 流式模型工厂app.agents.chat.runtime.llm_config.create_chat_litellm_from_agent_config、create_chat_litellm_from_config以及app.tasks.chat.streaming.flows.shared.llm_bundle下的两个对应函数被替换为tests.e2e.fakes.chat_llm的假工厂。patch 失败如某个 indexer 未加载会打印 warning 而非静默放过——源码注释明确警告如果生产代码在 E2E 里走到这条路径就会打到真实提供商请更新run_backend.py。Celery worker 为什么要单独入口run_celery.py几乎是run_backend.py的镜像同样的劫持、环境默认值、LLM patch、运行时 Fake 安装区别在于导入的是app.celery_app而不是app.app。原因是Celery worker 运行在独立的 Python 解释器中所以 patch 必须在这里也应用一遍——它们不会从 FastAPI 进程带过来。这正是许多人容易踩的坑以为 mock 是跨进程共享的。_main()里还包含平台相关的 worker 参数# macOS forks-after-MPS-init crash prefork workers; threads avoid it. default_pool threads if sys.platform darwin else prefork并默认监听surfsense,surfsense.connectors两个队列、并发数 2可通过CELERY_POOL、CELERY_CONCURRENCY、CELERY_TASK_DEFAULT_QUEUE覆盖。三、Fake 的设计哲学严格失败绝不静默穿透文档给出了 Fake 的总原则Fake 应该大声失败。如果生产代码开始使用 Fake 未实现的新 SDK 方法请把该方法加进 Fake而不是让测试去调用真实服务。3.1 严格 mixin未知属性一律NotImplementedError以 fakes/composio_module.py 为例所有假类继承_StrictFakeMixinclass _StrictFakeMixin: def __getattr__(self, name: str) - Any: raise NotImplementedError( fE2E Composio fake missing surface: {self._component_name}.{name!r}. fIf production code needs this, add an explicit method to fsurfsense_backend/tests/e2e/fakes/composio_module.py — fthe strict fake refuses to silently fall through to the real SDK. )同理_Tools.execute遇到没有建模的 tool slug 时也抛出NotImplementedErrorE2E Composio fake has no handler for tool slug ...。这样一旦生产代码引入新的 SDK 调用CI 会立刻给出清晰的请把该接口补进 Fake提示而不是让真实 SDK 悄悄穿透。3.2 顶层Composio类的形状生产代码的调用方式是Composio(api_key..., file_download_dir...)因此 Fake 提供同名构造并在实例上挂出connected_accounts、tools、auth_configs三个子客户端connected_accounts.initiate(...)模拟 OAuth 发起。真实 SDK 的redirect_url指向 Composio 托管的 OAuth 界面再跳第三方E2E 里直接短路回我们自己的同源回调从而彻底避免真实网络需求。它还根据 scenario 决定回调 URL 带connectedAccountId...成功还是?erroraccess_denied拒绝场景。connected_accounts.wait_for_connection / get / delete / refresh返回固定 ACTIVE 状态的_ConnectedAccount。auth_configs.list()为每个要测的 toolkit 返回一个 auth configgoogledrive、gmail、googlecalendar。tools.execute(slug..., connected_account_id..., arguments...)按 slug 分发给 Drive / Gmail / Calendar 的处理器。值得注意的细节是initiate的 account id 合成规则fake-acct-{toolkit_id}-{user_id}同一实体上同一 toolkit 会得到相同 ID——这样重复连接场景能稳定触发composio_routes.py里的重连分支。3.3 用夹具模拟文件列表与下载tools.execute的 Drive 处理器会解析 Drive 风格的q查询如folder_id in parents and trashed false从中抽取 folder id再到_DRIVE_FIXTURE来自drive_files.json里取对应列表并模拟trashed false、mimeType !、mimeType 等过滤条件。下载场景则把夹具内容写进/tmp/surfsense-e2e-composio-downloads/并返回文件路径——与真实 SDK 下载字节到本地文件并返回路径的行为一致生产侧composio_service.py读取字节的逻辑无需任何改动。drive_files.json的结构是文件夹 id → 文件列表的映射外加两个特殊键_file_contents: { fake-file-canary: Canary token for E2E tests: SURFSENSE_E2E_CANARY_TOKEN_DRIVE_001\n... }, _file_binary_paths: { fake-file-pdf-native: binary/drive-canary.pdf, fake-file-pdf-composio: binary/composio-drive-canary.pdf }文本文件直接内联内容二进制PDF则指向fakes/fixtures/binary/下的真实 PDF 文件fakes/fixtures/binary/generate_canary_pdfs.py用于生成它们。canary token如SURFSENSE_E2E_CANARY_TOKEN_DRIVE_001是贯穿全链路的信标文件内容被索引后测试断言可以在最终结果里找到这些标记从而确认端到端管线真实跑通。3.4 确定性 LLM 与 embeddingfakes/llm.py生产索引管线用summary_chain SUMMARY_PROMPT_TEMPLATE | llm总结文档llm由app.services.llm_service.get_agent_llm按文档提供。Fake 把该函数替换成返回 langchain 原生的FakeListChatModel固定返回E2E_FAKE_SUMMARY: Indexed by Playwright E2E run with deterministic LLM stub.——不碰任何真实 LLM 提供商包其余链条prompt 模板、chain 调用原样运行。fakes/embeddings.pyfake_embed_text / fake_embed_texts返回全 0.1 的np.float32向量维度从config.embedding_model_instance.dimension动态解析与 documents.embedding 的 pgvector 列向量兼容。它沿用了集成测试surfsense_backend/tests/integration/conftest.py中patched_embed_textsfixture 的模式patch 目标覆盖源绑定、消费方绑定与索引缓存门面app.indexing_pipeline.cache.cached_indexing.embed_texts且 patch 失败会直接raise RuntimeError——注释解释得很直白静默穿透到真实 embedding 模型会既昂贵又不确定。fakes/chat_llm.py这是最聪明的 Fake——一个完整的BaseChatModel子类FakeChatLLM能模拟多轮工具调用。它维护一张 canary 表Gmail、Calendar、Drive、OneDrive、Dropbox、Notion、Confluence、Linear、Jira、Slack、ClickUp、手动上传等根据最新人类消息的关键词决定是直接返回包含 canary token 的文本还是先发一个tool_calls如search_gmail→read_gmail_email让生产 agent 执行真实Fake 化的工具、拿到工具结果后再作答。它甚至能模拟主 agent 把连接器任务委派给mcp_discovery子 agent的流程。_astream还把工具调用流式输出为AIMessageChunk保证流式 SSE 路径也能被覆盖。四、请求级场景编排X-E2E-Scenario 中间件单个 Fake 只能给出一切顺利的答案但 E2E 还要测失败路径。middleware/scenario.py提供了一个极简而优雅的机制把X-E2E-Scenario请求头读入一个ContextVarFake 按当前请求上下文切换行为。_scenario: ContextVar[str] ContextVar(e2e_scenario, defaulthappy) async def dispatch(self, request: Request, call_next) - Response: value request.headers.get(X-E2E-Scenario, happy) token _scenario.set(value) try: request.state.e2e_scenario value return await call_next(request) finally: _scenario.reset(token)注意finally里的resetContextVar 是请求作用域的请求结束即还原不会串扰。该中间件只在 E2E 入口挂载_install_test_only_app_extensions生产环境既不会挂中间件、也不会读这个请求头。支持的内置场景场景行为happy默认一切成功返回确定性夹具deniedComposio.connected_accounts.initiate返回指向我们回调、带?erroraccess_denied的跳转 URL覆盖 OAuth 拒绝分支auth_expiredGOOGLEDRIVE_LIST_FILES抛_AuthExpiredError消息含 expired or revoked/401路由层据此把连接器置为config.auth_expiredduplicateFake 无特殊行为重复路径靠同一 toolkit 跑两次 OAuth 流程自然触发Fake 侧通过tests.e2e.middleware.scenario.current_scenario()惰性读取延迟导入是为了规避 sys.modules 劫持期scenario尚不可导入的时序。测试专用 token 铸造端点同属测试专用扩展的还有 auth_mint.pyPOST /__e2e__/auth/token。它存在的理由是生产登录接口/auth/jwt/login有 5 次/分钟/IP 的限流Playwright 的并行 worker 会把它打爆。mint 端点用X-E2E-Mint-Secret共享密钥做认证默认local-e2e-mint-secret-not-for-productionCI 里由docker-compose.e2e.yml的E2E_MINT_SECRET与 GitHub Actions 工作流共同持有然后直接为已 seed 的e2e-testsurfsense.net用户签发 JWT。auth_mint.install(app)与ScenarioMiddleware一起由_install_test_only_app_extensions挂载同样永远不会进入生产镜像。五、共享给后端集成测试文档还展示了一个关键复用后端集成测试在需要真实路由代码但不想碰真实 SDK时可以直接复用同一套 Fakefrom tests.e2e.fakes import composio_module as _fake_composio sys.modules[composio] _fake_composio from app.app import app当前实践见 surfsense_backend/tests/integration/composio/conftest.py——它的 docstring 写明composio 的 sys.modules 劫持位于父级 integration conftest并把config.COMPOSIO_API_KEY设为e2e-integration-composio-sentinel。这意味着同一条模块级注入技术同时服务了 E2E 与集成测试两层维护成本共享。六、本地运行 E2E完整实操流程文档给出的推荐本地流程是只把 Postgres 和 Redis 跑在 Docker 里后端与 Celery worker 跑在宿主机上。两个入口用setdefault提供了所有必需变量的默认值因此不需要.env。一次性准备从surfsense_web/执行pnpm install pnpm exec playwright install --with-deps chromium每次运行五个步骤1. 拉起 Postgres Redis在仓库根目录deps-only 里的 Zero、pgAdmin 等其余服务 E2E 用不到docker compose -f docker/docker-compose.deps-only.yml up -d db redis2. 启动后端surfsense_backend/终端 Auv sync uv run alembic upgrade head uv run python tests/e2e/run_backend.pyuv sync安装依赖alembic upgrade head把 170 个迁移版本跑到最新最后启动带 Fake 的 FastAPI默认0.0.0.0:8000可用UVICORN_HOST、UVICORN_PORT、UVICORN_LOG_LEVEL覆盖。3. 启动 Celery workersurfsense_backend/终端 Buv run python tests/e2e/run_celery.py4. 注册 Playwright 用户curl -X POST http://localhost:8000/auth/register \ -H Content-Type: application/json \ -d {email:e2e-testsurfsense.net,password:E2eTestPassword123!}5. 运行 Playwrightsurfsense_web/终端 Cpnpm test:e2e # dev server快速迭代 pnpm test:e2e:headed # 显示浏览器 pnpm test:e2e:ui # Playwright UI 模式 pnpm test:e2e:prod # build start与 CI 完全一致这四条命令对应 surfsense_web/package.json 里的脚本另有test:e2e:debug、test:e2e:report、test:e2e:install。playwright.config.ts与运行脚本共享默认值因此全新检出即可工作只有当你把测试指向另一套堆栈时才需要设置PLAYWRIGHT_TEST_EMAIL、PLAYWRIGHT_TEST_PASSWORD、NEXT_PUBLIC_FASTAPI_BACKEND_URL或后端环境变量如DATABASE_URL。清理docker compose -f docker/docker-compose.deps-only.yml down加-v会一并清掉 Postgres 数据卷。七、与 CI 完全一致的 Hermetic 方案如果要把 CI 环境精确复现——后端和 Celery 都在容器里、网络出口在 L3 层被禁止——可以把上面第 1–3 步替换为docker compose -f docker/docker-compose.e2e.yml up -d --build --wait然后照常执行第 4 步curl 注册和第 5 步pnpm test:e2e:prod。拆除docker compose -f docker/docker-compose.e2e.yml down -v --remove-orphans文档提醒这会构建约 9 GB 的surfsense-e2e-backend:local镜像所以日常开发用上面的 deps-only 流程更快。Hermetic 堆栈的源码级细节docker/docker-compose.e2e.yml 揭示了这个方案的多层防线网络隔离internal网络声明为internal: true纯内部网桥无到宿主机/互联网的路由db、redis、celery_worker都只挂在这张网上backend额外挂一张普通ingress网桥仅为让宿主机能访问:8000。celery worker 的对外攻击面为零。代理哨兵即使 backend 有出网路径容器环境里的HTTPS_PROXYhttp://127.0.0.1:1/HTTP_PROXY会把任何泄漏的 Python 出站 HTTP 调用变成连接被拒NO_PROXY只放行 localhost、db、redis 等内部目标。这就是文档说的L3 拒绝之上的应用层兜底。离线 HuggingFaceHF_HUB_OFFLINE1、TRANSFORMERS_OFFLINE1配合构建参数EMBEDDING_MODELsentence-transformers/all-MiniLM-L6-v2确保 embedding 模型也离线可用。环境一致性backend-env锚点里的大部分变量与run_backend.py的setdefault默认值一一对应DATABASE_URL指向db:5432/surfsense_e2eAUTH_TYPELOCALREGISTRATION_ENABLEDTRUEETL_SERVICEDOCLINGsentinel API keys……另外设置了E2E_MINT_SECRET供 token mint 端点使用。镜像构建backend服务用build.target: e2e构建见 surfsense_backend/Dockerfile 中base → deps → models → {e2e, production}的阶段图并通过additional_contexts把被.dockerignore排除的tests/以tests-source上下文注入COPY --fromtests-source . ./tests/——这样测试 Fake 物理上不会进入生产镜像与文档开头不属于生产镜像的承诺闭环。celery_worker不重复构建pull_policy: never直接复用backend构建出的同一镜像。健康检查backend 用 Python 标准库探测/openapi.json避免依赖镜像里不一定装的 curl/wgetcelery 用celery inspect ping检查 worker 存活。八、如何新增一个 Fake可操作的清单文档给出了新增 Fake 的三步清单结合源码可以补充更多细节新增fakes/sdk_module.py参考composio_module.py的写法——顶层类用_StrictFakeMixin未建模属性抛NotImplementedError建模生产代码实际会用到的子客户端与方法需要失败路径时读tests.e2e.middleware.scenario.current_scenario()分支。在run_backend.py和run_celery.py中都注册必须放在import app.app/import app.celery_app之前。两类入口各维护一套_hijack_external_sdks()、_patch_llm_bindings()、_install_runtime_fakes()因为 FastAPI 与 Celery 是两个独立进程patch 不会跨进程传递。如果需要按测试变化的行为从tests.e2e.middleware.scenario.current_scenario()读取当前场景。同时注意若 Fake 需要连接器级数据把夹具加进fakes/fixtures/下对应 JSON文本走_file_contents二进制走_file_binary_paths指向fakes/fixtures/binary/。文档还给出了一条硬性纪律生产代码引入新的 SDK 方法时把方法补进 Fake而不是让测试调用真实服务——这是整套方案测试可信度的根基。九、设计启示这套模式的通用价值把 SurfSense 的 Backend E2E Harness 抽象出来可以看到一个可迁移到任何多 SDK 接入型项目的模式测试专用入口而非改造生产入口run_backend.py/run_celery.py与生产main.py/celery_worker.py并存生产代码零侵入、字节级一致且通过 Docker 多阶段构建 .dockerignore从物理上保证测试代码不进生产镜像sys.modules 劫持早于任何业务导入解决了模块加载期绑定固定这一 mock 工具的先天盲区严格失败的 Fake 契约NotImplementedError让Fake 与生产 SDK 面漂移在 CI 立刻暴露而不是静默打到真实服务确定性数据 canary token固定向量、固定摘要、内容里的标记 token让 E2E 断言既稳定又可读请求级场景编排一个X-E2E-Scenario头 ContextVar就同时覆盖了 happy path 与 OAuth 拒绝、凭证过期、重复连接等异常分支多层纵深防御模块劫持第一层→ 严格__getattr__第二层→ 哨兵 API key 代理拒绝 L3 内网隔离第三层任何一层失守都会被大声暴露。如果你在维护一个重度依赖第三方 SDK 的后端SurfSense 这套 surfsense_backend/tests/e2e/ 的实现是值得通读的参考范本配套的 docker-compose.e2e.yml 与 surfsense_backend/tests/integration/composio/conftest.py 则分别展示了容器化隔离与一套 Fake 两处复用的落地姿势。【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表