区块链初探

区块链[&比特币]概念

拜占庭问题:讨论如何在远程协商且有干扰信息的情况下整个系统达成正确决策的问题。 拜占庭将军问题深入探讨 。一般地,把故障(不响应)的情况称为“非拜占庭错误”,恶意响应的情况称为“拜占庭错误”,对应的节点称为拜占庭节点。设总节点数N,故障节点f,PBFT场景需满足N>=3f+1;所以在prepare和commit两个阶段必须收到至少2f+1(包括自己) 个节点的消息,才可认为多于故障和恶意节点正常节点发回了响应,并有消息至少占据f+1数量的节点,从而达成共识。——注意,达成共识并不表示最终结果的正确性,而是只要得出这个最终结果即可。

两军问题:信道的不可靠(信息存在被篡改和丢失的可能)使得两军问题无解。

倘若1号蓝军(简称1)向2号蓝军(简称2)派出了通信兵,若1要知道2是否收到了自己的信息,1必须要求2给自己传输一个回执,说“你的信息我已经收到了,我同意你提议的明天早上10点9分准时进攻”。然而,就算2已经送出了这条信息,2也不能确定1就一定会在这个时间进攻,因为2发出的回执1并不一定能够收到。所以,1必须再给2发出一个回执说“我收到了”,但是1也不会知道2是否收到了这样一个回执,所以1还会期待一个2的回执。虽然看似很可笑,但在这个系统中永远需要存在一个回执,这对于两方来说都并不一定能够达成十足的确信。更要命的是,我们还没有考虑,通信兵的信息还有可能被篡改。

这有点类似于《三体》里的猜疑链,除非我们/文明之间如三体人一样可以敞开心扉无障碍交流(可靠信道)。

不幸的是,两军问题作为现代通信系统中必须解决的问题,我们尚不能将之完全解决,这意味着你我传输信息时仍然可能出现丢失、监听或篡改的情况。但我们能不能通过一种相对可靠的方式来解决大部分情形呢?这需要谈到TCP协议。事实上,搜索“两军问题与三次握手”,您一定可以找到大量与TCP协议相关的内容。TCP协议中,A先向B发出一个随机数x,B收到x了以后,发给A另一个随机数y以及x+1作为答复,这样A就知道B已经收到了,因为要破解随机数x可能性并不大;然后A再发回y+1给B,这样B就知道A已经收到了。这样,A和B之间就建立一个可靠的连接,彼此相信对方已经收到并确认了信息。而事实上,A并不会知道B是否收到了y+1;并且,由于信道的不可靠性,x或者y都是可能被截获的,这些问题说明了即使是三次握手,也并不能够彻底解决两军问题,只是在现实成本可控的条件下,我们把TCP协议当作了两军问题的现实可解方法。

FLP Impossibility:可参看FLP Impossibility,文中比较难以理解的是——F0属于D,因为D是”确定“的,E0是0-valent的,无论E0从F0可达,还是F0从E0可达,则F0必定是0-valent的——“确定”即表示后续能且只能做出一个决议,因为E0是0-valent,若E0从F0可达(即E0是F0的后续),那么F0不能演化出1-valent,也就是说F0自身不能是1-valent(若F0是1-valent,那么任意事件e应用到失效的p上,结果还是1-valent,这将反推得D是“不确定”的);F0从E0可达同理。

Diffie-Hellman算法可以使得在不安全信道上安全的交换密钥。

比特币的方式每秒可以处理多少笔交易?最理想状态下,平均每笔交易225 字节。在1M区块限制下,一般平均10分钟可以打包大约 4400 笔交易。每秒大约7.3笔交易,实际交易平均大小是这个的一倍,那么容量减半,也就是每秒大约 3.6 笔交易。

