news 2026/7/9 12:35:12

Postman中实现国密SM2签名验签的完整实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Postman中实现国密SM2签名验签的完整实践指南

1. 项目概述:当Postman遇上国密SM2

如果你正在对接一个要求使用国密SM2算法进行API签名验签的后端服务,而你的调试工具是Postman,那么这篇文章就是为你准备的。我最近刚完成一个金融项目的接口联调,对方的后端系统强制要求所有请求都必须使用SM2算法进行签名,验签失败直接返回400。在Postman里折腾了大半天,从环境配置、密钥生成到签名脚本编写,踩了不少坑,也总结出了一套完整的、可复现的流程。这篇文章,我就把从零开始,在Postman中实现SM2签名,并成功通过后端验签的全过程拆解给你看,附上每一步的代码和避坑要点。

SM2作为国家密码管理局发布的椭圆曲线公钥密码算法标准,在政务、金融、物联网等领域应用越来越广。但和常见的RSA、HMAC不同,SM2在Postman这类通用API工具中没有原生支持,需要我们手动实现签名逻辑。这不仅仅是调用一个加密函数那么简单,它涉及到密钥对的处理、签名格式的规范、请求体的组装顺序等一系列细节。一个参数顺序不对,或者编码格式有误,都会导致验签失败。接下来,我会带你一步步走通这个流程,让你下次遇到类似需求时,能快速上手,避免我走过的弯路。

2. 核心思路与准备工作

2.1 为什么要在Postman中处理SM2?

在API开发与联调中,Postman几乎是前端、后端、测试工程师的标配。当后端服务采用国密SM2作为安全认证方案时,前端或调用方在调试阶段就会面临一个现实问题:如何在不修改业务代码的情况下,快速构造出携带正确SM2签名的请求进行测试?手动计算签名再粘贴到Header里显然不现实,尤其是请求参数动态变化时。因此,在Postman的Pre-request Script中集成SM2签名逻辑,就成了最高效的方案。它允许我们在请求发出前,动态地根据当前请求参数计算签名,并自动添加到请求头中,模拟真实客户端的行为。

2.2 技术选型与工具准备

实现这个目标,我们需要几个核心组件:

  1. SM2算法库:这是核心。我们需要一个能在Postman的JavaScript(实际是Node.js沙箱环境)中运行的SM2库。经过对比,我选择了sm-crypto这个库。它纯JavaScript实现,不依赖原生模块,兼容性好,并且同时支持SM2、SM3、SM4等国密算法,功能齐全。
  2. Postman环境变量:用于安全地存储你的SM2私钥。绝对不要把私钥硬编码在脚本里。我们将私钥存入Postman的环境变量或全局变量中,脚本运行时从中读取。
  3. 密钥对:你需要一对有效的SM2公钥和私钥。通常由后端提供,或者使用工具生成。这里我们会用sm-crypto演示如何生成。

首先,我们需要获取sm-crypto库。由于Postman的脚本环境无法直接使用npm install,我们需要获取其浏览器版本或UMD版本的JS文件内容。

实操步骤:获取并引入sm-crypto

  1. 访问sm-crypto的GitHub仓库或通过CDN获取其UMD打包文件。
  2. 我们将使用其最简方式:直接获取整个库的压缩代码,并封装成一个Postman可用的函数模块。这里我提供一个经过验证的、可在Postman中直接运行的sm2对象构造方法。你需要将下面这段较长的代码保存为Postman的全局脚本(Globals)中的一个变量值,例如命名为sm2Library

注意:以下是一段浓缩的、功能完整的sm-cryptov0.3.2 的核心代码。由于篇幅很长,在实际操作中,我建议你直接从其官方仓库复制完整的sm2.js文件内容,或者使用我提供的经过精简和测试的代码片段。为了文章清晰,我在这里展示关键的函数封装思路。

// 这是一个示意结构,实际你需要导入完整的sm-crypto库代码。 // 假设你已经将完整的sm-crypto库代码赋值给了环境变量 `sm2_library_code` const sm2Code = pm.globals.get('sm2_library_code'); eval(sm2Code); // 执行这段代码,将在当前上下文中生成 `sm2` 对象 // 现在可以使用 sm2 对象了,例如:sm2.generateKeyPairHex()

