Wazuh Engine 的 cmcrud 模块解析:内容管理器的 CRUD 服务层与“先验证后写入“设计

发布时间:2026/11/6 13:54:59

Wazuh Engine 的 cmcrud 模块解析:内容管理器的 CRUD 服务层与“先验证后写入“设计 Wazuh Engine 的 cmcrud 模块解析内容管理器的 CRUD 服务层与先验证后写入设计【免费下载链接】wazuhWazuh - The Open Source Security Platform. Unified XDR and SIEM protection for endpoints and cloud workloads.项目地址: https://gitcode.com/GitHub_Trending/wa/wazuhcmcrud 是 Wazuh 引擎engine中 Content Manager 的 CRUD 服务层位于 HTTP API 处理器与持久层cmstore之间负责协调对命名空间namespace、策略policy与各类资源decoder、filter、output、integration、KVDB的全部增删改查操作。本文基于仓库中src/engine/source/cmcrud/README.md及其对应源码实现完整讲解该模块的架构分层、验证先于变更Validation-Before-Mutation原则、资源导入的依赖顺序与回滚机制、弱指针生命周期模型并给出构建目标与测试组织方式帮助读者理解 Wazuh 引擎如何保证每一份持久化到cmstore的内容工件artifact都已通过一致性检查。架构分层ICrudService 处于 HTTP 层与持久层之间从模块 README 给出的架构图可以看到cmcrud 在调用链中的位置非常清晰┌────────────────┐ ┌────────────────┐ │ api/cmcrud │ │ cmsync │ │ (HTTP layer) │ │ (sync service) │ └───────┬────────┘ └───────┬─────────┘ │ ICrudService │ ICrudService ▼ ▼ ┌──────────────────────────────────────────┐ │ CrudService │ │ │ │ • Structured JSON handling │ │ • Asset adaptation (canonical ordering) │ │ • Validation delegation │ │ • Import orchestration with rollback │ └──────┬─────────────────────┬─────────────┘ │ ICMStore │ IValidator ▼ ▼ ┌──────────────┐ ┌─────────────────┐ │ cmstore │ │ builder module │ │ (persistent │ │ (structural │ │ content) │ │ validation) │ └──────────────┘ └─────────────────┘上游有两个消费者均通过ICrudService接口与 cmcrud 交互而不直接依赖具体实现api/cmcrudHTTP API 处理器向外部客户端暴露命名空间与资源的 CRUD 操作。在 handlers.cpp 中可以确认每个路由处理器如namespaceList、namespaceCreate、namespaceDelete都持有std::weak_ptrcm::crud::ICrudService并通过adapter::getReqAndHandler模板将请求分发到 CRUD 服务——这种弱指针持有方式与 cmcrud 内部的生命周期设计相呼应。cmsync内容同步服务使用ICrudService从集群导入内容其核心实现 cmsync.cpp 调用的正是importNamespace的重载之一。下游则依赖两个模块持久内容层cmstore通过ICMStore接口和builder模块的结构化验证器通过IValidator接口其实现在 builder.cpp。关键设计约束在 README 中明确写出在任何变更到达 store 之前cmcrud 会接收结构化的json::Json载荷执行类型特定的适配资产的规范字段排序并把结构化验证委托给builder::IValidator。这保证了每一份持久化到cmstore的工件都已经被检查过一致性。验证先于变更按资源类型分派的验证路径CrudService从不绕过验证直接写cmstore。根据资源类型它走不同的验证路径资源类型验证路径Policy策略IValidator::softPolicyValidate()Integration集成IValidator::softIntegrationValidate()Assetdecoder / filter / outputIValidator::validateAsset()KVDB通过cm::store::dataType::KVDB::fromJson()解析仅做结构检查导入路径上的force/softValidation标志可以放松或跳过部分检查。这一点在 cmcrudservice.cpp 中可以直接印证。三个私有验证方法分别封装了对应的委托调用L758-L779void CrudService::validatePolicy(const std::shared_ptrcm::store::ICMStoreNSReader nsReader, const cm::store::dataType::Policy policy) const { throwIfError(getValidator()-softPolicyValidate(nsReader, policy), fmt::format(Policy validation failed in namespace {}, nsReader-getNamespaceId().toStr())); } void CrudService::validateIntegration(...) const { throwIfError(getValidator()-softIntegrationValidate(nsReader, integration), ...); } void CrudService::validateAsset(const std::shared_ptrcm::store::ICMStoreNSReader nsReader, const json::Json asset) const { throwIfError(getValidator()-validateAsset(nsReader, asset), ...); }注意它们都以nsReader命名空间只读视图作为第一个参数传入验证器——这意味着验证器能够基于命名空间中当前已有什么来做上下文相关的检查例如 policy 引用的资源是否存在而不是孤立地检查单个 JSON 文档。而 KVDB 的验证实际上就是解析kvdbFromDocument()直接委托给KVDB::fromJson()L51-L54解析成功即意味着结构合法没有额外的验证器调用。force/softValidation标志的语义在导入流程中体现为条件判断例如 JSON 文档导入路径中的 integration 分支L262-L265if (!force) { validateIntegration(nsReader, integ); }即force true时跳过该项验证直接落库。Asset 适配规范字段排序保证确定性序列化资产decoder / filter / output在存储前会经过一个类型特定的adapter强制执行规范字段排序detail::adaptDecoder()— decoder 的规范字段顺序detail::adaptFilter()— filter 的规范字段顺序detail::adaptOutput()— output 的规范字段顺序这确保了无论用户以何种顺序提供字段序列化结果都是确定性的。在 cmcrudservice.cpp 中这三个适配函数在importNamespace的资产分支L291-L300、upsertResource的资产分支L624-L633以及validateResource中都被以相同的模式调用auto assetJson [item, type]() - json::Json { switch (type) { case cm::store::ResourceType::DECODER: return cm::store::detail::adaptDecoder(item); case cm::store::ResourceType::FILTER: return cm::store::detail::adaptFilter(item); case cm::store::ResourceType::OUTPUT: return cm::store::detail::adaptOutput(item); } __builtin_unreachable(); }();适配之后cmcrud 还会做一项名字前缀检查资产的逻辑名必须形如decoder/...、filter/...、output/...前缀部分必须与声明的资源类型一致否则抛出Asset name {} does not match resource type {}错误L308-L312。这从命名层面防止了把一个 filter 伪装成 decoder 存进去这类错误。命名空间导入严格依赖顺序与 all-or-nothing 回滚导入import是一个全有或全无的操作。资源按严格的依赖链顺序摄取KVDBDecoderFilterJSON 文档重载下从 integration 中提取OutputJSON 文档重载下从 integration 中提取IntegrationPolicy如果任何一步失败bestEffortDelete回滚 lambda 会删除该命名空间中已经创建的一切。JSON 文档重载的完整实现见 importNamespace其执行流程与 README 的描述一一对应并补充了若干源码级的细节拒绝覆盖若目标命名空间已存在直接抛错——Import is only allowed into a new namespaceL170-L175。严格的结构校验导入 JSON 必须包含且仅包含policy与resources两个顶层键且二者都必须是对象L191-L225。文档注释中给出的预期结构为{ policy: { ... }, resources: { kvdbs: [ ... ], decoders: [ ... ], integrations: [ ... ], policy: { ... } } }注册回滚bestEffortDelete是一个捕获 store 的 lambda调用store-deleteNamespace(id)若回滚删除本身也失败只记录LOG_WARNING_L警告而不掩盖原始错误L148-L163。按序导入importResourceslambda 按KVDB → DECODER → FILTER → OUTPUT → INTEGRATION的顺序依次读取/resources/kvdbs、/resources/decoders等数组并逐条入库L329-L334随后处理/policy并通过ns-upsertPolicy(policy)落库。origin space 记录若调用方提供了originSpace非空导入的策略会通过policy.setOriginSpace(originSpace)标记来源空间L344-L348。失败即回滚外层catch块在destinationCreated为真时执行bestEffortDelete(nsId)并抛出一条带命名空间标识与原始错误信息的std::runtime_errorL353-L360。importNamespace存在两个重载职责与适用场景不同重载输入使用场景importNamespace(nsId, jsonDocument, origin, force)包含全部组件的单个 JSON 文档API 驱动的导入例如上传完整的命名空间导出importNamespace(nsId, kvdbs, decoders, filters, integrations, policy, softValidation)预解析的组件向量cmsync的程序化导入从 icmcrudservice.hpp 的接口签名可以看到第二个重载实际接收六个参数向量kvdbs、decoders、filters、integrations四个std::vectorjson::Json加上policy文档与softValidation布尔标志。其实现L365-L462同样先拒绝已存在的命名空间然后依次循环处理四类资源最后upsertPolicy。与 JSON 文档重载的一个差异是组件重载中每个资产/集成使用if (!softValidation) validator-validateAsset/softIntegrationValidate(...)控制验证粒度即softValidation true时只保留最关键的检查。弱指针资源模型显式的生命周期管理CrudService把它的两个依赖ICMStore、IValidator保存为std::weak_ptr。构造时要求两个shared_ptr均非空否则抛std::invalid_argumentL72-L85此后每个公开方法入口都通过getStore()/getValidator()尝试lock()若底层对象已被销毁则抛出带明确语义的std::runtime_errorCMStore is no longer available / Validator is no longer availableL87-L105。这种设计带来两个好处其一避免CrudService通过强引用意外延长 store/validator 的生命周期防止悬垂引用其二把生命周期问题转化为运行期可诊断的异常使底层对象何时可以安全析构这一行为是显式的。上游api/cmcrud的 handlers 同样以weak_ptr持有ICrudService说明弱引用 入口锁定是整条调用链统一的资源持有风格。公开接口ICrudService 与 ResourceSummary接口定义在 icmcrudservice.hpp命名空间为cm::crud。接口按三组组织文档头注释明确了实现者的四项职责使用cm::store::ICMStore解析命名空间把结构化 JSON 载荷转换为对应数据类型Policy、Integration、KVDB、资产在变更底层 store 之前把结构化检查委托给builder::IValidator成功后应用cm::store变更。错误处理约定为抛出携带描述性消息的std::runtime_error或派生类型。命名空间操作virtual std::vectorcm::store::NamespaceId listNamespaces() const 0; virtual void createNamespace(const cm::store::NamespaceId nsId) 0; virtual bool existsNamespace(const cm::store::NamespaceId nsId) const 0; virtual void deleteNamespace(const cm::store::NamespaceId nsId) 0;createNamespace若命名空间已存在则实现应失败接口注释如此约定实现中会包装为带命名空间名的std::runtime_error见 L117-L127。deleteNamespace删除命名空间及其全部资源。Policy 操作virtual void upsertPolicy(const cm::store::NamespaceId nsId, const json::Json policy) 0; virtual void deletePolicy(const cm::store::NamespaceId nsId) 0;upsertPolicy的语义接口注释 实现 L464-L481若命名空间没有 policy 则新建否则替换载荷先转换为cm::store::dataType::Policy验证通过后才落库——这也是验证先于变更原则在非导入路径上的体现。通用资源操作virtual std::vectorResourceSummary listResources(const cm::store::NamespaceId nsId, cm::store::ResourceType type) const 0; virtual json::Json getResourceByUUID(const cm::store::NamespaceId nsId, const std::string uuid) const 0; virtual void upsertResource(const cm::store::NamespaceId nsId, cm::store::ResourceType type, const json::Json resource) 0; virtual void deleteResourceByUUID(const cm::store::NamespaceId nsId, const std::string uuid) 0; virtual void validateResource(cm::store::ResourceType type, const json::Json payload) 0;配套的轻量目录结构体struct ResourceSummary { std::string uuid; /// Resource UUID (unique within namespace). std::string name; /// Logical name, e.g. decoder/apache_access. };validateResource是一个隔离验证入口——不绑定任何命名空间仅按类型校验载荷结构。接口注释中特别注明对 DECODER 的验证缺失的 KVDB 引用不应被视为错误icmcrudservice.hpp#L239这解释了为什么它对 DECODER/FILTER 走validateAssetShallow()浅验证而非完整validateAsset()。CrudService 具体实现头cmcrudservice.hpp 中的CrudService final除了实现全部接口方法外还声明了私有成员与辅助方法std::weak_ptrcm::store::ICMStore m_store; std::weak_ptrbuilder::IValidator m_validator; std::shared_ptrcm::store::ICMStore getStore() const; std::shared_ptrbuilder::IValidator getValidator() const; void validatePolicy(...) const; void validateIntegration(...) const; void validateAsset(...) const; std::shared_ptrcm::store::ICMStoreNSReader getNamespaceStoreView(const cm::store::NamespaceId) const; std::shared_ptrcm::store::ICMstoreNS getNamespaceStore(const cm::store::NamespaceId) const;其中两个命名空间辅助方法分别获取只读视图getNSReader用于listResources/getResourceByUUID等读路径与可写句柄getNS用于写路径二者在命名空间不存在时都抛出Namespace {} does not existL781-L800。匿名命名空间中的辅助函数cmcrudservice.cpp 顶部的匿名 namespace 定义了一组小型辅助函数辅助函数作用assetUuidFromJson(json, assetName)从资产 JSON 文档提取/id字段缺失或为空则抛错还通过base::utils::generators::isValidUUIDv4()校验必须是合法 UUIDv4L14-L30assetNameFromJson(json)从资产 JSON 提取/name逻辑名缺失或为空则抛错throwIfError(base::OptError, context)把OptError转换为抛出的std::runtime_error错误消息带上上下文前缀policyFromDocument(const json::Json)把 JSON 对象转换为cm::store::dataType::PolicyintegrationFromDocument(const json::Json, bool requireUUID)把 JSON 对象转换为cm::store::dataType::IntegrationkvdbFromDocument(const json::Json, bool requireUUID)把 JSON 对象转换为cm::store::dataType::KVDBrequireUUID参数值得注意在导入路径中一律传true导入的文档必须自带 UUID而在upsertResource路径中传false——因为单个资源的 upsert 允许不提供 UUID由存储层分配再按UUID 是否存在则更新、否则按名创建的分支处理L582-L618。关键流程剖析upsertResource按类型分派的创建/更新决策upsertResourceL571-L669接收json::Json载荷后按ResourceType分支INTEGRATION— 经integrationFromDocument(resource, /*requireUUID:*/ false)转换validateIntegration()验证后若 UUID 非空且assetExistsByUUID(uuid)为真则updateResourceByUUID否则createResource按名创建。KVDB— 经kvdbFromDocument()转换后采用同样的按 UUID 存在性决策。DECODER / FILTER / OUTPUT— 先经detail::adaptDecoder/Filter/Output()适配再校验名字前缀与类型一致然后validateAsset()最后以名字作为幂等键assetExistsByName(name)为真则updateResourceByName否则createResource。任一步失败都包装为带资源类型与命名空间上下文的std::runtime_error抛出。注意 integration/KVDB 以 UUID 为幂等键、资产以逻辑名为幂等键的差异这与ResourceSummary中UUID 在命名空间内唯一、name 为逻辑名的注释一致。importNamespaceJSON 文档重载前文命名空间导入一节已详述其步骤与 README 完全对应解析并严格校验文档结构 → 创建命名空间 → 注册bestEffortDelete回滚 → 按KVDBs → Decoders → Filters → Outputs → Integrations严格顺序导入 → 除非force true否则逐项验证 → 成功后返回导入的PolicyL362return store-getNS(nsId)-getPolicy();。getResourceByUUIDUUID 到 JSON 的解析路径getResourceByUUIDL526-L569分三步通过命名空间只读视图的resolveNameFromUUID(uuid)把 UUID 解析为{name, type}按类型加载类型化对象INTEGRATION →getIntegrationByUUIDKVDB →getKVDBByUUIDDECODER/OUTPUT/FILTER →getAssetByUUID把类型化对象序列化回json::Json返回integration/KVDB 经各自的toJson()资产直接返回视图层 JSON。validateResource隔离验证的类型分派validateResourceL686-L756DECODER / FILTER适配载荷 → 提取名字与 UUID校验 UUIDv4→ 校验名字前缀与类型一致 → 调validator-validateAssetShallow()浅验证通过throwIfError把OptError转为异常。INTEGRATION经Integration::fromJson(payload, /*requireUUID:*/ true)转换并校验base::Name::isValidPart名字合法性。KVDB经KVDB::fromJson(payload, /*requireUUID:*/ true)转换并做同样的名字合法性检查。目录结构cmcrud/ ├── CMakeLists.txt ├── README.md ├── interface/cmcrud/ │ └── icmcrudservice.hpp # ICrudService 纯虚接口 ResourceSummary ├── include/cmcrud/ │ └── cmcrudservice.hpp # CrudService 具体实现头 ├── src/ │ └── cmcrudservice.cpp # 完整实现约 800 行 └── test/ ├── mocks/cmcrud/ │ └── mockcmcrud.hpp # GMock mockMockCrudService ├── unit/ # 单元测试 └── component/ # 组件测试各文件在当前仓库中的实际路径为 接口、实现头、实现、mock。CMake 构建目标CMakeLists.txt 定义了五个目标与 README 表格一致并补充了yml私有依赖这一表格未列出的细节Target类型Alias链接cmcrud_icmcrudINTERFACEcmcrud::icmcrudcmstore::icmstore另含basecmcrud_cmcrudSTATICcmcrud::cmcrudcmcrud::icmcrud、builder::ibuilderpublicymlprivatecmcrud_mocksINTERFACEcmcrud::mockscmcrud::icmcrud、GTest::gmockcmcrud_utestExecutable—cmcrud::cmcrud、cmstore::mocks、builder::mocks、GTest::gtest_maincmcrud_ctestExecutable—cmcrud::cmcrud、cmstore::mocks、builder::mocks、GTest::gtest_main所有测试目标都在if(ENGINE_BUILD_TEST)条件内注册并通过gtest_discover_tests向 CTest 暴露用例。测试组织单元测试cmcrudservice_test.cpp约 2500 行使用 mock 的ICMStore与IValidator逐方法测试CrudService。组件测试cmcrud_test.cpp约 570 行覆盖导入流程与资源生命周期的更宽场景。Mockmockcmcrud.hppMockCrudService用 GMock 宏实现了ICrudService的全部 13 个方法含两个importNamespace重载供下游消费者如api/cmcrud、cmsync的测试注入。消费者与装配位置模块依赖角色api/cmcrudcmcrud::icmcrudHTTP API 处理器向外部客户端暴露命名空间与资源的 CRUD 操作cmsynccmcrud::icmcrud内容同步服务使用ICrudService从集群导入内容main.cppcmcrud::cmcrud创建CrudService实例把它与 store、validator 装配起来装配点可以在引擎入口 main.cpp 中确认cmCrudService std::make_sharedcm::crud::CrudService(cmStore, builder);即CrudService在引擎启动时以 store 与 buildervalidator构造随后注入到 API 服务器与同步服务main.cpp 中 L826 附近将其作为依赖传入。这也解释了前文反复出现的弱指针设计main.cpp持有唯一的强引用其余所有使用者handlers、cmsync、CrudService自身的成员都以weak_ptr间接持有析构顺序由引擎入口统一掌控。小结cmcrud 是 Wazuh 引擎内容管理链条上的守门人向上以纯虚接口ICrudService隔离 API 层与同步服务向下把持久化交给cmstore、把一致性判断交给builder::IValidator。它的三个核心机制——按类型分派的验证先于变更、资产适配保证的确定性序列化、带严格依赖顺序与 best-effort 回滚的命名空间导入——共同保证了cmstore中不存在结构不合法的工件而贯穿全模块的weak_ptr 入口lock()模式则把生命周期问题从隐蔽的悬垂引用变成了显式且可诊断的运行时错误。【免费下载链接】wazuhWazuh - The Open Source Security Platform. Unified XDR and SIEM protection for endpoints and cloud workloads.项目地址: https://gitcode.com/GitHub_Trending/wa/wazuh创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
延伸阅读