一笔交易的可读格式如下:

 1 {
 2   "version": 1,
 3   "locktime": 0,
 4   "vin": [
 5     {
 6       "txid":"7957a35fe64f80d234d76d83a2a8f1a0d8149a41d81de548f0a65a8a999f6f18",
 7       "vout": 0,
 8       "scriptSig": "3045022100884d142d86652a3f47ba4746ec719bbfbd040a570b1deccbb6498c75c4ae24cb02204b9f039ff08df09cbe9f6addac960298cad530a863ea8f53982c09db8f6e3813[ALL] 0484ecc0d46f1918b30928fa0e4ed99f16a0fb4fde0735e7ade8416ab9fe423cc5412336376789d172787ec3457eee41c04f4938de5cc17b4a10fa336a8d752adf",
 9       "sequence": 4294967295
10     }
11  ],
12   "vout": [
13     {
14       "value": 0.01500000,
15       "scriptPubKey": "OP_DUP OP_HASH160 ab68025513c3dbd2f7b92a94e0581f5d50f654e7 OP_EQUALVERIFY OP_CHECKSIG"
16     },
17     {
18       "value": 0.08450000,
19       "scriptPubKey": "OP_DUP OP_HASH160 7f9b1a7fb68d60c536c2fd8aeaa53a8f3cc025a8 OP_EQUALVERIFY OP_CHECKSIG",
20     }
21   ]
22 }

交易手续费:一笔交易设置了输入输出(UTXO),若输出总额小于输入总额,那么差额就作为手续费给到[打包到区块中的]矿工。因此,矿工会优选手续费大的交易;且由于区块大小一定,若一个交易有很多个输入输出(如以非常多的小额输入汇总到一个输出),即该笔交易数据size较大,矿工也许会比较该笔交易的手续费与多笔小size的交易手续费之和的大小。总之,除挖矿奖励的比特币外,矿工都会希望自己挖到的区块记录的交易有更多的手续费。若链上交易频繁,那些手续费少或者为0的交易甚至都不会被记录,从而得不到确认。

在手动创建交易时,务必注意输入、输出的值,非常容易犯错的是忘记构造找零输出。曾经有人构造交易时忘记找零,发生了支付 200 BTC 的矿工费的人间惨剧,所幸的是收录该笔交易的Block由著名挖矿团队“烤猫(Friedcat)”挖得,该团队非常厚道的退回了多余费用

比特币的交易验证引擎依赖于两类脚本来验证比特币交易:锁定脚本(上述代码scriptPubKey)和解锁脚本(上述代码scriptSig)。锁定脚本是输出的一个字段,而解锁脚本是输入的一个字段,解锁的是上笔输出的锁定脚本。可以理解为解锁脚本作为参数传入锁定脚本,若最终得到的结果为true,那么表示该锁定脚本关联的交易输出可以作为该解锁脚本关联的交易输入,即能提供正确的解锁脚本的账号才可使用锁定脚本锁定的输出。矿工亦是据此验证一个交易是否成立。上述脚本格式为P2PKH——支付到公钥地址模式(还有P2SH,支付到脚本模式,使用多重签名就需要用到这种模式),在P2PKH模式下,scriptSig格式为[签名的字节数][签名]0×01 [公钥的字节数] [公钥],scriptPubKey格式为OP_DUP OP_HASH160 (0×14) [一个20字节的哈希值] OP_EQUALVERIFY OP_CHECKSIG

我们可以查看上述交易的前一笔交易的输出:

1 "vout": [
2    {
3      "value": 0.10000000,
4      "scriptPubKey": "OP_DUP OP_HASH160 7f9b1a7fb68d60c536c2fd8aeaa53a8f3cc025a8 OP_EQUALVERIFY OP_CHECKSIG"
5    }
6  ]

第4行的20字节的16进制串7f9b1a7fb68d60c536c2fd8aeaa53a8f3cc025a8是收款方的地址,乃是收款方公钥经过二次hash后得到的。

为什么不直接使用公钥呢,这是出于安全考虑。我们知道,可以从私钥轻松地得到公钥,反之则非常困难。虽然通过公钥很难得到私钥,但为了将风险降到最低,在交易过程中,我们可以将公钥hash之后作为收款地址发给对方;更进一步,比特币转账每次都是彻底把原来账户中的币全部转走(除非一个账户付款给自己,如找零),因此只要我不重复使用同一个公钥,用完就扔掉,就不用担心别人拿到我的公钥后能干啥:如果高手不能很快的在我发布交易后根据里面的公钥算出私钥,一旦交易完成,公钥对应的就是一个空账户了。