更稳妥的做法是,利用Postman的require功能(部分版本支持)或直接使用crypto-js结合自定义椭圆曲线逻辑,但后者极其复杂。因此,我推荐的实际操作是:找一个在线的、包含sm-crypto的HTML测试页面,通过浏览器开发者工具的控制台,将sm2对象的实现代码提取出来,保存到Postman的一个全局变量中。这是初期最耗时但一劳永逸的一步。

密钥生成与保管一旦引入了库,就可以生成密钥对。在Pre-request Script中临时运行以下代码生成(仅用于测试,正式环境使用后端提供的密钥):

// 假设sm2对象已可用 const keypair = sm2.generateKeyPairHex(); console.log('公钥:', keypair.publicKey); // 04开头,130位十六进制字符串 console.log('私钥:', keypair.privateKey); // 64位十六进制字符串 pm.environment.set('SM2_PRIVATE_KEY', keypair.privateKey); // 私钥存环境变量 // 将公钥 publicKey 发送给后端配置

生成后,立即将私钥从控制台复制出来,妥善保存到Postman的环境变量中,并清除脚本中的生成代码。公钥交给后端部署。环境变量设置路径:Postman右上角眼睛图标 -> Environment -> Edit -> 添加变量SM2_PRIVATE_KEY

3. 签名验签流程全解析

3.1 SM2签名原理与Postman适配要点

SM2的签名算法(SM2-with-SM3)大致流程是:先使用SM3杂凑算法计算待签名消息的摘要,然后利用椭圆曲线数字签名算法(ECDSA)和私钥对这个摘要进行签名,产生两个大整数(r, s)。最后需要将(r, s)序列化为特定的格式(如ASN.1 DER或纯十六进制拼接)。

在API验签场景中,后端通常会有明确的要求,主要包括三点:

  1. 待签名字符串的组装格式:也就是“签什么”。常见格式有:将所有请求参数(不包括sign本身)按参数名ASCII码升序排序后,以key=value的形式用&连接(类似HTTP查询字符串)。也可能包含HTTP方法、请求路径、时间戳等。
  2. 签名结果的输出格式:也就是“签出来是什么样”。可能是Base64编码的ASN.1 DER序列,也可能是64字节的固定长度十六进制字符串(r和s各32字节)。
  3. 签名放置的位置:通常在HTTP请求头(如X-Signature)或查询参数(sign)中。

你必须从后端接口文档中明确这三项要求。本文以最常见的“按参数名排序后key=value&连接,签名输出为Base64(DER),放在HeaderX-Sign中”为例进行演示。

3.2 构建动态签名Pre-request Script

这是最核心的部分。我们将编写一个Pre-request Script,它会在每次请求发送前自动执行,完成签名计算并设置请求头。

假设我们的接口POST /api/v1/transaction需要以下参数(JSON body):

{ "merchantId": "100001", "orderId": "ORDER_20231027001", "amount": "100.00", "timestamp": 1698394665000 }

签名要求:对JSON请求体整体字符串(需按字母序排序并紧凑格式)进行SM2签名。

完整脚本如下(请仔细阅读注释):

