做前端时间久了,几乎每个人都会被问到这么一句:这个参数能不能加密一下?我第一次认真面对这个问题,是在一个后台管理项目里,运营反馈订单接口被人用脚本狂刷,数据被爬走一大半,后端甩过来一句“前端加个签名吧”。说实话,前端加密这个概念听着唬人,落地拆开以后就是三件事:防偷窥、防篡改、防重放。把这三件事认清楚,绝大多数项目的加密需求都能被切成很多小块,一块一块做掉就完事了。这篇文章主要写给被接口联调和安全测试折腾过、想搞懂前端到底能怎么加密的前端开发者,也顺带让零基础的朋友知道密码学在前端世界里到底有哪几种基础玩法。
1. 前端加密到底在防什么
1.1 别指望 HTTPS 包打天下
HTTPS 解决的是数据在传输管道里被偷看、被篡改的问题,它保护的是“管道”,不是“管道两端的人”。现实里的接口链路,远不止一条 HTTPS 管道那么简单:
- 很多内网部署的业务系统至今还是 HTTP;
- 网关层会把 URL query 记录到访问日志里,日志一多就可能被人拉走;
- 反向代理、CDN 节点可能缓存响应,敏感字段以明文躺在缓存文件里;
- 更常见的一种情况:请求到了服务端之后,服务端只信任参数本身,完全没有机制去验证这个请求到底是不是客户端发的、参数有没有被改过、请求是不是几分钟前被重放过来的。
就算全站都上了 HTTPS,一个能抓到包的内部人员,照样可以把请求报文原样复制下来,改掉金额再重放一遍。服务端如果不做任何身份和完整性校验,它根本分不清这一份“合法请求”是从真实页面还是脚本里来的。
所以前端加密和签名,真正补的是服务端这一层信任缺失。HTTPS 负责路途安全,前端加密负责确认“来的人是谁、递来的货有没有被人动过”。
1.2 三个核心目标:防偷窥、防篡改、防重放
- 防偷窥:让敏感字段不以明文出现在请求日志、浏览器开发者工具、缓存文件里。最常见的是手机号、身份证号、银行卡号这类数据,传输和落地都要做处理。
- 防篡改:加一个基于密钥和参与参数的摘要,任何参数改动都会让摘要校验失败。这就是验签。
- 防重放:在签名里加入时间戳和随机数,服务端检查时间窗口,并给随机数记一次“已使用”,同一个请求不能重复生效。
这三个目标往往是一套组合拳,而不是单选。我在实际项目中最大的感受是:对普通业务系统来说,防篡改和防重放的优先级,其实比防偷窥更靠前。数据泄露的常见形式不是“被人看了一眼”,而是“被人改了数据”和“被人拿同一个请求反复刷”。接口被刷这种问题,签名+防重放几乎能一口气解决。
1.3 前端加密的边界:它是一道门槛,不是保险柜
这一点必须放在最前面讲清楚。前端代码运行在用户浏览器里,所有代码和密钥理论上都能被有心人扒出来,这是前端安全的一条铁律。所以前端加密的正确姿势是:把它当门槛,提高攻击成本,把脚本小子和批量抓取挡在门外,而不是把它当保险柜,存放核心机密。
如果你的业务场景里“密钥一旦泄露就完蛋”,那这个密钥就不应该出现在前端代码里,而应该由后端下发的短期凭证来驱动前端的签名逻辑。下文讲到的每一种方案,我都会同步提醒它在哪个位置是可信的、哪个位置只是增加成本、哪个位置其实是表演式加密。
2. 密码学基础:散列、对称、非对称到底怎么选
2.1 散列:碎纸机
散列算法,前端最熟悉的是 MD5、SHA-1、SHA-256。它的特点可以用碎纸机来理解:无论你丢进去一本小说还是一张便签,出来的都是大约同样大小的碎纸条,而且碎片没法还原成原稿。更关键的是,哪怕你只改原文里的一个标点,出来的碎纸条形态会完全不同,这就是“雪崩效应”。
散列是单向的,你不能从摘要反推原文。它适合做完整性校验、密码摘要存储这类场景。但单靠散列解决不了“防止重放”的问题,因为算法公开、没有秘密,任何人都能对同一份参数算出同样的摘要,直接把摘要连参数一起原样提交即可。
所以在实际接口防篡改场景里,我们几乎不会用裸的散列,而是用带密钥的散列变体,也就是后面要说的 HMAC。
2.2 对称加密:一把钥匙的保险箱
对称加密,前端最常见的是 AES 系列。它的逻辑是一把钥匙同时负责锁和开,想用同一个保险箱存东西、取东西,都得拿同一把钥匙。因为计算量小、速度快,AES 适合加密大数据量,比如一整段用户信息、一大段报文。
对称加密有两个要点会在实战里反复踩坑:一个是密钥长度,AES 常用 128 位或 256 位;另一个是工作模式,CBC 模式需要随机初始向量(IV),GCM 模式自带认证标签,能发现密文是否被篡改。我现在凡是能用 GCM 的地方,都不会选 CBC,因为 CBC 只保证机密性,不保证完整性,密文被改动时经常解出来才发现数据坏了,还不好定位。
对称加密的短板也显而易见——钥匙怎么安全地给到对方?如果钥匙和密文走同一条路,等于把保险箱和钥匙一起交给快递员,安全性就无从谈起。
2.3 非对称加密:人人都能投递、只有你能打开的信箱
非对称加密的代表是 RSA 和椭圆曲线系列。它的模型是公共信箱:任何人都知道邮箱地址,能往里投信,但只有拿着专属钥匙的你能打开箱门取信。公钥用于加密,私钥用于解密;公钥可以随便分发,私钥必须自己藏好。
由于涉及大数运算,非对称加密比对称加密慢得多,而且加密长度也有限。它天生不适合加密一大段业务数据,但非常适合解决对称加密的密钥传递问题:客户端用服务端公钥把一个临时对称密钥加密后传给服务端,服务端用私钥解开拿到对称密钥,接下来大数据量就用这个对称密钥去通信。这正是 TLS 握手的核心思路。
2.4 混合使用才是常态
前端加密项目里,很少有只用一种算法解决的。合理的组合方式通常是:
- 非对称加密负责交换密钥或加密极短的关键字段,比如登录密码、身份证号;
- 对称加密负责加密实际传输或存储的大段内容;
- HMAC 签名负责对参与请求的全部参数做完整性校验;
- 时间戳和 nonce(一次性随机数)负责抗重放。
把它们组合在一个方案里,才是一套比较完整的前端加密实践。记住一个原则:算法永远公开,要保密的是密钥。任何把“算法本身藏着掖着”当作保密手段的做法,都是自欺欺人。
3. 工具选型解析:主流前端加密方案横向对比
3.1 优先用 Web Crypto API
新项目我强烈建议优先用浏览器原生提供的 Web Crypto API,也就是window.crypto.subtle。它支持 AES-GCM、RSA-OAEP、HMAC、PBKDF2 这些核心算法,既有浏览器原生实现,还有硬件层面的随机数支持。
Web Crypto 有两个“性格”需要适应。第一,它几乎全是异步 API,调用方式是await一串 promise,写起来没有同步代码那么顺手;第二,它要求运行在安全上下文中(HTTPS 或 localhost),如果你在本地用file://或者 HTTP 打开页面,crypto.subtle会是 undefined,一上来就报错。
大多数团队会因为异步 API 而不太愿意在新项目里用它。但我反而觉得异步是优势,因为它天然不会阻塞 UI 主线程,尤其加密批量数据的时候,体验差别很明显。
3.2 crypto-js 的取舍
crypto-js 是非常老牌的加密库,支持 MD5、SHA、AES、HMAC、PBKDF2 等一大堆算法,API 是同步的,写起来很直接:
const hash = CryptoJS.SHA256('输入内容').toString(); const encrypted = CryptoJS.AES.encrypt('明文', 'password').toString();同步 API 是它最大的便利性,也是它在老项目里长期存在的原因。但便利背后有几个问题要注意。早期某些版本的随机数生成有过质量问题,所以要用就必须用相对新的版本。另外,CryptoJS.AES.encrypt(明文, 'password')这种传字符串密钥的写法,底层会走 OpenSSL 的 KDF 从密码派生密钥,导致你很难用 Node 的createCipheriv去对接同一个密文,经常出现“前端加密后端解不开”的坑。
我的建议是:crypto-js 适合做快速签名、计算哈希,以及在一些无法使用 Web Crypto 的老环境里做兜底。但做 AES 对接时,不要用传密码的偷懒写法,要显式传入 key 和 IV,用CryptoJS.AES.encrypt(明文, key, { iv })的形式。
3.3 jsencrypt:RSA 专用的小工具
jsencrypt 是专门做 RSA 加解密的库,最大的特点是直接支持 PEM 格式的密钥字符串,开箱即用:
const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKeyPem); const result = encryptor.encrypt('待加密内容');它在前端登录密码加密场景里非常常用。但要注意,jsencrypt 默认用的填充是 PKCS#1 v1.5,虽然能用,但比 RSA-OAEP 要老,安全性上也弱一些。而且 rs encryption 本身就不适合加密长文本,它加密长度受密钥位数限制,2048 位密钥最多也就加密 245 字节左右。
所以 jsencrypt 的定位很明确:登录密码、身份证号这类短字段的 RSA 加密。真正要加密长文本,请回去用对称加密。
3.4 选型对比
| 方案 | 支持算法 | 异步 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| Web Crypto API | AES-GCM/CBC、RSA-OAEP、HMAC、PBKDF2 | 是 | 现代浏览器新项目 | 必须在 HTTPS 或 localhost 下使用 |
| crypto-js | MD5/SHA/AES/HMAC/PBKDF2 | 否 | 快速签名、老环境兜底 | AES 用字符串密钥时注意 KDF 与 Node 不兼容 |
| jsencrypt | RSA | 否 | 登录密码等短字段加密 | 默认 PKCS#1 v1.5 padding,长文本无法加密 |
| node:crypto | 全套 | 同步+异步 | 服务端验签、加解密对拍 | 前端联调时最常使用的对照工具 |
选型的最终建议就一句话:新项目优先 Web Crypto API,接口签名用 HMAC-SHA256,短字段机密用 RSA-OAEP,长文本先 AES 生成密文再把 AES 密钥用 RSA 加密传过去。
4. 典型场景实战:从密码登录到本地存储
4.1 登录密码传输:RSA-OAEP,而不是前端哈希
很多前端同学处理登录密码时,第一反应是“前端先把密码 SHA-256 哈希一下再传”。这个做法在大多数场景下没有太大意义。原因很简单:
- 哈希算法是公开的,没有密钥,抓包的人不需要知道密码原文,直接把哈希值重放就能通过校验;
- 服务端如果拿不到密码原文,就没法用
bcrypt这类慢哈希算法做安全的密码存储; - 前端哈希唯一的好处是避免密码原文出现在浏览器日志或开发者工具面板里,但这点收益远不如直接交给 HTTPS。
正确的做法是:前端用服务端下发的公钥,用 RSA-OAEP 把密码加密后传过去,服务端私钥解密拿到密码原文,再用服务端的密码哈希算法(比如 bcrypt)处理后存储。
Web Crypto 的 RSA-OAEP 实现如下:
async function encryptWithPublicKey(plainText, publicKeyPem) { const pemBody = publicKeyPem .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', '') .replace(/\s/g, ''); const binary = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); const publicKey = await crypto.subtle.importKey( 'spki', binary, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const encrypted = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, new TextEncoder().encode(plainText) ); let binaryStr = ''; new Uint8Array(encrypted).forEach((b) => { binaryStr += String.fromCharCode(b); }); return btoa(binaryStr); }几个关键点:
- 密钥格式是 SPKI,这是公钥的标准格式,后端 Java 或 Node 生成的 RSA 公钥都能转换;
hash: 'SHA-256'对应的是 OAEP 内部的 MGF1 哈希,比默认的 SHA-1 更稳妥;- 加密结果要转 Base64 再传输,避免二进制内容被 JSON 序列化搞坏。
这套流程里,公钥可以放心给前端,真正保密的是服务端的私钥。私钥只存在于服务端,任何人都不可能从前端代码里翻出来。
4.2 接口参数防篡改:HMAC-SHA256 签名
这是我最推荐优先落地的方案,也是投入产出比最高的一个。整个思路是:前端把请求参数、时间戳、随机数拼接成一段原始字符串,再用一个 appSecret 作为 HMAC 密钥算出签名,服务端用同一个密钥按同样的拼接规则重新计算签名,比对一致就放行。
用 Web Crypto 实现 HMAC-SHA256:
async function hmacSha256(key, message) { const enc = new TextEncoder(); const importedKey = await crypto.subtle.importKey( 'raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', importedKey, enc.encode(message)); return Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }签名生成函数:
async function buildSign(params, appSecret) { const timestamp = Math.floor(Date.now() / 1000); const nonce = crypto.randomUUID().replace(/-/g, ''); const keys = Object.keys(params).sort(); const query = keys .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); const rawString = `timestamp=${timestamp}&nonce=${nonce}&${query}&secret=${appSecret}`; const sign = await hmacSha256(appSecret, rawString); return { timestamp, nonce, sign }; }签名里必须包含 timestamp 和 nonce,这两个字段承担了防重放的职责:
- timestamp 让服务端可以判断请求是否在允许的时间窗内,超时直接拒绝;
- nonce 让服务端在时间窗内对随机数做一次判重,同一个随机数用第二次就拒绝。
注意,appSecret不要塞进 URL 参数里,否则会被网关日志记下来,等于把密钥写在明面上。标准做法是放进请求头,比如X-App-Id、X-Timestamp、X-Nonce、X-Sign。
4.3 本地敏感数据存储:AES-GCM 加密
localStorage 里存 token、用户资料、偏好配置太常见了,但明文存储等于把隐私数据摆在一个任何人都能打开的地方。哪怕是同一个浏览器里不同的网站脚本,在 XSS 面前也能直接读到这些数据。
对需要落地的敏感数据,我一般用 AES-GCM 加密。GCM 模式自带认证标签,密文被篡改时解密会直接失败,比 CBC 安全一个层次:
async function generateAesKey() { const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); const rawKey = await crypto.subtle.exportKey('raw', key); return arrayBufferToBase64(rawKey); } async function encryptWithAesGcm(keyBase64, data) { const key = await crypto.subtle.importKey( 'raw', base64ToArrayBuffer(keyBase64), 'AES-GCM', false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(data) ); const result = new Uint8Array(iv.length + ciphertext.byteLength); result.set(iv, 0); result.set(ciphertext, iv.length); return arrayBufferToBase64(result); }这里我把 IV 直接拼在密文前面一起存,解密时先截出前 12 个字节当 IV,剩下的当密文,省去单独管理 IV 的麻烦。AES-GCM 的 IV 不要求绝对保密,但必须保证每次加密都不同。
要格外警惕一个反模式:把密钥和密文一起存到 localStorage。那就等于把保险箱和钥匙放在同一个床头柜里,除了防防误操作,起不了真正的保密效果。如果数据必须长期保存,密钥应该由服务端下发,或者放在会话级的内存变量里,刷新即失效。
4.4 混合加密:用 RSA 保护 AES 密钥
如果需要加密一段较长的请求体,又想用 RSA 参与,最合适的做法是混合加密:
- 前端生成一个临时的 AES-256 密钥;
- 用这个 AES 密钥加密请求内容;
- 用服务端公钥通过 RSA-OAEP 加密这个临时 AES 密钥本身;
- 把 AES 密文和 RSA 加密后的 AES 密钥一起发给服务端;
- 服务端先解 RSA 拿到 AES 密钥,再用它解内容。
这套流程里,RSA 只加密几十字节的密钥,AES 负责加密任意长度的内容,两头都占了。这也是几乎所有真实加密通信都在使用的基本思路。
5. 完整案例:给下单接口加签名防刷
5.1 场景还原与方案设计
之前的后台项目里,接口被脚本刷的典型特征是:脚本固定死了请求参数,反复提交同一个请求,后台订单表刷出一堆垃圾数据。服务端没有鉴权,没有验签,没有重复提交控制。
方案定下来是这样:
- 客户端请求头带四个字段:
X-App-Id、X-Timestamp、X-Nonce、X-Sign; Sign的计算规则是:参数名按字典序排列,拼成key=value&key2=value2,然后加上 timestamp、nonce 和 appSecret,整串做 HMAC-SHA256;- 服务端校验三步走:时间窗检查、nonce 判重、签名比对。
为什么参数要排序?因为服务端重新拼接时必须保证顺序一致。如果客户端是a=1&b=2,服务端却按b=2&a=1拼,签名必然对不上。排序之后,双方对同一组参数的拼接结果就是确定性的。
5.2 前端实现签名
我贴一个可以直接用的完整请求示例:
async function requestWithSign(url, params, appId, appSecret) { const { timestamp, nonce, sign } = await buildSign(params, appSecret); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-App-Id': appId, 'X-Timestamp': String(timestamp), 'X-Nonce': nonce, 'X-Sign': sign, }, body: JSON.stringify(params), }); return response.json(); }这里有一个容易被忽略的细节:签名用的原始字符串和实际请求体里的参数,必须严格一致。如果你body里用的是原始 params,但签名时对 params 做了排序和 URL 编码,那服务端校验时也要按相同的逻辑处理。最稳妥的办法是:请求体里的字段顺序不敏感,但签名原始串的构造规则要固化成文档,前后端各留一份。
5.3 服务端校验逻辑
服务端用 Node 做参考实现:
const crypto = require('crypto'); function verifySign(req, appSecretMap) { const appId = req.headers['x-app-id']; const timestamp = Number(req.headers['x-timestamp']); const nonce = req.headers['x-nonce']; const sign = req.headers['x-sign']; const secret = appSecretMap[appId]; if (!secret || !sign || !timestamp || !nonce) return { ok: false, msg: '缺少签名参数' }; // 时间窗 5 分钟 const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 300) { return { ok: false, msg: '请求已过期' }; } // nonce 判重,这里用 Redis 或者内存 Set if (nonceCache.has(nonce)) { return { ok: false, msg: '请求重复' }; } const rawString = buildRawString(req.body, timestamp, nonce, secret); const expected = crypto.createHmac('sha256', secret).update(rawString).digest('hex'); if (expected !== sign) { return { ok: false, msg: '签名不匹配' }; } nonceCache.add(nonce); return { ok: true }; }初看逻辑不复杂,但真正上线后,bug 往往不在加密本身,而在“服务端拿到的 body 和前端签名的参数是否一致”上。Node 里req.body是 JSON 解析后的对象,排序、编码都容易出偏差。我的经验是:签名原始串的生成最好前后端共用一份伪代码文档,甚至可以直接把前端buildSign的逻辑原样翻译到后端,避免理解偏差。
5.4 上线后要注意的事
第一,时间戳务必统一用秒级,别混用毫秒和秒,否则服务端和客户端差了 1000 倍,时间窗永远不成立。第二,nonce 判重如果漏了,防重放就只剩时间戳一层,而时间戳范围内的重放依然有效,所以 Redis 缓存 nonce 的时间至少要和时间窗一样长。第三,服务端日志不要打签名原始字符串,里面带了 secret,打进去等于自我泄露。
这套签名方案上线后,脚本直接改参数重放的路子基本就堵死了。攻击者如果真想继续刷,必须逆向出 appSecret,那就进入了对抗更深一层的范畴,已经超出了普通业务防刷的预期。
6. 常见问题与排查技巧实录
6.1 前端加密,后端解不开
这个问题的排名绝对第一。先说 RSA 解不开的常见原因:
- 前后端使用的填充方式不一致。前端 jsencrypt 默认 PKCS#1 v1.5,后端却用 OAEP 解密,必挂。要统一,且推荐都用 OAEP;
- Base64 解码环节出错,字符串里有换行符、空格,解出的二进制就已经错了;
- 前端把加密结果交给了 JSON 序列化,
+被转成了空格,后端还原不出来。传输前先做统一处理,比如encodeURIComponent或者换用 URL-safe Base64。
AES 解不开,最常见的就是 crypto-js 的坑。CryptoJS.AES.encrypt('Hello', '123456')这个写法和 Node 的aes-256-cbc完全对不上,因为前者用密码字符串做了一个基于 OpenSSL 的密钥派生。解决方式是显式指定 key 和 iv,并明确加密模式:
const key = CryptoJS.enc.Utf8.parse('16位或32位的密钥字符串'); const iv = CryptoJS.enc.Utf8.parse('16位IV字符串'); const encrypted = CryptoJS.AES.encrypt('Hello', key, { iv, mode: CryptoJS.mode.CBC }).toString();Node 端用createCipheriv('aes-256-cbc', key, iv)来对应,不要用createCipher那种废弃写法。
6.2 签名总是校验失败
签名对不上的第一直觉别去怀疑 HMAC 算法,顺序不统一才是最大的坑。具体表现:
- 前后端对参数排序规则理解不一致,一个排序一个没排;
- URL 编码的差异:空格在前端编码成了
%20,后端还原出来变成了+; - 拼接原始字符串时,有的字段被遗漏,比如 timestamp、nonce 没加;
- 请求体在中间被网关改写,比如被加了签名参数才真正发送,但服务端拿到的是加完之后的东西。
排查这类问题,最有效的办法不是盯代码,而是让前后端把各自生成签名时的原始字符串分别打印出来对比。两行字一摆,差异立刻现形。我之前有两次踩坑都是这样五分钟内解决的。
6.3 密钥和编码的常见误区
密钥长度不匹配是 AES 高频问题。前端的密钥是 16 字节,后端用 32 字节去解,直接报错。所以密钥长度必须在方案里写明:AES-128 用 16 字节,AES-256 用 32 字节,IV 固定 16 字节(CBC)或 12 字节(GCM)。
再就是 hex 和 Base64 混用。前端把哈希结果输出成了 hex,后端却按 Base64 去解码,或者反过来,都会得到一堆乱码字符串。建议全链路统一用 hex,因为 HMAC 摘要输出 hex 最直观,可读性最好,也方便排查。
6.4 防重放失效:时间戳对不齐、nonce 没判重
上线一段时间后,偶尔会听到“加了防重放还是被刷”的反馈。先查三件事:
- 时间戳是不是被客户端手动改成当前时间,每次请求都重新生成,时间窗永远通过——这种情况说明脚本已经逆向逻辑了;
- nonce 缓存没做持久化,服务端重启后清空,攻击者拿历史请求就能再次通过;
- 并发场景下,同一 nonce 被两个请求同时带过来,检查与写入之间有竞态,短时间窗内出现“漏网之鱼”。
前面两点属于运维层面问题,第三点需要用 Redis 原子操作或者加唯一索引去兜底。对绝大多数项目来说,能把这些查完改完,防刷效果已经非常可观了。
7. 一点个人心得
前端加密做了几年,我最大的体会是:它更像一道门槛和一层滤网,而不是一个绝对安全的保险柜。设计任何前端加密方案之前,先自己问一句:如果这个密钥被公开,我会不会慌?会慌,就说明它不应该出现在前端代码里。能把这个边界想清楚,方案基本不会走偏。
我也越来越倾向于把加密分成三档来处理:普通接口参数,只做 HMAC 签名防篡改和防重放;密码、身份证号这类不能再短的机密字段,用 RSA-OAEP;本地要存的敏感数据,用 AES-GCM 并避免密钥与密文共存。顺序也很有讲究,先全站 HTTPS,再做接口验签,然后才考虑字段加密和本地存储加密。步子迈大了容易给自己挖坑,一步步来,反而稳定。文章里的代码片段都是我实际项目里跑过的方案,你可以直接抄去改一改。真遇到排查不出来的加解密问题,记住先把原始拼接串打出来,把那行字符串对齐了,百分之七八十的坑都埋在那里。