交易验证过程简单地说就是:

  1. 把scriptSig中的公钥同样经过二次hash后得到的数据与前一笔交易的scriptPubKey的哈希值比对,若相等则说明公钥有效;
  2. 使用公钥解密scriptSig中的签名,得到的结果若与当初私钥签名的内容(目前比特币采用的是当前交易的全部or部分数据,取决于SIGHASH标志字节,scriptSig中指定)相同,则交易有效[,不考虑其它非法情况比如交易输出>输入](矿工便可将该交易打包)。

一般钱包会使用SIGHASH_ALL标志(0x01)进行签名,表示Signature applies to all inputs and outputs。指定不同的SIGHASH标志可以实现特殊的业务场景,比如ALL|ANYONECANPAY(0x81,Signature applies to one input and all outputs)可以实现某种众筹形式:试图筹集资金的人可以用单笔输出来构建一个交易,单笔输出将“目标”金额付给众筹发起人。这样的交易显然是无效的,因为它没有输入。但是现在其他人可以通过添加自己的输入作为捐赠来修改它,他们用ALL | ANYONECANPAY签署自己的输入,除非收集到足够的输入以达到输出的价值,否则交易[数据]无效,可以把每次捐赠看作是一项“抵押”,直到募集到整个目标金额募款人才能真正收取(即将交易数据发送到比特币网络,满足输入>=输出才能被矿工打包记录)。

在签名过程中,会先 generates an ephemeral (temporary) private public key pair。为何要另外生成一对临时密钥对,而不使用已有的公私钥,现在我还没理解进去。

私钥就是一个随机选出的数字。比特币私钥空间的大小是2^256,这是一个非常大的数字。用十进制表示的话,大约是10^77,而可见宇宙被估计只含有10^80个原子。

通过椭圆曲线乘法可以从私钥计算得到公钥,这是不可逆转的过程:K = k * G 。其中k是私钥,G是被称为生成点的常数点,而K是所得公钥。比特币使用了secp256k1标准所定义的一种特殊的椭圆曲线和一系列数学常数,所有比特币用户的生成点是相同的。

公钥的格式:按是否压缩可分为非压缩格式和压缩格式。我们知道,公钥是在椭圆曲线上的一个点,由一对坐标(x,y)组成。非压缩公钥通常表示为前缀04紧接着两个256比特的数字,组成格式为04 x y共520bit。引入压缩格式公钥是为了减少比特币交易的字节数,从而可以节省那些运行区块链数据库的节点磁盘空间——压缩格式只存储公钥的x坐标值,y坐标可以通过解椭圆曲线的方程求得,前缀02/03分别对应y是偶数/奇数的情况。

为了存储安全,私钥有必要再次加密。BIP0038提出了一个通用标准,使用一个[强]口令加密私钥并使用Base58Check对加密的私钥进行编码。这样就算别人获得了你的私钥(加密过的),但不知道口令[密码]依然无用。

P2P网络

当一个节点(如比特币钱包客户端)启动后,如何加入到比特币网络中呢,即怎样才能与已在比特币网络中的节点建立连接。一种方式是通过已知的DNS服务器,这些DNS服务器提供比特币节点的IP地址列表;或者指定至少一个已知的比特币节点的IP地址。目前你还不能期望一个孤独的节点连入Internet后自己能很快寻找到其它的比特币节点。

SPV指的是“支付验证“,而不是“交易验证”。这两种验证有很大区别。
“交易验证”非常复杂,涉及到验证是否有足够余额可供支出、是否存在双花、脚本能否通过等等,通常由运行完全节点的矿工来完成。
“支付验证”则比较简单,只判断用于“支付”的那笔交易是否已经被验证过(已经被矿工打包),并得到了多少的算力保护(多少确认数)。这里涉及到Merkle Tree的概念,将数据块分段hash,然后相邻两段[或多段]的hash拼接后再hash,最终得到一个根hash,这样就组成了一棵Merkle Tree。我们可以依靠它校验局部数据,而不需要对整个数据块进行校验,参看 大话Neo系列:Merkle Tree。 

