详解 ERC20 代币及众筹 - 熊丽兵 | Jeth 第一期

编者按:本文系 登链科技CTO 熊丽兵 讲师,在由掘金技术社区主办,以太坊社区基金会、以太坊爱好者与 ConsenSys 协办的《开发者的以太坊入门指南 | Jeth 第一期 - 北京场》 活动上的分享整理。Jeth 围绕以太坊技术开发主题的系列线下活动。每期 Jeth 会邀请以太坊开发领域的优秀技术团队和工程师在线下分享技术干货。旨在为开发者提供线下技术交流互动机会,帮助开发者成长。前端

熊丽兵老师本次活动分享视频回放(B站)

分享整理传送门

智能合约全栈介绍 - Howard | Jeth 第一期git

以太坊智能合约 + DApp 从入门到上线:来自前端工程师的实战指南 - 王仕军 | Jeth 第一期github

熊丽兵老师目前在登链科技担任 CTO,是全网访问量最大的区块链技术博客《深刻浅出区块链》博主,对底层公链技术,区块链技术落地都有深刻研究。熊老师曾前后加入创新工场及猎豹移动,全面负责数款千万级用户开发及管理工做,2014年做为技术合伙人参与建立酷吧时代科技,2016年起重心投入区块链技术领域。编程

很高兴参加掘金技术社区此次举办的《开发者的以太坊入门指南》活动,今天我带来的分享主题是经过代币和众筹来介绍智能合约的开发。我先作一下自我介绍,我叫熊丽兵,应该有一些人看过个人博客《深刻浅出区块链》,我如今在登链科技担任 CTO。bash

我今天分享内容分为图上的四个部分,我是最后一个作分享的讲师,相信在场的观众坚持到如今有点疲惫,可是个人内容很是实用,有不少人拿个人代码已经筹了很多钱,或许个人代码对你也有帮助,但愿你们能认真听。微信

Token 代币是什么

  • 币 → 钱
  • 代币 → 能够替代钱

代币,币其实就是钱,代币能够代替钱——这是我给代币下的一个定义。从这个定义出发,无论是比特币仍是以太币都算代币。咱们今天要讲的这个代币是基于以太坊的智能合约开发出来的。代币不仅仅是能够代替钱,还能够代替不少的东西,能够代替积分,也能够代替一本书或一首歌,以上的均可以用以太坊智能合约来代替。前端工程师

智能合约

智能合约就是以太坊上的程序,是代码和数据(状态)的集合。 智能合约跟人工智能的“智能”是没有关系的,智能合约并不智能。智能合约最先是尼克萨博提出来的一个概念,是指把法律条文程序化,这个理念和以太坊上的程序很是相似,由于法律条文的执行不该该受到任何人的干涉与干扰;以太坊的程序也是同样的,只要你写好代码之后没有任何人能够干扰以太坊程序的运行。数据结构

智能合约有不少编程语言,最多见、被官方推荐的是 Solidity 语言,这个语言的后缀是 .sol 。上图是一个简单的 Hello World,其中 contract 对应着合约,咱们能够把它理解为一个“类”,若是把 contract 变成 class,它就是定义一个“类”了,这跟咱们写别的语言定一个“类”有点类似。所以这个合约的名字就叫作 Hello World,做用是返回一个字符串,这是一个最简单的智能合约。写合约的时候不像其余语言的有main方法,Solidity 中是没有的,每个函数都是单独要用的。app

如何实现代币

咱们再来看看如何来实现一个代币,实现一个代币最根本的是要维护一个账本。这里有一个简单的账本,尾号122账户里面有余额100,尾号123帐户有120,若是咱们要执行从尾号122的帐户转帐10块钱到尾号123的账户的时候,是否是从这个100里面减掉10,从120里面加上10。若是要实现这样一个账本应该怎么作?你们想想,咱们是否是能够把账户这部分(帐号)当成 key,把余额当成 value,这就是 ”键值对“,就是一个Map,或者叫字典。编程语言

Solidity里面有一个数据结构叫做 mapping ,咱们能够用这个关键字 mapping 这样的结构来保存账本信息,这里 Key 是一个地址或一个帐号。 value 表示余额。 另外,咱们要发币的话要设置发行量。以及咱们须要有一个转帐函数,从一个地址转到另一个地址。