// 1. 确保sm2库可用 - 假设已按前述方法将库代码存入环境变量 `SM2_LIB` try { const sm2LibCode = pm.globals.get('SM2_LIB'); if (sm2LibCode && typeof eval === 'function') { eval(sm2LibCode); // 执行库代码,初始化 sm2 对象 } else { throw new Error('SM2库未正确加载。请检查全局变量 SM2_LIB。'); } } catch (e) { console.error('加载SM2库失败:', e); // 可以在这里提供一个备用的、极简的sm2实现(仅签名),用于应急 } // 2. 从环境变量获取私钥(!!! 关键安全步骤 !!!) const privateKey = pm.environment.get('SM2_PRIVATE_KEY'); if (!privateKey) { console.error('未找到环境变量 SM2_PRIVATE_KEY,请先配置你的SM2私钥。'); // 可以选择让请求失败 // pm.request.headers.add({key: 'X-Sign-Error', value: 'Missing Private Key'}); } // 3. 构造待签名字符串 // 获取请求方法、URL路径(根据需要) const method = request.method; const url = new URL(request.url); const path = url.pathname + url.search; // 根据后端要求,可能只取pathname // 获取请求体。注意:Postman的request对象在Pre-request中可能不可直接访问body。 // 更可靠的方式是使用pm.request.body。 let bodyRaw = ''; const body = pm.request.body; if (body && body.mode === 'raw' && body.raw) { bodyRaw = body.raw; console.log('原始请求体:', bodyRaw); } else if (body && body.mode === 'formdata' || body.mode === 'urlencoded') { // 如果是form-data或x-www-form-urlencoded,需要将其转换为排序后的字符串 // 这里以urlencoded为例: const formData = body.urlencoded; const params = {}; formData.members.forEach(item => { params[item.key] = item.value; }); // 按key排序 const sortedKeys = Object.keys(params).sort(); bodyRaw = sortedKeys.map(key => `${key}=${params[key]}`).join('&'); console.log('构造的待签名串(form):', bodyRaw); } else { // 其他情况,如binary,可能需要特殊处理,通常不对二进制数据直接签名,而是签其元数据 console.warn('请求体模式不支持或为空,将使用空字符串签名。'); } // 4. 按后端规则组装最终待签名字符串 // 假设规则是:`method|path|sortedBody`,具体规则务必与后端一致! let messageToSign = `${method.toUpperCase()}|${path}|${bodyRaw}`; // 或者如果只签body: messageToSign = bodyRaw; console.log('最终待签名字符串:', messageToSign); // 5. 计算SM3摘要(sm-crypto的doSignature内部已包含SM3,但明确计算有助于调试) // const msgHash = sm2.doHash(messageToSign, 'hex'); // 如果需要查看中间hash // console.log('SM3 Hash:', msgHash); // 6. 执行SM2签名 // sm2.doSignature(msgHash, privateKey) 也可以,但推荐直接使用doSignature处理原文 try { // 使用 doSignature 直接签名原文,库内部会先计算SM3 hash // 第二个参数是私钥,第三个参数用于指定输出格式(默认就是der+hex,再转16进制) const signResult = sm2.doSignature(messageToSign, privateKey, { der: true, // 输出为ASN.1 DER序列 hash: true, // 输入为字符串,库内部先进行SM3哈希。如果输入已经是哈希值,则设为false。 }); console.log('签名结果(16进制):', signResult); // 7. 将签名结果转换为Base64(常见要求) // 注意:signResult已经是16进制字符串,我们需要将其先转换为字节数组 function hexToBase64(hexString) { // 将16进制字符串转换为字节数组 const byteArray = []; for (let i = 0; i < hexString.length; i += 2) { byteArray.push(parseInt(hexString.substr(i, 2), 16)); } // 使用Postman内置的btoa,或polyfill const base64 = btoa(String.fromCharCode.apply(null, byteArray)); return base64.replace(/=+$/, ''); // 可选:移除尾部等号 } const signatureBase64 = hexToBase64(signResult); console.log('签名结果(Base64):', signatureBase64); // 8. 将签名添加到请求头 pm.request.headers.add({ key: 'X-Sign', value: signatureBase64 }); // 也可以添加时间戳等 const timestamp = Date.now(); pm.request.headers.add({ key: 'X-Timestamp', value: timestamp.toString() }); pm.environment.set('last_signature', signatureBase64); // 可选:存到环境变量供查看 } catch (signError) { console.error('SM2签名过程出错:', signError); pm.request.headers.add({ key: 'X-Sign-Error', value: 'Sign Failed: ' + signError.message }); }