哈希算法在比特币中的应用几乎是方方面面,主要包括SHA256和RIPEMD160,比特币将这两个哈希算法的应用组合成两个函数:hash256(d)=sha256(sha256(d))和hash160(d)=ripemd160(sha256(d)),其中d为待哈希的字节数组,两者分别生成256位(32字节)和160位(20字节)的16进制数值。hash256主要用于生成标志符,如区块ID,交易ID等,而hash160主要用于生成比特币地址。
值得一提的是,为什么两个函数都是做两次哈希呢?对于hash160比较认同的答案是ripemd160可以使得生成的地址更短,但是只做ripemd160一次哈希可能会存在安全漏洞所以同时使用sha256起到安全加固;至于hash256使用两次sha256哈希算法的原因来源于sha1算法,由于一次sha1哈希存在被生日攻击(birthday attack)的风险,所以当使用sha1运算时一种有效方式就是做两次sha1哈希,sha256本身并不存在生日攻击漏洞,但是防御性的使用两次sha256哈希借鉴于sha1.

 

参考资料:精通比特币(第二版) 英文原版:https://github.com/bitcoinbook/bitcoinbook


涉及到的部分数学知识

数论是纯粹数学的分支之一,主要研究整数的性质。

算术基本定理(用反证法易得):又称唯一分解定理,表述为 任何大于1的自然数,都可以唯一分解成有限个质数的乘积,公式:\(n=p_1^{a_1}p_2^{a_2}\cdots p_k^{a_k}=\prod\limits_{i=1}^kp_i^{a_i}\),这里\(p_i\)均为质数,其指数\(a_i\)是正整数。算术基本定理是初等数论中一条非常基本和重要的定理,它把对自然数的研究转化为对其最基本的元素——素数的研究。

\(a\equiv b\pmod{c}\):a和b除以c后余数相同。

线性同余方程:\(ax\equiv b\pmod{n}\),当且仅当b能够被a与n的最大公约数整除(记作gcd(a,n) | b)时,此方程有解。这时,如果 x0 是方程的一个解,那么所有的解可以表示为:{x0+kn/d|(k∈z)},其中 d 是a 与 n 的最大公约数。在模 n 的完全剩余系 {0,1,…,n-1} 中,恰有 d 个解。例如,在方程5x ≡ 2 (mod 6)中, d = gcd(5,6) = 1,1 整除 2,因此方程在{0,1,2,3,4,5} 中恰有一个解: x=4。

对于[一元]线性同余方程组,中国剩余定理给出了有解的判定条件,并用构造法给出了在有解情况下解的具体形式。

欧拉函数:\(\varphi(x)=x\prod\limits_{i=1}^n(1-\frac{1}{p_i})\),其中p1, p2……pn为x的所有质因数,x是不为0的整数。\(\varphi(1)=1\)。表述为 设n为正整数,以 φ(n)表示不超过n且与n互素的正整数的个数,称为n的欧拉函数值。例如φ(8)=4,因为1,3,5,7均和8互质;注意:每种质因数只一个。 比如12=2*2*3那么根据欧拉函数φ(12)=12*(1-1/2)*(1-1/3)=4。

欧拉函数重要性质:如果 n 为正整数且 a 是一个与n互质的整数,那么\(a^{\varphi(n)}\equiv 1\pmod{n}\)。这个性质称为欧拉定理,其实是费马小定理的一个升级版。

由上可得,如果 a 与 n 是互质的正整数,那么至少存在一个正整数满足同余方程\(a^x\equiv 1\pmod{n}\),使之成立的最小正整数 x 称为a模n的,记为\(ord_na\) 。

原根:如果 a 与 n 是互质的整数且n>0,那么当\(ord_na=\varphi(n)\)时,称a为模n的原根。若a是n的原根,那么\(a^i \bmod n,其中(i\in[1,n-1],a\in[2,n-1])\)的结果互不相同。也可从另个方面理解,P为素数,i、j为整数,其中\(i \ne j, 且 i,j \in [1,P-1]\),若对于满足上述条件的任意i、j,有\(g^i \bmod P \ne g^j \bmod P\),则 g 就是 P 的原根。

更多阶与原根的知识可参看 数论之原根

Diffie-Hellman算法:一种秘钥交换算法,它是一种建立秘钥的方法,而不是加密方法,这种秘钥交换技术的目的在于使两个用户安全的交换一个秘钥。按上述原根的性质,可知如果a是素数p的一个原根,那么数值\(a \bmod p,a^2 \bmod p,\cdots,a^{p-1} \bmod p\) 是各不相同的整数,并且以某种排列方式组成了从1到p-1的所有整数。可参看 Diffie-Hellman(迪菲-赫尔曼)秘钥交换