更多相关文章

2026/11/7 1:23:19

Python图像处理入门:Pillow库基础与应用

1. Python图形处理入门:PIL/Pillow基础解析 计算机图形处理是当代编程中的必备技能,而Python生态中的PIL(Python Imaging Library)及其分支Pillow无疑是这个领域最受欢迎的库之一。作为处理图像的基础工具,它们提供了…

2026/11/7 4:30:44

Java数据类型存储与位运算实战指南

1. Java数据存储基础原理在Java中,数据存储的核心在于理解基本数据类型在内存中的表示方式。以int类型为例,它占用4个字节(32位)的存储空间。当我们声明int a 21时,计算机会将这个值转换为二进制形式存储:…

2026/11/7 11:43:50

初级电工证怎么考流程多久拿证全攻略

初级电工证怎么考流程多久拿证全攻略 实操考试心里没底怕挂科?别急,今天我就从安阳项目现场管理员的角度,把初级电工证怎么考流程拆解得明明白白,让你知道多久拿证、怎么准备、考试要啥条件,全都有答案。 初级电工证到底有什么用?…

2026/11/7 11:43:50

悦雅国际电工证怎么考 2026河南最新政策全解析

悦雅国际电工证怎么考 2026河南最新政策全解析 证书过期了不知道怎么复审延期?别慌,2026河南最新政策已经出台,针对特种作业操作证的复审流程、报考条件、考试内容等都有明确说明。尤其是像 悦雅国际电工证…