关键点解析与避坑指南:

  1. eval的安全性与替代方案:在Pre-request Script中使用eval加载外部库代码存在一定风险,但在这个封闭的测试环境中是可接受的。如果你非常介意,可以手动将sm-crypto库中用到的最小函数集提取出来,写成一个独立的、无依赖的签名函数,但这需要较强的密码学知识。
  2. 请求体的获取时机pm.request.body在Pre-request Script中是可读的,你可以获取到即将发送的body内容。这一点非常重要,确保了签名是基于最终请求体计算的。
  3. 字符串格式与编码:这是最容易出错的地方。务必确保messageToSign的字符串与后端验签方完全一致,包括:
    • 排序规则:按字母序升序(ASCII),且区分大小写。
    • 键值对格式:是key=value还是key:value?连接符是&还是|
    • 空格与缩进:JSON字符串是否紧凑(无多余空格和换行)?Postman默认发送的JSON是格式化的(有空格),而后端解析时可能直接使用原始字符串(含空格)。强烈建议在签名前,对JSON字符串进行JSON.stringify(obj, null, 0)JSON.stringify(obj)处理,确保一致性。可以在脚本中添加bodyRaw = JSON.stringify(JSON.parse(bodyRaw));来标准化JSON。
    • 中文与特殊字符:是否需要URL编码?通常签名前不编码,但发送请求时body里的内容需要是标准JSON格式。
  4. 签名输出格式sm2.doSignatureder选项为true时,输出是ASN.1 DER编码的十六进制字符串。你需要确认后端期望的是这种格式的Base64,还是单纯的r||s拼接的64字节Hex的Base64。如果不匹配,验签必定失败。
  5. 时间戳与非重复性:签名中应包含时间戳(timestamp)和随机数(nonce),以防止重放攻击。这些参数也需要参与签名。

3.3 后端验签流程与联调技巧

后端验签流程通常是:

  1. 从请求头获取签名signature_base64
  2. 按照同样的规则,组装出待签名字符串messageToSign必须与前端逻辑一字不差)。
  3. 使用预先配置的SM2公钥,对signature_base64解码后得到的签名值,验证其对messageToSign的签名是否有效。

联调技巧:

  • 开启Console日志:在Postman的设置中开启“Console”(View -> Show Postman Console),这样所有console.log的输出都会显示在这里,方便你对比前端计算的messageToSignsignature
  • 先使用静态数据测试:先不写动态脚本,用后端提供的示例请求(包括所有参数和签名值)在Postman中手动发送,确认接口通。然后用脚本生成签名,对比你的签名值和示例的签名值是否一致。不一致时,将双方计算的messageToSign字符串打印出来,逐个字符比对(包括不可见字符)。
  • 利用环境变量做A/B测试:可以设置两套环境变量,一套用你的私钥,一套用后端提供的测试私钥,快速切换。
  • 错误处理:在脚本中加入充分的try-catch和错误日志,将错误信息甚至中间变量通过Header(如X-Debug-Message)发送给后端,后端可以在日志中查看,极大提升排查效率。

4. 完整实战案例:模拟支付接口调用

让我们通过一个更贴近真实的案例,将上述所有步骤串联起来。假设我们要调用一个“创建订单”的API。

4.1 接口定义

  • 端点POST https://api.example.com/v1/order/create
  • 请求头
    • Content-Type: application/json
    • X-App-Id: your_app_id
    • X-Sign: <sm2_signature_base64>
    • X-Timestamp: <timestamp_ms>
    • X-Nonce: <random_string>
  • 请求体(JSON)
    { "out_trade_no": "20231027123456", "total_amount": 1, "subject": "测试商品", "body": "商品描述" }
  • 签名规则
    1. 获取请求体JSON字符串,使用紧凑无空格格式(即JSON.stringify(obj))。
    2. 将以下字符串按顺序用换行符\n连接:X-App-Id的值X-Timestamp的值X-Nonce的值 请求方法(大写) 请求路径(/v1/order/create) 第1步得到的JSON字符串
    3. 对连接后的整体字符串进行SM2签名(使用SM3哈希),输出DER格式的十六进制,再转换为Base64。