涉及引理:若\(a \bmod b \equiv n\),则\(a^k \bmod b \equiv n^k \bmod b\)。

证明:

因为\(a \bmod b \equiv n\),则必然存在唯一整数q使得 a=qb+n (带余除法基本原理)

于是\(a^k=(qb+n)^k\),将\((qb+n)^k\)二项式展开,可以发现展开项除\(n^k\)外,其余项b的次数都大于零,可得除以b的余数必然由\(n^k\)这一项产生

所以\(a^k \bmod b \equiv n^k \bmod b\),证毕

RSA加密算法:参看 阮一峰:RSA算法原理(一)RSA算法原理(二)


其它

复式记账法:Asset(资产) = Liability(负债) + Equity(股东权益)。等式左边可理解为[公司]债务,右边可理解为[股东和其他投资方的]债权,可以理解为公司本身是没钱的,公司赚的钱都是属于股东的。

个人理解,一个好处是便于数据的统计汇总,它将一笔经济业务格式化为借&贷。借表示资金目前的状态,贷表示资金的来源。整个借贷分录,描述出一笔经济业务的资金运行情况,包括了业务种类(即会计科目)、涉及金额和资金运行方向,也就是用会计的语言描述了一笔经济业务。这比纯语言记录如“老李借给我100块钱”更规范,也便于工具[&自己]汇总对账。

另一个角度理解复式记账法,可以建立一个“主体观”,要明白记账的“主体”是什么?记账的主体是“公司”,不是股东,也不是债权人(这就是公司的法人独立性)。所以资源流入的时候,公司资产增加。但是“公司” 是属于股东的,公司有一部分资产也是属于债权人的,所以每一笔资源流入,必然对应股东权益增加,或者债权人债权增加(不考虑资产内部互转等情况);流出亦然。也就是说,公司每一笔业务导致的资源(财、物)流动,复式记账法不仅给“主体”做了登记,还给资源的归属做了登记。为什么要这么复杂?因为公司法人独立,公司资产与股东财产分离,才能做到公司债务与股东责任分离。公司可以破产,因为经营的主体是“公司”而非股东个人,所以股东不必“卖房卖车卖儿卖女”的去还债;所以股东虽然是公司所有者,但是非法挪用公司财产照样是犯罪。这个“主体观”,不仅可以帮助理解复式记账法,还是以后学习更复杂的企业合并、报表合并、股权投资核算的有力工具。[来自知乎,相关问题:复式记账(复式簿记)的基本原理是什么?]

通货膨胀导致货币缓慢但不可避免的贬值,这是一种隐性税收的形式,惩罚在银行存钱的人从而实现解救债务人(包括*这个最大的债务人)。 *控制下的货币容易遭受债务发行的道德风险,之后可能会以牺牲储蓄者为代价,通过贬值来抹去债务。

以太坊全节点中,都保存有完整的区块链数据。以太坊不仅将交易数据保存在链上,编译后 的合约代码同样也保存在链上,同时其还提供了一个虚拟机来执行合约代码。感性认识:深入解析调用合约的三种方法序列8 智能合约能自动触发吗?-智能合约平台的本质

一般情况下,DApp还是需要中心服务器,用于同区块链/以太坊节点交互,非用户数据存储在本地,用户数据保存在链上。若有成千上万个DApp运行在主链上,将在计算和存储两方面对主链产生压力,可使用侧链架构,即每一个Dapp就是一条侧链,侧链可以有相对独立的区块链和节点网络,不同DApp之间互不影响,更合理。

寄存器是*处理器内的组成部份。它们可用来暂存指令、数据和位址。在*处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC)。在*处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)。