咱们看一下如何实现最简单的代币,咱们来看一下这个代币的代码,它只有15行。它定义了一个叫做My Token的合约,它用了mapping 键值对的数据结构,这个类型的键是address(地址类型),值是 uint 无符号型整数的。变量的名字叫做 balanceOf,balance 在这里指代余额。那咱们这个合约里面有两个方法,一个叫作构造函数,另一个是转账的方法。构造函数来初始化发行量,最初的时候全部的发行的货币都在 owner 手里,咱们经过msg.sender得到当前建立合约的人是谁。刚开始发行的时候,全部的代币都在 owner 本身手里,这个就像央行发行货币的时候,刚开始货币都在央行手里是同样的。

而后另一个方法就是transfer,有两个参数:接受者的地址,以及发送的金额。看看具体的实现,第一个用来判断交易条件,即判断他有没有足够的钱完成交易。第二步是作一个溢出的判断,待会儿后面会有一个分析,就是目标这个账户加上这个余额要大于他原来的账户。假如说目标账户的余额接近这个存储的上限,他加上一个值他可能会发生溢出,咱们这里要判断是否会出现这种状况。

第三步和第四步就是作简单的减法和加法,在原账户里面减去或加上一个金额,综上咱们经过这十几行的代码就实现了代币。

ERC-20 标准

  • 什么是 ERC-20
  • 标准包含哪些内容 名称、发行量、统一函数名、事件名

咱们接下来看一下 ERC-20,咱们刚刚已经实现了这个代币,为何还要有 ERC-20?若是钱包要支持代币的转账也好,获取代币的名字也罢,都须要有统一的名字。ERC-20 实际上是一个规范,你们能够点开上方 GitHub 连接中查看规范的具体内容。 ERC-20 包含了名称、发行量、统一的转账名称、受权的函数名以及事件名。

pragma solidity 0.4.20;

contract ERC20Interface {
  string public name;
  string public symbol;
  uint8 public  decimals;
  uint public totalSupply;

  function transfer(address _to, uint256 _value) returns (bool success); function transferFrom(address _from, address _to, uint256 _value) returns (bool success); function approve(address _spender, uint256 _value) returns (bool success); function allowance(address _owner, address _spender) view returns (uint256 remaining); event Transfer(address indexed _from, address indexed _to, uint256 _value); event Approval(address indexed _owner, address indexed _spender, uint256 _value); } 复制代码

这个是ERC20的一个接口文件,咱们来具体看一下它有哪些内容:

name 是咱们须要指定名字,好比说咱们要发生一个掘金Token,它的名字叫作掘金Token。

symbol是 代币的符号,如常见的 BTC、ETH 等。

decimal 是代币最少交易的单位,它表示小数点的位数,若是最少能够交易0.1个代币的话,小数点位数就是1;假如最少交易一个代币,就没有小数点,那这个值就是0。

totalSupply 是指总发行量。

下面几个方法是用来进行转账的: transfer 指代转帐的目标地址,它会提供一个返回值是否转帐成功。

transferFrom 由被委托人调用,由被委托人转移受权人的货币,

approve 是把权限委托给别人,括号里写的是被委托人的地址和被委托了多大的金额

allowance 能够返回被受权委托的金额,委托人能够查询剩下的金额。

TransferApproval 能够监听并记录事件信息,当事件发生时你能够获得通知。

实现ERC-20接口

接下来演示具体的 ERC-20 代码,由于ERC-20 Token的代码有点长,咱们切换到remix看一下代码:

