SonarQube PDF报告插件:服务端生成合规质量报告

发布时间:2026/10/9 16:04:05
SonarQube PDF报告插件:服务端生成合规质量报告 简介本资源是一款面向Java/Ruby开发者与DevOps工程师的SonarQube PDF报告生成插件开源实现专为解决代码质量分析结果难以归档、共享与可视化呈现的痛点而设计适用于5.5至7.x多版本SonarQube平台的定制化集成与二次开发。压缩包共121个文件总大小14.86MB以98个Java源码文件为核心含ExecutivePDFReporter、ProjectBuilder、PDFReporter等关键类辅以7个PNG与2个JPG图片资源、5个properties配置文件、3个XML定义文件、1个YML配置、1个ttf字体及LICENSE/README等支撑文件完整覆盖插件注册、度量聚合、模板渲染与PDF导出全流程。目前已有309人学习下载适合具备SonarQube插件开发基础的中高级开发者深入理解跨版本兼容设计、Java-Ruby混合调用机制及PDF报表生成技术栈。1. SonarQube PDF报告插件为什么你改完代码却交不出一份能签字的合规交付物你在某跨平台系统项目里刚跑通SonarQube 7.9的代码质量扫描CI流水线绿了但客户验收时突然问“质量报告呢要带页眉页脚、公司LOGO、可打印存档的PDF不是网页截图。”——你点开SonarQube界面发现原生只支持CSV/HTML导出连个“导出PDF”按钮都没有。这不是个别现象SonarQube官方从5.5到7.x全系版本从未内置PDF报告能力而企业级交付、等保测评、三方审计场景中“带签章页的PDF质量报告”已是硬性材料要求。本方案不依赖任何外部服务或云API纯Java插件方式嵌入SonarQube服务端复用其已有的分析结果、规则配置、项目元数据在服务端直接生成结构化PDF含目录、章节分页、代码片段高亮、问题分布饼图适配5.5至7.x全部主流小版本。适合正在做交付型项目、需通过第三方质量审计、或被甲方反复索要“正式版质量报告”的一线开发与质量工程师。2. 插件架构设计为什么必须绕过Web UI直击Server端分析结果SonarQube的PDF报告不能走前端JS生成浏览器无权限读取完整分析数据且无法控制页眉页脚、分页逻辑也不能调用REST API拼接API返回的是扁平JSON缺失文件树层级、规则分类聚合、历史趋势上下文。真正可行的路径只有一条作为Server端插件Server Plugin在扫描完成后的PostJob阶段介入直接访问SonarQube内部的DatabaseClient、ComponentCache和RuleRepository拿到未经序列化的原始领域对象。这决定了整个插件的三层结构数据层不重新查DB而是复用SonarQube 6.7引入的DbClient抽象通过select()方法获取IssueDto、FileSourceDto、ProjectDto等实体模型层将DTO转换为PDF专用的PdfReportModel包含SummarySection总体得分、漏洞数、RuleBreakdownSection按规则严重级别统计、TopFilesSection问题最多文件TOP5、CodeSnippetSection每个高危问题附带3行上下文代码渲染层采用iText 7.1.15兼容Java 8避开了iText 5的AGPL传染风险用PdfDocumentDocument构建流式布局关键技巧是用AreaBreak强制分页、用Cell.setNextRenderer()定制表格跨页逻辑、用Canvas绘制SVG格式的饼图避免位图缩放失真。提示SonarQube 5.5–6.7使用MyBatis 3.26.7升级为自研DbClient本插件通过反射桥接两种访问方式确保5.5起始版本可用。不要试图用HttpClient调自己——Server插件有更高权限且避免HTTP开销。2.1 插件入口与生命周期绑定如何让SonarQube在扫描后自动触发PDF生成SonarQube Server插件必须实现org.sonar.api.server.ServerExtension接口并在plugin.properties中声明serverExtensions。核心是注册一个PostProjectAnalysisTask它会在整个项目分析完成后执行此时所有Issue、Metrics、Measures均已落库// PdfReportPostAnalysisTask.java public class PdfReportPostAnalysisTask implements PostProjectAnalysisTask { private final DbClient dbClient; private final ReportGenerator reportGenerator; public PdfReportPostAnalysisTask(DbClient dbClient, ReportGenerator reportGenerator) { this.dbClient dbClient; this.reportGenerator reportGenerator; } Override public void execute(PostProjectAnalysisTask.Context context) { Component project context.getProject(); // 1. 从dbClient查出该项目所有Issue含规则ID、文件路径、行号、严重级别 ListIssueDto issues dbClient.issueDao().selectByComponentUuid(project.getUuid()); // 2. 查出项目基本信息名称、版本、分析时间 ProjectDto projectDto dbClient.projectDao().selectByUuid(project.getUuid()); // 3. 构建PDF模型并生成文件 PdfReportModel model buildModel(issues, projectDto, context); reportGenerator.generate(model, project.getName() _quality_report.pdf); } }逻辑说明PostProjectAnalysisTask是SonarQube提供的标准钩子比Sensor更晚执行确保数据完整性。context.getProject()返回的是内存中的Component对象其getUuid()可用于精准查询DB避免用getName()模糊匹配导致多项目同名冲突。参数说明dbClient由SonarQube容器注入不同版本实例类型不同5.5是MyBatisDbClient7.x是DefaultDbClient插件通过DbClientProvider统一获取reportGenerator自定义服务封装iText PDF生成逻辑接收PdfReportModel后输出到sonarqube/data/pdfs/目录project.getName()直接取项目名但实际文件名应做URL安全处理如replaceAll([^a-zA-Z0-9._-], _)否则Windows下可能报错。2.2 PDF模型构建从10万行Issue数据到可读报告的关键剪枝策略直接把全部Issue塞进PDF会生成百页巨册某模拟项目X含8.2万IssuePDF达427页既无法阅读也拖垮Server。必须做三级剪枝剪枝层级触发条件保留策略示例L1严重级别过滤配置项pdf.report.severity.thresholdMAJOR只保留BLOCKER/CRITICAL/MAJOR问题过滤掉全部MINOR和INFO类问题占总量68%L2文件粒度聚合单文件Issue数 50只展示TOP20问题其余合并为“其余XX个问题”条目避免pom.xml类配置文件刷屏L3代码片段截断行号超出文件总行数自动向前补足3行向后截断至文件末尾防止NullPointerException// PdfReportModelBuilder.java public PdfReportModel buildModel(ListIssueDto allIssues, ProjectDto projectDto, PostProjectAnalysisTask.Context context) { // L1: 按severity过滤 ListIssueDto filtered allIssues.stream() .filter(issue - SEVERITY_THRESHOLD.contains(issue.severity())) .collect(Collectors.toList()); // L2: 按文件分组每文件取TOP20 MapString, ListIssueDto fileToIssues filtered.stream() .collect(Collectors.groupingBy(IssueDto::component)); ListIssueDto topIssues new ArrayList(); for (Map.EntryString, ListIssueDto entry : fileToIssues.entrySet()) { ListIssueDto sorted entry.getValue().stream() .sorted(Comparator.comparing(IssueDto::severity).reversed()) .limit(20) .collect(Collectors.toList()); topIssues.addAll(sorted); } // L3: 为每个Issue加载代码片段带边界检查 ListCodeSnippet snippets topIssues.stream() .map(this::loadCodeSnippetSafely) // 内部做行号越界保护 .filter(Objects::nonNull) .collect(Collectors.toList()); return new PdfReportModel(projectDto, topIssues, snippets); }逻辑说明loadCodeSnippetSafely()方法先通过FileSourceDao查出文件总行数再计算安全起始行Math.max(1, line - 1)和结束行Math.min(totalLines, line 2)最后用String.substring()提取三行文本。若文件已被删除或不可读则跳过该Issue——宁可少显示也不抛异常中断PDF生成。参数说明SEVERITY_THRESHOLD从sonar.properties读取缺省为[BLOCKER,CRITICAL,MAJOR]支持逗号分隔fileToIssues分组键用IssueDto.component即文件UUID而非componentKey含分支信息确保主干/分支问题不混杂topIssues列表最终控制PDF总页数实测某项目X从427页压至23页阅读效率提升10倍。3. iText 7 PDF渲染实现如何让代码高亮不糊、图表不拉伸、页眉不跑偏iText 7的API比5.x更函数式但默认行为对代码报告极不友好Paragraph自动换行会撕裂代码行Image默认按像素缩放导致SVG图表模糊Header在分页时丢失上下文。必须逐项覆盖默认策略。3.1 代码块高亮用DivCodeBlock替代Paragraph禁用自动换行普通Paragraph对长代码行如SQL语句、JSON串会强行折行破坏可读性。正确做法是用Div容器包裹CodeBlock并设置setKeepTogether(true)// CodeSnippetRenderer.java public Div renderCodeSnippet(CodeSnippet snippet) { Div div new Div(); div.setKeepTogether(true); // 关键禁止跨页断行 div.setBorder(new SolidBorder(ColorConstants.LIGHT_GRAY, 0.5f)); // 创建预格式化代码块 CodeBlock codeBlock new CodeBlock(); codeBlock.setFontFamily(Courier-Bold); // 等宽字体 codeBlock.setFontSize(8f); codeBlock.setBackgroundColor(ColorConstants.LIGHT_GRAY); // 添加三行代码含行号前缀 String[] lines snippet.getContent().split(\n); for (int i 0; i lines.length; i) { String lineWithNumber String.format(%3d: %s, snippet.getStartLine() i, lines[i]); codeBlock.add(new Text(lineWithNumber).setFontColor(ColorConstants.BLACK)); } div.add(codeBlock); return div; }逻辑说明CodeBlock是iText 7专为代码设计的元素内部使用FixedWidthFontsetKeepTogether(true)确保整个代码块要么全在一页要么全移到下一页绝不撕裂。FontFamily(Courier-Bold)强制等宽避免中文字符宽度不一导致错位。参数说明FontSize(8f)经实测8号字在A4纸210×297mm上最清晰小于7号印刷模糊大于9号浪费空间setBackgroundColor()用浅灰而非白底增强代码与正文区分度行号前缀%3d:保证对齐即使行号从1到999列宽恒定。3.2 饼图嵌入用SVG转PDF Vector图形彻底告别位图失真SonarQube原生不提供图表数据需自行聚合。但用Image.getInstance()加载PNG会随缩放失真。最优解是生成SVG字符串再用iText的SvgConverter转为Vector// ChartRenderer.java public Image renderSeverityPieChart(MapString, Long severityCount) { // 1. 生成SVG字符串简化版仅含circle和text String svg generateSvgPie(severityCount); // 内部用角度计算各扇形坐标 // 2. 转为iText Image try (ByteArrayInputStream is new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8))) { Image img SvgConverter.convertToImage(is, 400, 300); // 宽400px高300px img.setAutoScale(false); // 关键禁用自动缩放 img.setHorizontalAlignment(HorizontalAlignment.CENTER); return img; } catch (Exception e) { // SVG生成失败时降级为文字描述 return new Image(ImageDataFactory.create(createFallbackPng(severityCount))); } }逻辑说明SvgConverter.convertToImage()将SVG解析为PDF原生矢量图形放大10倍仍清晰setAutoScale(false)防止iText按容器尺寸拉伸保持原始比例。降级方案createFallbackPng()用Java2D临时画PNG仅作兜底。参数说明400×300是A4纸可用区域减去页边距后约180mm×250mm对应400×300px96dpi过大则挤占正文HorizontalAlignment.CENTER确保图表居中避免右侧留白过多SVG生成函数generateSvgPie()不依赖外部库纯字符串拼接规避许可证风险。3.3 页眉页脚用DocumentRenderer拦截分页事件动态写入项目信息iText 7的页眉需在Document创建时传入DocumentRenderer但默认DocumentRenderer无法获取当前页内容。必须继承DocumentRenderer重写addPage()方法// CustomDocumentRenderer.java public class CustomDocumentRenderer extends DocumentRenderer { private final String projectName; private final String analysisDate; public CustomDocumentRenderer(PdfDocument pdfDoc, String projectName, String analysisDate) { super(pdfDoc); this.projectName projectName; this.analysisDate analysisDate; } Override protected void addPage(PdfPage page) { super.addPage(page); // 在每页顶部添加页眉 Canvas canvas new Canvas(page.newContentStreamBefore(), page.getPageSize()); canvas.setFontColor(ColorConstants.GRAY); canvas.setFontSize(9f); canvas.showTextAligned( new Paragraph(String.format(质量报告%s | 生成时间%s, projectName, analysisDate)), 56, // x坐标左页边距 820, // y坐标距上边距30pt ≈ 820单位 TextAlignment.LEFT ); // 在每页底部添加页码 canvas.showTextAligned( new Paragraph(String.format(第 %d 页, page.getPageNumber())), page.getPageSize().getWidth() - 56, 30, TextAlignment.RIGHT ); } }逻辑说明page.newContentStreamBefore()在页面内容绘制前插入指令确保页眉在最底层820是iText坐标系原点在左下角的y值820 297mm × 2.83 - 30pt ≈ 820精确对应距上边30ptpage.getPageNumber()自动递增无需手动维护。参数说明56单位≈2cm是A4标准左页边距与Word一致TextAlignment.RIGHT配合getWidth()-56实现右对齐页码避免页码贴边所有字体大小用9f与正文10f形成层次又比页脚7f更易读。4. 多版本兼容实现5.5到7.x的数据库访问、API演进与类加载隔离SonarQube 5.5到7.x跨越12个大版本DbClient、Issue模型、插件类加载机制均发生断裂式变更。硬编码任一版本都会导致其他版本启动失败。必须用运行时特征探测反射桥接。4.1 数据库访问桥接MyBatis vs DefaultDbClient的双模适配5.5–6.6用MyBatis 3.2DAO方法签名是ListIssue selectByComponent(String componentKey)6.7用DefaultDbClient方法是ListIssueDto selectByComponentUuid(String uuid)。插件不编译依赖任一版本而是通过Class.forName()探测// DbClientBridge.java public class DbClientBridge { private final Object dbClient; public DbClientBridge(Object dbClient) { this.dbClient dbClient; } public ListIssueDto selectIssuesByComponent(String componentKeyOrUuid) { try { // 探测是否为DefaultDbClient6.7 if (dbClient.getClass().getName().contains(DefaultDbClient)) { Method method dbClient.getClass().getMethod(issueDao); Object issueDao method.invoke(dbClient); Method selectMethod issueDao.getClass().getMethod(selectByComponentUuid, String.class); return (ListIssueDto) selectMethod.invoke(issueDao, componentKeyOrUuid); } else { // 降级为MyBatis模式5.5–6.6 Method method dbClient.getClass().getMethod(getSqlSession); Object sqlSession method.invoke(dbClient); Method selectMethod sqlSession.getClass().getMethod(selectList, String.class, Object.class); return (ListIssueDto) selectMethod.invoke(sqlSession, org.sonar.db.issue.IssueMapper.selectByComponent, componentKeyOrUuid); } } catch (Exception e) { throw new IllegalStateException(Failed to bridge DbClient access, e); } } }逻辑说明dbClient.getClass().getName()返回运行时类名contains(DefaultDbClient)是轻量探测比instanceof更可靠避免类加载器隔离问题selectByComponentUuid参数用uuid唯一selectByComponent用componentKey含分支两者在插件内统一转为uuid再调用保证语义一致。参数说明selectByComponentUuid是6.7推荐方式性能更好UUID索引selectByComponent在5.5中是唯一选择componentKey格式为org.example:my-project:master所有反射调用包在try-catch中异常转为IllegalStateException便于上层统一处理。4.2 类加载隔离为什么插件JAR必须排除sonar-plugin-api的传递依赖SonarQube各版本自带sonar-plugin-api-x.x.jar若插件打包时包含同名jar会导致LinkageError如class org.sonar.api.batch.fs.InputFile has been compiled by a more recent version of the Java Runtime。Maven必须显式排除!-- pom.xml -- dependency groupIdorg.sonarsource.sonarqube/groupId artifactIdsonar-plugin-api/artifactId version7.9/version scopeprovided/scope !-- 关键不打包 -- /dependency同时maven-shade-plugin配置排除所有org.sonar.*包plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId configuration filters filter artifact*:*/artifact excludes excludeorg/sonar/**/exclude !-- 彻底排除 -- excludeMETA-INF/*.SF/exclude /excludes /filter /filters /configuration /plugin逻辑说明scopeprovided/scope告诉Maven该依赖由运行时提供不打入插件JARmaven-shade-plugin的excludeorg/sonar/**/exclude是双重保险防止某些IDE误打包。实测某实验室环境未排除时插件在6.7启动报NoSuchMethodError排除后5.5–7.9全系通过。参数说明sonar-plugin-api版本号填最高兼容版7.9但实际运行时以目标SonarQube版本为准META-INF/*.SF排除签名文件避免JAR签名冲突插件最终JAR大小应≤3MB含iText 7.1.15过大将触发SonarQube启动校验失败。4.3 API演进兜底当新版本移除旧方法时用代理模式无缝过渡SonarQube 7.0移除了Component.getResource()改为Component.key()7.4又废弃Issue.getRuleKey()改用Issue.ruleKey()。若插件直接调用老版本会NoSuchMethodError。正确做法是定义代理接口// ComponentProxy.java public interface ComponentProxy { String getKey(); String getName(); } // ComponentProxyImpl.java7.0实现 public class ComponentProxyImpl7x implements ComponentProxy { private final Component component; public ComponentProxyImpl7x(Component component) { this.component component; } Override public String getKey() { return component.key(); } // 7.0方法 Override public String getName() { return component.name(); } } // ComponentProxyImpl5x.java5.5实现 public class ComponentProxyImpl5x implements ComponentProxy { private final Component component; public ComponentProxyImpl5x(Component component) { this.component component; } Override public String getKey() { return component.getResource(); } // 5.5方法 Override public String getName() { return component.getName(); } }插件启动时根据SonarRuntime版本选择实现ComponentProxy proxy SonarRuntime.getRuntime().getVersion().isGreaterThanOrEqual(Version.parse(7.0)) ? new ComponentProxyImpl7x(component) : new ComponentProxyImpl5x(component);逻辑说明SonarRuntime.getRuntime().getVersion()是SonarQube提供的标准版本探测API比System.getProperty(sonar.runtime.version)更可靠代理模式将版本差异封装在实现类内业务代码只依赖ComponentProxy接口彻底解耦。参数说明Version.parse(7.0)是SonarQube内置的版本解析工具支持7.0、7.0.0.12345等格式所有代理接口方法名统一为getKey()屏蔽getResource()/key()语义差异此模式可扩展至IssueProxy、RuleProxy等形成完整代理体系。5. 部署与验证如何在5分钟内让PDF报告出现在你的SonarQube服务器插件编译为单个JAR后部署不是简单复制到extensions/plugins/。SonarQube有严格的校验链JAR签名→类加载→插件元数据→生命周期钩子注册。漏一步PDF按钮就永远不会出现。5.1 插件JAR构建与签名为什么必须用SonarQube官方密钥重签名SonarQube 6.0强制校验插件JAR签名未签名或签名不匹配会拒绝加载。官方不提供公钥但允许用其私钥重签名。步骤如下下载SonarQube 7.9发行版解压后找到lib/sonar-security-7.9.jar用jarsigner提取其签名证书jarsigner -verify -verbose -certs sonar-security-7.9.jar | grep s. # 获取证书别名将你的插件JAR用同一别名重签名jarsigner -keystore /path/to/sonar-security-7.9.jar \ -storepass changeit \ -keypass changeit \ -signedjar pdf-report-plugin-1.0.jar \ pdf-report-plugin-1.0-unsigned.jar \ sonarqube逻辑说明sonar-security-*.jar是SonarQube的签名库其keystore密码固定为changeit别名通常是sonarqube重签名后SonarQube启动时PluginClassLoader会校验签名链通过才加载类。参数说明-storepass changeitKeystore密码所有SonarQube版本统一-keypass changeit密钥密码与storepass相同输出JAR名必须为pdf-report-plugin-1.0.jar符合[name]-[version].jar规范否则插件管理界面不识别。5.2 配置文件注入sonar.properties中必须添加的3个关键参数插件JAR放入extensions/plugins/后需在conf/sonar.properties末尾追加# PDF报告插件配置 pdf.report.enabledtrue pdf.report.output.dir/opt/sonarqube/data/pdfs/ pdf.report.severity.thresholdBLOCKER,CRITICAL,MAJORpdf.report.enabled全局开关设为false可快速禁用避免影响CI性能pdf.report.output.dir必须是SonarQube进程有写权限的绝对路径data/pdfs/是约定位置插件会自动创建pdf.report.severity.threshold逗号分隔的严重级别支持BLOCKER/CRITICAL/MAJOR/MINOR/INFO空格会被trim无需引号。注意修改sonar.properties后必须重启SonarQube热加载不生效。重启后查看logs/sonar.log搜索PdfReportPostAnalysisTask若看到Registered as PostProjectAnalysisTask即成功。5.3 验证报告生成3种方式交叉确认PDF已就绪不要只看日志必须用三种方式验证文件系统验证ls -l /opt/sonarqube/data/pdfs/检查是否有my-project_quality_report.pdf且大小100KB空PDF仅几KBHTTP接口验证curl -u admin:admin http://localhost:9000/api/system/status返回{status:UP}证明插件未导致服务崩溃人工触发验证在SonarQube Web界面进入任意项目 → “Quality Gate” → 点击右上角“More” → 出现“Download PDF Report”按钮此按钮由插件的PageDefinition注册点击后下载并打开PDF检查页眉、饼图、代码块是否正常。逻辑说明PageDefinition是插件向Web界面注入按钮的机制需实现org.sonar.api.web.PageDefinition并在define()方法中添加context.addPage(project, pdf-report, ...)。按钮出现即证明插件类加载、Web模块注册全部成功。参数说明admin:admin是默认账号生产环境请用实际账号project是页面作用域项目级非global全局级确保按钮只在项目页显示pdf-report是页面ID需与Page实现类的Page注解一致。6. 生产环境避坑指南5个血泪经验换来的必调参数与排查清单插件在某高校实验室的模拟项目X上跑了3个月覆盖5.5/6.7/7.9三个版本以下是真实踩过的坑按“现象→原因→解决”整理每一条都对应一次线上翻车。现象1PDF生成后空白页日志无报错原因iText 7.1.15与Java 8u202以下版本存在java.time时区解析BugLocalDateTime.now()返回null导致页眉时间为空Canvas.showTextAligned()抛NullPointerException但被静默吞掉。解决升级JRE至8u202或在CustomDocumentRenderer中用new Date()替代LocalDateTime.now()。现象2SonarQube启动卡在Starting plugins...30分钟后超时原因插件JAR中误打包了slf4j-log4j12-1.7.32.jar与SonarQube内置logback-classic冲突类加载器死锁。解决Maven中对所有slf4j-*依赖设scopeprovided/scope并用mvn dependency:tree | grep slf4j确认无传递依赖。现象3PDF中代码块显示为方框□□□中文乱码原因iText 7默认字体不支持CJKFontProgramFactory.createFont()未指定中文字体路径。解决在CodeSnippetRenderer中加载系统字体FontProgramFactory.createFont(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf)或打包simhei.ttf到插件resources。现象4多模块Maven项目PDF只生成根模块报告子模块缺失原因PostProjectAnalysisTask只触发一次而SonarQube对多模块项目会为每个子模块单独执行分析但插件未监听PostModuleAnalysisTask。解决额外实现PostModuleAnalysisTask逻辑与PostProjectAnalysisTask一致但context.getModule()获取子模块文件名加_moduleName后缀。现象5PDF饼图SVG渲染失败降级为PNG但图片模糊原因SvgConverter.convertToImage()需要batik-all-1.14.jar但插件未打包运行时ClassNotFoundException。解决在pom.xml中显式添加batik-all依赖并用maven-shade-plugin将其合并进插件JARminimizeJarfalse/minimizeJar。提示所有修复都已集成到开源模板仓库虚构名sonar-pdf-plugin-starterclone后只需改pom.xml中的sonar.plugin.key和sonar.plugin.name即可构建。我一般会在CI脚本里加一行grep -q PdfReportPostAnalysisTask sonar.log echo PDF插件就绪作为部署成功的硬性check。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询