堆栈:堆和栈是两种内存分配的两个统称,可能有很多种不同的实现方式。每种语言针对其有基本的设计原则,如栈通常后进先出(LIFO)。堆都是动态分配的,没有静态分配的堆。栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配;栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。栈是机器系统提供的数据结构,计算机会在底层对栈提供支持,分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。栈通常用于存取函数参数值、局部变量值以及指令地址等,主要保证逻辑按预期顺序执行;而堆简单的多,只是用于数据的存取。理解堆栈的概念还是得从早期的硬件和语言历史回顾,随着计算机技术的发展,这两者在软硬件的层面,在不同场景下都有不同的体现。C/C++中的变量存储类别以及内存分配 

指令系统分成堆栈型和寄存器型。不光这两种,指令系统共有四种分类,堆栈型,累加器型,寄存器-存储器型和寄存器-寄存器型。分类的依据是操作数的来源。堆栈型默认的操作数都在栈顶,累加器型默认一个操作数是累加器,寄存器-存储器型的操作数可以是寄存器或者内存,寄存器-寄存器型除了访存指令,操作数都是寄存器。

进程,线程,协程:进程和线程大家很熟悉。进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度;线程拥有自己独立的栈,共享堆,不共享栈,线程亦由操作系统调度;协程主要是针对单线程的一个概念(如Js、NodeJs、Python由于GIL导致的伪多线程),常用的一个场景是IO阻塞时,程序控制线程不傻傻等待,而是去执行其它任务,等IO操作完毕再控制线程回来继续操作,由于是单线程,所以也不涉及到由于系统调度导致的性能损耗。在编程层面上来说,协程的概念偏向于已同步编程的模式实现异步处理的编程模式,避免了多层回调代码嵌套的问题。协程的模式容易让人联想到C#的await/async,其实两者虽然解决的问题差不多,但是C#是多线程,若回调到达时,之前的线程仍在忙其它事情,线程池完全可以派另外的线程处理后续步骤,虽然多了线程上下文(主要是栈数据,刷新寄存器)切换的开销,但不用死等之前线程。参考资料 事件驱动与协程:基本概念介绍 (虽然其中内容不太准确,但是意思到位了)。

多个协程对应到一个线程才有意义,就如同一个进程包含多个线程。一个cpu内核同一时间只能执行一个进程/线程,因此在一个内核上的多线程执行其实效率反而比串行执行低,只是给用户一种并发的错觉;协程也同理,虽然貌似多个协程并行执行,其实最终还是落到一个线程上,不过比掩耳盗铃的多线程切换要省时省力(无需切换线程,省去了内核调度),并发的感觉上似乎也更加快了。当然多个协程可以对应到多个线程,特别是在当前多核cpu场景下,一般协程数多于线程数才有使用协程的意义。Go语言中的Goroutine可看作协程的一种实现,参看:理解Goroutine

指针:

void Add(int * a)   // a 是一个int型的指针,a指的地址存放的是int型的数据
{
  a    // 取指针a的值(即其指向内容的地址)
  *a   // 取指针a指向的内容
  &a   // 取存放指针a的值的地址
}

为什么会有指针。从广义上讲,在编码时,我们也可以认为普通变量是指向具体内存的指针,但是代码编译后变量名会被替换为[虚拟]内存地址或者各种寄存器,所以变量只是给程序员编程时操作数据用的,但是有时我们需要操作的是数据所在的内存地址,这个时候普通的变量就没办法了,于是有了我们平常所说的指针的概念。

C++还有引用的概念,可以理解为一个变量的别名,指向同一块内存(即使用引用不会创建对象副本),主要用在方法参数和返回值。不同于C#,C#一开始就在底层区分了值类型和引用类型,而C++传递变量名默认就是值传递(不管是什么数据类型)。很多人对指针和引用傻傻分不清,虽然指针也可以实现引用的功能(毕竟指针直接操作内存地址,你说啥功能实现不了),但是引用更符合它所代表的使用场景。

vector:C++中的一种数据结构,一般作为动态size的数组使用。vector的扩充机制:按照容器现在容量的一倍进行增长。 vector容器分配的是一块连续的内存空间,每次容器的增长,并不是在原有连续的内存空间后再进行简单的叠加, 而是重新申请一块更大的新内存,并把现有容器中的元素逐个复制过去,然后销毁旧的内存。 这时原有指向旧内存空间的迭代器已经失效,所以当操作容器时,迭代器要及时更新。

 
上一篇:[C++ Basic]C++与Java的主要区别


下一篇:某电商平台开发记要——客服系统