contract ERC20 is ERC20Interface {

    mapping (address => uint256) public balanceOf;
    mapping (address => mapping (address => uint256)) internal allowed;

    constructor() public {
        totalSupply = 1000;
        name = "JueJin Token";
        symbol = "JJT";
        decimals = 0;
        balanceOf[msg.sender] = totalSupply;
    }

  function balanceOf(address _owner) view returns (uint256 balance) {
      return balanceOf[_owner];
  }

  function transfer(address _to, uint _value) public returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[msg.sender] -= _value;
      balanceOf[_to] += _value;
      emit Transfer(msg.sender, _to, _value);
      return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[_from]);
      require(_value <= allowed[_from][msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[_from] -= _value;
      balanceOf[_to] += _value;

      allowed[_from][msg.sender] -= _value;
      emit Transfer(_from, _to, _value);
      return true;
    }

  function approve(address _spender, uint256 _value) returns (bool success) {
      allowed[msg.sender][_spender] = _value;
      emit Approval(msg.sender, _spender, _value);
      return true;
  }

  function allowance(address _owner, address _spender) view returns (uint256 remaining) {
      return allowed[_owner][_spender];
  }

}

复制代码

这个就是标准的ERC-20的代码,它首先 import 了一个接口文件,咱们经过 is 这个方式实现这样一个接口,就像在 Java 里面 extends 同样。在这个合约里面, balanceOf 用来定义每个地址所对应的余额。allowed 中咱们刚刚讲到标准里面有两个方法,一个是 approve 受权,另外一个是代理转账,进行这个过程的时候就须要判断有没有受权,咱们用 allowed 去作这个事情,它的 key 是地址,保存owner,value也是一个mapping,记录被受权人及额度。

constructor() public {
        totalSupply = 1000;
        name = "JueJin Token";
        symbol = "JJT";
        decimals = 0;
        balanceOf[msg.sender] = totalSupply;
    }

  function balanceOf(address _owner) view returns (uint256 balance) {
      return balanceOf[_owner];
  }

复制代码

构造函数很简单,对咱们刚刚指明的信息,好比说名字、总发行量、符号等,对状态的变量作一些初始化。那好比说我这个代币的名字就叫作掘金Token,balanceOf这个函数很简单,它就是返回某一个账号他有多少余额。