2026/11/7 11:43:50

初级电工证怎么考流程 职场逆袭关键在含金量

初级电工证怎么考流程 职场逆袭关键在含金量 学历不高怕报不上名,这是很多想进工地、进厂的工友心里最大的顾虑。其实,初级电工证的报考门槛并没有你想象中那么高,关键是要弄清楚流程,知道哪里容易踩坑。本文从安阳一家中小施工企业的实际案例出发,带你…

2026/11/7 11:43:50

日本怎么考电工证补贴网上查询攻略,转岗电工别走弯路

日本怎么考电工证补贴网上查询攻略,转岗电工别走弯路 你是不是正愁手里没证,连面试机会都少?特种作业证是很多行业的“硬门槛”,尤其是电工证,不仅关系到能否上岗,还直接影响你的薪资水平。现在网上查询越来越方便,但很多人对“日本怎么考电工证补贴”…

2026/11/7 11:38:50

低压电工证更改归属地怎么报名?上岗找工作必看新规

低压电工证更改归属地怎么报名?上岗找工作必看新规 你是不是正在为找不到工作发愁?别急,可能是你手里的低压电工证出了问题。现在低压电工证的 归属地更改 政策一出,很多人都懵了,不知道 怎么报名…

2026/11/5 15:39:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/6 10:49:01

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/7 0:03:08