4.2 Postman集合与脚本配置

  1. 创建环境变量:新建一个环境,如SM2_DEV,添加以下变量:

    • SM2_PRIVATE_KEY: 你的私钥(64位Hex)
    • APP_ID: 你的应用ID
    • API_BASE_URL:https://api.example.com
  2. 创建请求

    • Method: POST
    • URL:{{API_BASE_URL}}/v1/order/create
    • Headers: 添加Content-Type: application/jsonX-App-Id: {{APP_ID}}X-SignX-TimestampX-Nonce由脚本自动添加。
    • Body: raw, JSON,填入上述JSON内容。
  3. 编写Pre-request Script: 根据上述规则,编写脚本。核心是构造signString

    // 获取配置 const privateKey = pm.environment.get('SM2_PRIVATE_KEY'); const appId = pm.environment.get('APP_ID'); const timestamp = Date.now(); const nonce = Math.random().toString(36).substring(2, 15); // 简单生成随机字符串 // 设置时间戳和随机数头(签名计算需要它们) pm.request.headers.add({key: 'X-Timestamp', value: timestamp.toString()}); pm.request.headers.add({key: 'X-Nonce', value: nonce}); // 获取并标准化请求体 const body = pm.request.body.raw; let bodyStr = ''; if (body) { try { // 紧凑化JSON,确保与后端处理一致 const bodyObj = JSON.parse(body); bodyStr = JSON.stringify(bodyObj); } catch(e) { bodyStr = body; } } // 构造待签名字符串(严格按照规则) const method = pm.request.method.toUpperCase(); const url = new URL(pm.request.url.toString()); const path = url.pathname; // 注意:不包含查询参数,根据规则来 const signString = `${appId}\n${timestamp}\n${nonce}\n${method}\n${path}\n${bodyStr}`; console.log('待签名字符串:\n', signString); // 计算签名(假设sm2对象已加载) const signHex = sm2.doSignature(signString, privateKey, { der: true, hash: true }); // 转换为Base64 const signatureBase64 = btoa(signHex.match(/\w{2}/g).map(h => String.fromCharCode(parseInt(h, 16))).join('')); // 添加签名头 pm.request.headers.add({key: 'X-Sign', value: signatureBase64}); // 将一些信息存入环境变量,方便在Tests脚本中断言 pm.environment.set('last_sign_string', signString); pm.environment.set('last_nonce', nonce);
  4. 编写Tests脚本进行验证(可选但推荐): 请求发送后,我们可以写一些测试脚本来验证响应,或者检查签名相关环境变量。

    // 检查响应状态码 pm.test("Status code is 200", function () { pm.response.to.have.status(200); }); // 检查响应体包含成功标识 pm.test("Response has success flag", function () { const jsonData = pm.response.json(); pm.expect(jsonData.code).to.eql(0); }); // 打印本次使用的nonce,用于后端查日志 console.log("本次请求Nonce:", pm.environment.get('last_nonce'));

4.3 运行与调试点击Send按钮。首先在Postman Console中观察日志,确认signString的构建是否正确。然后查看请求头,确认X-Sign等头部已添加。如果后端返回签名错误(如400或特定的错误码),对比Console中打印的signString和后端日志中的signString是否完全一致。常见问题依然是空格、换行符、JSON格式化差异。

5. 常见问题排查与进阶优化