function transfer(address _to, uint _value) public returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[msg.sender] -= _value;
      balanceOf[_to] += _value;
      emit Transfer(msg.sender, _to, _value);
      return true;
    }
    
    function transferFrom(address _from, address _to, uint256 _value) returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[_from]);
      require(_value <= allowed[_from][msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[_from] -= _value;
      balanceOf[_to] += _value;

      allowed[_from][msg.sender] -= _value;
      emit Transfer(_from, _to, _value);
      return true;
    }
复制代码

transfer 和咱们刚才说的方法差很少,只多了一步,他发出了这样一个事件,这个也是ERC20他标准里面须要实现的,咱们在实现这样一个转账的时候必需要把这个事件记录下来。那 transferFrom 在转出的时候再也不是由转出的人而是由 From 这个地址发出的,可是 transferFrom 这个方法咱们须要作一个检查,就是必需要有足够受权的额度,当咱们执行了以后须要减掉必定的受权额度,其余的地方都同样。

function approve(address _spender, uint256 _value) returns (bool success) {
      allowed[msg.sender][_spender] = _value;
      emit Approval(msg.sender, _spender, _value);
      return true;
  }
复制代码

咱们再来看一下 approve 这段代码,approve 这个方法我能够受权给其余人来操做我账号下的代币,这个参数是被受权人的地址和受权的额度。这个函数的实现其实就是对于咱们刚刚定义的一个状态变量allowed进行一个赋值,一样这个方法也须要去发出这样的一个事件,即我受权给谁了。

function allowance(address _owner, address _spender) view returns (uint256 remaining) {
      return allowed[_owner][_spender];
  }
复制代码

最后咱们看看 allowance,它就是返回受权额度。

以上就是ERC-20代币的标准实现,有50多行的代码,实现以后就是能够简单的部署一下。

众筹

咱们接着讲众筹,这个是我给众筹的一个定义,就是(约定时间内)向公众募资(约定数量),ICO 意思是首次代币发行,首次代币发行的时候实际上是向公众募资以太币,所以也是一个众筹行为,最出名的项目就是 EOS ,他们在将近一年的时间里募集了721万个 ETH。

实现众筹

那咱们再来看一下如何来实现一个众筹。

  1. 首先要设定众筹的时间,你不能无限期的众筹;而后设定一个目标的金额,还有兑换的价格,由于咱们刚刚讲ICO他实际上是用 ETH 买咱们本身的代币,须要设定一个兑换的价格;此外就是受益人,当咱们众筹完成以后谁可以来提取募集到的 ETH 。

  2. 实现一个以太和代币的一个兑换,当咱们的合约收到别人打过来的 ETH 以后,咱们要给他转对应的代币给他,注意这样的一个过程是被动触发的。

  3. 实现提款/退款,众筹目标完成后,受益人是能够提款的,把全部的 ETH 给提走;若众筹没有完成,应该容许退款,固然这一步不是全部人可以作到的。

而后接着咱们仍是同样,就是咱们来看一下 ICO 的代码来了解如何实现众筹。

pragma solidity ^0.4.16;

interface token {
    function transfer(address receiver, uint amount) external ; } contract Ico {
    address public beneficiary;
    uint public fundingGoal;
    uint public amountRaised;
    
    uint public deadline;
    uint public price;
    token public tokenReward;
    
    mapping(address => uint256) public balanceOf;
    bool crowdsaleClosed = false;

    event GoalReached(address recipient, uint totalAmountRaised);
    event FundTransfer(address backer, uint amount, bool isContribution);


    constructor (
        uint fundingGoalInEthers,
        uint durationInMinutes,
        uint etherCostOfEachToken,
        address addressOfTokenUsedAsReward
    ) public {
        beneficiary = msg.sender;
        fundingGoal = fundingGoalInEthers * 1 ether;
        deadline = now + durationInMinutes * 1 minutes;
        price = etherCostOfEachToken * 1 ether;
        tokenReward = token(addressOfTokenUsedAsReward);
    }


    function () public payable {
        require(!crowdsaleClosed);
        
        uint amount = msg.value;  // wei
        balanceOf[msg.sender] += amount;
  
        amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, amount / price);
        }
        
        

        emit FundTransfer(msg.sender, amount, true);
    }

    modifier afterDeadline() {
        if (now >= deadline) {
            _;
        }
    }


    function checkGoalReached() public afterDeadline {
        if (amountRaised >= fundingGoal) {
            emit GoalReached(beneficiary, amountRaised);
        }
        crowdsaleClosed = true;
    }


    function safeWithdrawal() public afterDeadline {
        
        if (amountRaised < fundingGoal) {
            uint amount = balanceOf[msg.sender];
            balanceOf[msg.sender] = 0;
            if (amount > 0) {
                msg.sender.transfer(amount);
                emit FundTransfer(msg.sender, amount, false);
            }
        }

        if (fundingGoal <= amountRaised && beneficiary == msg.sender) {
            beneficiary.transfer(amountRaised);
            emit FundTransfer(beneficiary, amountRaised, false);
        }
    }
}
复制代码

首先第一步是要设定相关的参数,这些参数是在构造的时候去作设计的,咱们看看有哪些参数。

  • beneficiary 是受益人;
  • fundingGoal 是众筹的目标;
  • amountRaised 表示当前众筹的总额;
  • Deadline 是众筹的截止日期;
  • price 是兑换价格
  • tokenReward 是所关联的代币,实际上咱们须要用代币的一个地址去给他关联起来,待会儿咱们看构造函数的时候就能够看到;
  • mapping(address → uint256) 是用来记录每个参与的众筹人投入了多少 ETH。
  • bool crowdsaleClosed 判断咱们的众筹是否已经关闭了;
  • event GoalReached 记录众筹完成的事件;
  • event FundTransfer 记录转换的事件;
constructor (
        uint fundingGoalInEthers,
        uint durationInMinutes, 
        uint etherCostOfEachToken,
        address addressOfTokenUsedAsReward
    ) public {
        beneficiary = msg.sender;
        fundingGoal = fundingGoalInEthers * 1 ether;
        deadline = now + durationInMinutes * 1 minutes;
        price = etherCostOfEachToken * 1 ether;
        tokenReward = token(addressOfTokenUsedAsReward);
    }
复制代码

构造函数就是咱们须要在建立合约的时候知道的几个参数。

  • uint fundingGoalInEthers 目标总额;
  • unit durationInMinutes 众筹持续时间单位是分钟,这个你们能够随意去调的。