郑州报考避坑指南:电工证和钳工证怎么选?实操考试心里没底怕挂科

郑州报考避坑指南:电工证和钳工证怎么选?实操考试心里没底怕挂科 实操考试心里没底怕挂科?你不是一个人。特别是在郑州这样的城市,很多施工企业负责人和一线作业人员在报考电工证和钳工证时都面临着同样的困惑。到底该考哪个证?哪个更适合自己?哪个证书…

2026/11/7 0:03:08

广西电工证办理流程全解析:外省证怎么转?在哪考最靠谱?

广西电工证办理流程全解析:外省证怎么转?在哪考最靠谱? 之前考的证在外省能不能转过来?这个问题我遇到太多了,今天就拿广西电工证办理流程来具体说说,别再走弯路了。广西电工证的报考、复审、继续教育这些环节,如果你不了解,真的会吃大亏,尤其对于从…

2026/11/7 0:03:08

电气电工证都有哪些保姆级教程

电气电工证都有哪些保姆级教程 你是不是正愁不知道去哪报名,怕被中介坑?别急,这篇保姆级教程专为河南初次报考人员准备,帮你搞清楚 电气电工证都有哪些 ,以及如何避开那些坑。 电气电工证都有哪些?别被五花八门的证书搞懵了…

2026/11/5 15:39:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/6 10:49:01

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