5.1 典型错误与解决方案

  1. 错误:Invalid signature验签失败

    • 原因99%:前后端待签名字符串不一致。
    • 排查
      • 逐字比对:让后端打印出他们用于验签的原始字符串(signString),与你Postman Console里打印的进行逐字符比对。推荐使用在线差异比较工具。
      • 检查编码:字符串中是否有中文?前后端编码是否一致(UTF-8)。
      • 检查空格与换行:JSON字符串里的空格、Tab、换行符是否完全一致?使用JSON.stringify(obj)而不要用JSON.stringify(obj, null, 2)
      • 检查参数顺序:如果签名包含多个参数,顺序是否与后端规定一致(例如按字典序)。
      • 检查包含的参数:是否漏掉了某些应该参与签名的参数(如timestamp,nonce)?或者多加了不该加的(如sign本身)。
  2. 错误:SM2 library not foundsm2 is not defined

    • 原因sm-crypto库代码未成功加载。
    • 解决
      • 检查全局变量SM2_LIB是否存在且内容完整。
      • 尝试在Pre-request Script最开头添加console.log(pm.globals.get('SM2_LIB').substring(0,100))看看是否成功读取。
      • 考虑使用另一种引入方式:将sm-crypto的CDN链接(如https://unpkg.com/sm-crypto@0.3.2/lib/index.js)的内容,通过“View Page Source”全部复制出来,存入变量。
  3. 错误:Invalid private key

    • 原因:私钥格式错误或损坏。
    • 解决
      • 确认私钥是64位的十六进制字符串(0-9, a-f)。
      • 检查私钥是否包含0x前缀,sm-crypto通常不需要。
      • 确保环境变量中的私钥值没有多余的空格或换行。
  4. Postman脚本执行慢或卡顿

    • 原因eval执行较大的库代码可能有一定开销。
    • 优化
      • 只提取sm-crypto中你真正用到的函数(sm2.doSignature,sm2.doHash等),制作一个精简版。
      • 考虑将签名函数封装成一个Collection级别的Pre-request Script,这样同一个集合下的所有请求共享一份库代码,避免每个请求都重复初始化。

5.2 安全与最佳实践建议

  1. 私钥管理

    • 永远不要将私钥提交到版本控制系统(Git)。
    • 在Postman中,使用环境变量而非全局变量来存储私钥。环境变量可以按开发、测试、生产环境隔离。
    • 考虑使用Postman的Secret变量类型(部分版本支持),或利用其内置的模糊化功能。
    • 对于团队协作,使用Postman Workspace并设置适当的变量访问权限。
  2. 签名逻辑复用

    • 将完整的签名脚本(包括库加载、字符串构造、签名计算)抽象成一个函数,放在Collection的Pre-request Script中。
    • 在每个具体请求的Pre-request Script里,只需调用这个函数,并传入当前请求特有的参数(如不同的签名规则标识)。这样便于统一维护和更新。
  3. 增加重放攻击防护

    • 确保nonce(随机数)在短时间内唯一,后端应记录近期使用过的nonce并拒绝重复请求。
    • 结合timestamp,后端应校验请求时间戳与服务器时间的偏差(如允许±5分钟),拒绝过期请求。
  4. 调试与监控

    • 在Tests脚本中,将关键的调试信息(如signString的前后若干字符、nonce)输出到Console,甚至可以作为响应断言的一部分。
    • 如果后端支持,可以约定一个特殊的调试头(如X-Debug: true),让后端在响应中返回它计算出的signString,用于直接比对。

5.3 扩展到其他国密算法

这套流程不仅适用于SM2签名。sm-crypto库同样支持:

  • SM3哈希:如果需要单独计算SM3,可以使用sm2.doHash(message, 'hex')
  • SM4加解密:如果API请求体需要加密传输,可以在Pre-request Script中用SM4加密body.raw,然后将密文作为新的请求体发送,并在Header中指明加密方式。验签则可能在加密前的明文上进行,也可能在密文上进行,需与后端约定。

实现SM4加密的流程类似:引入库、获取密钥、加密数据、更新请求体。这为构建符合国密标准的全链路安全API调试提供了可能。

整个过程的核心在于精确一致。密码学算法本身是精确的,任何微小的差异都会导致失败。因此,与后端开发人员保持密切沟通,明确每一个细节规则,并充分利用Postman的脚本调试功能,是成功的关键。当你看到第一个带着自己生成的SM2签名的请求返回“成功”时,这套流程就真正成为你API调试工具箱里一件趁手的兵器了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/9 12:35:03

终极GitHub加速指南:3分钟安装,让你的下载速度提升100倍!

终极GitHub加速指南&#xff1a;3分钟安装&#xff0c;让你的下载速度提升100倍&#xff01; 【免费下载链接】Fast-GitHub 国内Github下载很慢&#xff0c;用上了这个插件后&#xff0c;下载速度嗖嗖嗖的~&#xff01; 项目地址: https://gitcode.com/gh_mirrors/fa/Fast-Gi…

作者头像 李华
网站建设 2026/7/9 12:31:09

如果25年前就能用容度原理的视角来审视“氢键全量子效应”,这趟持续20多年的科学苦旅,完全可以走一条截然不同的捷径。这不仅是理论上的推演,更是对科学研究范式的一次深刻反思

如果25年前就能用容度原理的视角来审视“氢键全量子效应”&#xff0c;这趟持续20多年的科学苦旅&#xff0c;完全可以走一条截然不同的捷径。这不仅是理论上的推演&#xff0c;更是对科学研究范式的一次深刻反思。⏳ 历史回顾&#xff1a;一条充满艰辛的“发现”之路王恩哥院士…

作者头像 李华
网站建设 2026/7/9 12:27:44

告别网络束缚:用fanqienovel-downloader打造你的永久小说图书馆

告别网络束缚&#xff1a;用fanqienovel-downloader打造你的永久小说图书馆 【免费下载链接】fanqienovel-downloader 下载番茄小说 项目地址: https://gitcode.com/gh_mirrors/fa/fanqienovel-downloader 你是否曾经在深夜追更时突然断网&#xff0c;看着加载中的页面焦…

作者头像 李华