function () public payable {
        require(!crowdsaleClosed);
        
        uint amount = msg.value;  // wei
        balanceOf[msg.sender] += amount;
  
        amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, amount / price);
        }
复制代码

这个函数很奇怪,它没有函数的名字,这个函数会在别人往这个合约打入 ETH 的时候他会被动触发的。咱们经过用户打入的 ETH 的金额,去换算咱们应该给他兑换多少代币。 那首先咱们用msg.value 来得到打给用户的以太币的数量,记录每个人从过去到如今一共打了多少 ETH 。

此外咱们还要有一个变量amountRaised不停地把募集到的金额记录下来,给用户打入对应的代币。 msg.sender是打入以太币的地址,tranfer这个方法正是咱们刚刚写ERC20实现的方法,咱们要用这个方法去给用户发送咱们的代币。

function safeWithdrawal() public afterDeadline {
        
        if (amountRaised < fundingGoal) {
            uint amount = balanceOf[msg.sender];
            balanceOf[msg.sender] = 0;
            if (amount > 0) {
                msg.sender.transfer(amount);
                emit FundTransfer(msg.sender, amount, false);
            }
        } 
        
复制代码

不管是用户退款仍是受益人取ETH,咱们都须要提款,但必需要在众筹结束以后才能够提款,这就是afterDeadLine的用处。afterDeadLine就是一个函数修改器,有点像Python的装饰器,它在函数执行的时候可先进行一些判断,只有符合条件的状况下才会去执行这样的一个函数。那这里必须符合的条件,就是当前的时间必须是在DeadLine以后。 回头来看safeWithdrawal的实现,咱们先要判断当前募集到的总额是否小于目标的金额,若是是小于目标的金额表示众筹失败,失败的话全部参与众筹的人均可以把钱提走。这里面咱们首先拿到用户以前打入的以太币数量,而后经过 API 提供的 transfer 方法,给某一个地址转入对应的以太,经过这个方法能够把用户以前发过来的以太打回去。

if (fundingGoal <= amountRaised && beneficiary == msg.sender) {
            beneficiary.transfer(amountRaised);
            emit FundTransfer(beneficiary, amountRaised, false);
        }
复制代码

这里还有另一个分支就是若是要募集到资金的总额他设定的目标,这样受益人就能够把全部的以太提走。固然这里面还有一个条件,调用这个方法的人必须是当前的受益者,这个应该很好理解,不是说全部人均可以提款,而后咱们一样是调用transfer 的方法把以太转到受益人的地址名下,这样就完成了一个ICO。整体上代码也很少,只有七八十行。

扩展功能

咱们接着来看一下 ERC-20 的扩展功能。

  • 空投你们也接触过,空投是能够不须要打入任何的以太就能够得到对应的Token,因此被不少的项目拿来作营销和推广项目。
amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, 10);
        } else {
            tokenReward.transfer(msg.sender, amount / price);
        }
复制代码

假如说咱们要给每一个帐户空投10个币,代码就能够这么写,学会了就能够发币了。

  • 挖矿本质就是增发。增发很简单,增发是咱们刚刚讲到的总供应量totalSupply,经过函数修改总供应量不就是增发了吗,也就是日常说的挖矿了。
  • 锁定,有一些项目他们怕别人砸盘,因此对代币的转移有一些限制,就是我不让你转,并有分时间段作一些限制,比方说参与众筹以后的三个月内不能转走。锁定的本质就是在代币转账的时候加入了一些控制条件。

常见漏洞分析

美链

美链前段时间炒得比较火,正是由于它这个溢出漏洞,咱们在这个连接中能够了解发生漏洞时交易的状况。在这笔交易的Token Transfer 里面咱们能够看到有巨大数量的币转移到了两个不一样的地址。实际上美链共发行70亿个代币,转移的币远大于发行量,这笔交易形成凭空增发了不少代币,这个漏洞出来了以后全部的交易所都已经关闭了美链的交易,当时美链应该是60多亿市值,而后由于这个漏洞基本上直接归零。

Function: batchTransfer(address[] _receivers, uint256 _value)

MethodID: 0x83f12fec

[0] :0000000000000000000000000000000000000000000000000000000000000040
[1] :8000000000000000000000000000000000000000000000000000000000000000
[2] :0000000000000000000000000000000000000000000000000000000000000002
[3] :000000000000000000000000b4d30cac5124b46c2df0cf3e3e1be05f42119033
[4] :0000000000000000000000000e823ffe018727585eaf5bc769fa80472f76c3d7
复制代码

上方就是页面 Input Data 栏中当时函数调用的状况。这里用的是批量转账的方法,这边传入一个地址,给全部这些地址作转移对的金额。这个攻击的交易把 value 设计得很是巧妙:他是8后面接了63个0,由于 uint 最大存储上限是256位,换算成16进制恰好是64个字符。若是咱们对这个8后面接了63个0的数乘了2,咱们知道一个数乘一个2至关于向左移一位(16进制8是二进制1000),可是他只存了256位,溢出了以后就变成0。

咱们刚刚看到传入的这个是8后面接了63个0,那这样的话unit256 amount = unit256(cnt) * _value中的value 乘以地址的个数的时候乘以了2以后,恰好amount就是0,这就致使这行代码后面的全部检测都会经过,他有一个判断就是他原地址的余额须要大于amount,那么这里溢出后amount是 0。

接下来咱们来看下半部分代码中的条件,balance[msg.sender] = balances[msg.sender].sub(amount)转出了这我的他减去了金额减去了0,可是剩下的这两个传入地址须要加上8后面加63个0这样一个代币的金额,这样的话就对这两个地址,就是至关于平空增长了这么多代币,这个就是他溢出的漏洞。这个其实溢出的漏洞是这个合约里面比较常见的一个漏洞,其实解决方法很简单,就是这里应该去引入SafeMath去作加法,咱们应该全部的算数运算方法都要用SafeMath避免去溢出这样的一个漏洞。

EDU漏洞

我刚刚讲这张合约的时候他有一部是能够受权给其余人转账,就是别人能够表明我去转账,那 EDU 漏洞会在这种状况下触发,即在没有通过受权的状况下别人就能够把钱转走。

我想这个智能合约的做者没有理解transferFrom的意思,他忘了去用 allowed[_from][msg.sender] >= _value) 判断转账的时候是否有足够权限。其实即便他没有加这一句,若是他要是引入了我刚刚讲的SafeMath也能够一样避免这个问题。 每一次执行减法的时候,每一个 mappping 都有为0的默认值。若是他要是引入了 SafeMath 的话,0减去一个值也会发生溢出。由于溢出有两种状况,一种是向上溢出,一个是向下溢出。allowed[_from][msg.sender] 的值是无符号型的整形,他若是是0去减去一个值的话,按照道理值是负数,可是这里uint不保存负数,因此这个值减去以后会变成一个巨大的正整数,就发生了下溢出错误,可是程序依然没有处理到。因此你不管你有多少代币,别人均可以转走。

延伸

  • 代币(Token) 项目的基础,一个能够交易的内容

  • 区块链思惟 没法篡改的双刃剑 OpenZeppelin/SafeMath

最后是一个简单的总结:代币是一个区块链项目的基础,它不仅仅是咱们看到交易的钱,还能够代替不少的交易内容。咱们刚刚讲到了一些漏洞,这就涉及到区块链的思惟,在咱们平时开发的时候讲究的是互联网思惟,即快速迭代和不断试错;可是区块链不能这样作,区块链有一个没法修改的特色,这是把双刃剑。你发布以后没有办法轻易地修改,因此咱们在发布智能合约的时候要很是谨慎,咱们要通过完善的设计还有细致的测试。 推荐一个解决办法是使用OpenZeppelin ,它把编写智能合约最佳的实践作成了一些库,即轮子。咱们在编写智能合约的时候尽可能不要本身去造轮子,而是用他们的代码,由于他们的代码通过不少审查。好比OpenZeppelin 就提供了一些 SafeMath 能避免咱们发送溢出。

以上就是今天的分享,谢谢你们。下方是个人微信二维码,欢迎交流!

相关文章
相关标签/搜索