钱包开发经验分享:ETH篇
[TOC]
开发前的准备
工欲善其事,必先利其器
一路开发过来,积累了一些钱包的开发利器和网站,与大家分享一下。这些东西在这行开发过的人都知道,只是给行外打算入行的人做个参考。
-
最好用的ETH钱包–MetaMask
简介:这是一款以太坊浏览器插件,他可以很方便的查看或操作以太坊、erc20代币余额,也方便配合remix之类的合约IDE来部署合约,支持自定义代币,支持多种测试网络和正式网络以及自定义网络节点。总而言之,这是一款十分便利好用的钱包。
-
最官方的区块链浏览器–etherscan
网址:以太坊官方区块链浏览器
简介:这是以太坊最最最官方的区块链浏览器了,对于开发者而言,它不仅仅只是查询区块交易那么简单,他还有更多有利于程序员开发的功能。它提供了众多api和小工具,它是所有测试网络的父域名,可以轻松地切换查看到所有测试网络的区块和交易,在部署合约时,它又协助你发布合约,因此对于开发者而言,这是一个不可缺少的网站。
-
获取测试币的网站–rinkeby、ropsten
简介:以太坊有很多共享的测试网络,这篇博文介绍了各个网络的区别和其区块链浏览器,其中开发者主要使用的区块链浏览器不外乎rinkeby和ropsten,上述两个网址则是这两种测试币的水龙头网站,获取测试币的教程如下:获取rinkeby测试币、获取ropsten测试币。
-
免费的第三方节点接入–王站
网址:infura
简介:对于ETH钱包开发而言,这是个不可或缺的网站,当然,可能也有其他第三方节点免费对用户开放,不过我一直用的是这个网站。这个网站的作用是,我们不用搭建ETH节点也可以正常地进行ETH的开发,我们只需要动动手指注册一个账户,创建我们的项目,就能拿到一个免费接入的ETH节点,而且他还包括了所有流行的测试网络。而我之所以称之为王站,是因为它的网站图标类似一个王字。
-
最便捷的以太坊IDE–remix
网址:remix
简介:对于ETH钱包开发而言,合约开发和部署或许是必不可少的一部分,为什么我会这样说?那是因为在钱包开发中,总会需要对接各种erc20的代币,而我们虽然能够在获得ETH的测试币,但是其他的代币的测试币我们是很难获得的(或者说根本无法获得),而基于erc20协议的代币代码是通用的,所以接入代币钱包的时候,我们往往是考虑自己在测试网络部署一份erc20协议的合约,并自己铸币,以方便进行后续的开发,而结合remix和MetaMask来部署合约,那就是几个步骤的事情。部署合约的流程可以参考这篇教程。
ETH钱包代码参考
真正的知识就在经验中
生成钱包地址、公私钥和助记词/通过助记词恢复钱包地址、公私钥
-
导入依赖
<dependency> <groupId>org.bitcoinj</groupId> <artifactId>bitcoinj-core</artifactId> <version>0.14.7</version> </dependency> <dependency> <groupId>org.web3j</groupId> <artifactId>core</artifactId> <version>4.5.5</version> </dependency>
-
初始化web3j
private final static Web3j web3j = Web3j.build(new HttpService("https://mainnet.infura.io/v3/你自己从infura申请的id"));
-
参考代码
public static Map<String, Object> ethWalletGenerate(String mnemonic, String mnemonicPath, String passWord) { try { DeterministicSeed deterministicSeed = null; List<String> mnemonicArray = null; if (null == mnemonic || 0 == mnemonic.length()) { deterministicSeed = new DeterministicSeed(new SecureRandom(), 128, "", System.currentTimeMillis() / 1000); mnemonicArray = deterministicSeed.getMnemonicCode();// 助记词 } else { deterministicSeed = new DeterministicSeed(mnemonic, null, "", System.currentTimeMillis() / 1000); } byte[] seedBytes = deterministicSeed.getSeedBytes();// 种子 if (null == seedBytes) { logger.error("生成钱包失败"); return null; } //种子对象 DeterministicKey deterministicKey = HDKeyDerivation.createMasterPrivateKey(seedBytes); String[] pathArray = mnemonicPath.split("/");// 助记词路径 for (int i = 1; i < pathArray.length; i++) { ChildNumber childNumber; if (pathArray[i].endsWith("'")) { int number = Integer.parseInt(pathArray[i].substring(0, pathArray[i].length() - 1)); childNumber = new ChildNumber(number, true); } else { int number = Integer.parseInt(pathArray[i]); childNumber = new ChildNumber(number, false); } deterministicKey = HDKeyDerivation.deriveChildKey(deterministicKey, childNumber); } ECKeyPair eCKeyPair = ECKeyPair.create(deterministicKey.getPrivKeyBytes()); WalletFile walletFile = Wallet.createStandard(passWord, eCKeyPair); if (null == mnemonic || 0 == mnemonic.length()) { StringBuilder mnemonicCode = new StringBuilder(); for (int i = 0; i < mnemonicArray.size(); i++) { mnemonicCode.append(mnemonicArray.get(i)).append(" "); } return new HashMap<String, Object>() { private static final long serialVersionUID = -4960785990664709623L; { put("walletFile", walletFile); put("eCKeyPair", eCKeyPair); put("mnemonic", mnemonicCode.substring(0, mnemonicCode.length() - 1)); } }; } else { return new HashMap<String, Object>() { private static final long serialVersionUID = -947886783923530545L; { put("walletFile", walletFile); put("eCKeyPair", eCKeyPair); } }; } } catch (CipherException e) { return null; } catch (UnreadableWalletException e) { return null; } }
其中关于助记词路径(mnemonicPath)的解释请参考这篇文章:关于钱包助记词。erc20代币的钱包地址和ETH的钱包地址是通用的,所以这套代码可以用于生成ETH钱包地址,也可以用于生成erc20钱包地址。
-
测试代码
/** * 生成钱包地址、公私钥、助记词 */ @Test public void testGenerateEthWallet(){ Map<String, Object> wallet = AddrUtil.ethWalletGenerate(null, ETH_MNEMONI_PATH, "123456"); WalletFile walletFile = (WalletFile) wallet.get("walletFile"); String address = walletFile.getAddress(); ECKeyPair eCKeyPair = (ECKeyPair) wallet.get("eCKeyPair"); String privateKey = eCKeyPair.getPrivateKey().toString(16); String publicKey = eCKeyPair.getPublicKey().toString(16); String mnemonic = (String) wallet.get("mnemonic"); logger.warn("address: {}, privateKey: {}, publicKey: {}, mnemonic: {}", address, privateKey, publicKey, mnemonic); } /** * 通过助记词恢复钱包地址、公私钥 */ @Test public void testGenerateEthWalletByMnemonic(){ Map<String, Object> wallet = AddrUtil.ethWalletGenerate("clown cat senior keep problem engine degree modify ritual machine syrup company", ETH_MNEMONI_PATH, "123456"); WalletFile walletFile = (WalletFile) wallet.get("walletFile"); String address = walletFile.getAddress(); ECKeyPair eCKeyPair = (ECKeyPair) wallet.get("eCKeyPair"); String privateKey = eCKeyPair.getPrivateKey().toString(16); String publicKey = eCKeyPair.getPublicKey().toString(16); String mnemonic = (String) wallet.get("mnemonic"); logger.warn("address: {}, privateKey: {}, publicKey: {}, mnemonic: {}", address, privateKey, publicKey, mnemonic); }
进一步,我们或许希望能够从一个唯一的密钥或者助记词去推导出交易所所有的钱包地址和密钥,可以参考这面这套代码:
-
参考代码
/** * 通过助记词和id生成对应的子账户 * * @param mnemonic 助记词 * @param id 派生子id * @return 子账户key */ private static DeterministicKey generateKeyFromMnemonicAndUid(String mnemonic, int id) { byte[] seed = MnemonicUtils.generateSeed(mnemonic, ""); DeterministicKey rootKey = HDKeyDerivation.createMasterPrivateKey(seed); DeterministicHierarchy hierarchy = new DeterministicHierarchy(rootKey); return hierarchy.deriveChild(BIP44_ETH_ACCOUNT_ZERO_PATH, false, true, new ChildNumber(id, false)); } /** * 生成地址 * * @param id 用户id * @return 地址 */ public static String getEthAddress(String mnemonic, int id) { DeterministicKey deterministicKey = generateKeyFromMnemonicAndUid(mnemonic, id); ECKeyPair ecKeyPair = ECKeyPair.create(deterministicKey.getPrivKey()); return Keys.getAddress(ecKeyPair); } /** * 生成私钥 * * @param id 用户id * @return 私钥 */ public static BigInteger getPrivateKey(String mnemonic, int id) { return generateKeyFromMnemonicAndUid(mnemonic, id).getPrivKey(); }
-
测试代码
/** * 通过助记词和用户id生成钱包地址和私钥 */ @Test public void testGenerateEthChildWallet(){ String ethAddress = EthUtil.getEthAddress("clown cat senior keep problem engine degree modify ritual machine syrup company", 1); BigInteger privateKey = EthUtil.getPrivateKey("clown cat senior keep problem engine degree modify ritual machine syrup company", 1); logger.warn("address: {}, privateKey: {}", ethAddress, privateKey); }
获取余额/获取代币余额
-
参考代码
/** * 获取eth余额 * * @param address 传入查询的地址 * @return String 余额 * @throws IOException */ public static String getEthBalance(String address) { EthGetBalance ethGetBlance = null; try { ethGetBlance = web3j.ethGetBalance(address, DefaultBlockParameterName.LATEST).send(); } catch (IOException e) { logger.error("【获取ETH余额失败】 错误信息: {}", e.getMessage()); } // 格式转换 WEI(币种单位) --> ETHER String balance = Convert.fromWei(new BigDecimal(ethGetBlance.getBalance()), Convert.Unit.ETHER).toPlainString(); return balance; }
-
测试代码
/** * 获取ETH余额 */ @Test public void testGetETHBalance(){ String balance = EthUtil.getEthBalance("0x09f20ff67db2c5fabeb9a2c8dd5f6b4afab7887b"); logger.warn("balance: {}", balance); }
-
参考代码
/** * 获取账户代币余额 * * @param account 账户地址 * @param coinAddress 合约地址 * @return 代币余额 (单位:代币最小单位) * @throws IOException */ public static String getTokenBalance(String account, String coinAddress) { Function balanceOf = new Function("balanceOf", Arrays.<Type>asList(new org.web3j.abi.datatypes.Address(account)), Arrays.<TypeReference<?>>asList(new TypeReference<Uint256>() { })); if (coinAddress == null) { return null; } String value = null; try { value = web3j.ethCall(Transaction.createEthCallTransaction(account, coinAddress, FunctionEncoder.encode(balanceOf)), DefaultBlockParameterName.PENDING).send().getValue(); } catch (IOException e) { logger.error("【获取合约代币余额失败】 错误信息: {}", e.getMessage()); return null; } int decimal = getTokenDecimal(coinAddress); BigDecimal balance = new BigDecimal(new BigInteger(value.substring(2), 16).toString(10)).divide(BigDecimal.valueOf(Math.pow(10, decimal))); return balance.toPlainString(); }
-
测试代码
/** * 获取代币余额 */ @Test public void testGetTokenBalance(){ String usdtBalance = EthUtil.getTokenBalance("0x09f20ff67db2c5fabeb9a2c8dd5f6b4afab7887b", "0xdac17f958d2ee523a2206206994597c13d831ec7"); logger.warn("usdtBalance: {}", usdtBalance); }
ETH的地址分为两种,一种为普通的用户地址,另一种则是合约地址,所有代币类型的转账都是向合约地址发起转账,在输入中输入实际入账的信息(地址和数量),各种代币的合约地址可以查阅以太坊最最最官方的区块浏览器。上面参考代码中获取代币精度的代码可以继续参考下面的代码。
获取代币名称、精度和符号
-
参考代码
/** * 查询代币符号 */ public static String getTokenSymbol(String contractAddress) { String methodName = "symbol"; List<Type> inputParameters = new ArrayList<>(); List<TypeReference<?>> outputParameters = new ArrayList<>(); TypeReference<Utf8String> typeReference = new TypeReference<Utf8String>() { }; outputParameters.add(typeReference); Function function = new Function(methodName, inputParameters, outputParameters); String data = FunctionEncoder.encode(function); Transaction transaction = Transaction.createEthCallTransaction("0x0000000000000000000000000000000000000000", contractAddress, data); EthCall ethCall = null; try { ethCall = web3j.ethCall(transaction, DefaultBlockParameterName.LATEST).sendAsync().get(); } catch (InterruptedException e) { logger.error("获取代币符号失败"); e.printStackTrace(); } catch (ExecutionException e) { logger.error("获取代币符号失败"); e.printStackTrace(); } List<Type> results = FunctionReturnDecoder.decode(ethCall.getValue(), function.getOutputParameters()); if (null == results || 0 == results.size()) { return ""; } return results.get(0).getValue().toString(); } /** * 查询代币名称 */ public static String getTokenName(String contractAddr) { String methodName = "name"; List<Type> inputParameters = new ArrayList<>(); List<TypeReference<?>> outputParameters = new ArrayList<>(); TypeReference<Utf8String> typeReference = new TypeReference<Utf8String>() { }; outputParameters.add(typeReference); Function function = new Function(methodName, inputParameters, outputParameters); String data = FunctionEncoder.encode(function); Transaction transaction = Transaction.createEthCallTransaction("0x0000000000000000000000000000000000000000", contractAddr, data); EthCall ethCall = null; try { ethCall = web3j.ethCall(transaction, DefaultBlockParameterName.LATEST).sendAsync().get(); } catch (InterruptedException e) { logger.error("获取代币名称失败"); e.printStackTrace(); } catch (ExecutionException e) { logger.error("获取代币名称失败"); e.printStackTrace(); } List<Type> results = FunctionReturnDecoder.decode(ethCall.getValue(), function.getOutputParameters()); if (null == results || results.size() <= 0) { return ""; } return results.get(0).getValue().toString(); } /** * 查询代币精度 */ public static int getTokenDecimal(String contractAddr) { String methodName = "decimals"; List<Type> inputParameters = new ArrayList<>(); List<TypeReference<?>> outputParameters = new ArrayList<>(); TypeReference<Uint8> typeReference = new TypeReference<Uint8>() { }; outputParameters.add(typeReference); Function function = new Function(methodName, inputParameters, outputParameters); String data = FunctionEncoder.encode(function); Transaction transaction = Transaction.createEthCallTransaction("0x0000000000000000000000000000000000000000", contractAddr, data); EthCall ethCall = null; try { ethCall = web3j.ethCall(transaction, DefaultBlockParameterName.LATEST).sendAsync().get(); } catch (InterruptedException e) { logger.error("获取代币精度失败"); e.printStackTrace(); } catch (ExecutionException e) { logger.error("获取代币精度失败"); e.printStackTrace(); } List<Type> results = FunctionReturnDecoder.decode(ethCall.getValue(), function.getOutputParameters()); if (null == results || 0 == results.size()) { return 0; } return Integer.parseInt(results.get(0).getValue().toString()); }
-
测试代码
/** * 获取代币名称、符号和精度 */ @Test public void testGetTokenInfo(){ String usdtContractAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; String tokenName = EthUtil.getTokenName(usdtContractAddress); String tokenSymbol = EthUtil.getTokenSymbol(usdtContractAddress); int tokenDecimal = EthUtil.getTokenDecimal(usdtContractAddress); logger.warn("name: {}, symbol: {}, decimal: {}", tokenName, tokenSymbol, tokenDecimal); }
获取交易
-
参考代码
/** * 根据区块高度获取区块交易 * @param height 区块高度 * @return */ public static List<Transaction> getTxByHeight(BigInteger height) { List<Transaction> transactions = new ArrayList<>(); try { EthBlock.Block block = web3j.ethGetBlockByNumber(DefaultBlockParameter.valueOf(height), false).send().getBlock(); for (EthBlock.TransactionResult transactionResult : block.getTransactions()) { Transaction transaction = web3j.ethGetTransactionByHash((String) transactionResult.get()).send().getTransaction().get(); transactions.add(transaction); } logger.info("【获取交易数据成功】 区块哈希: {}, 区块高度: {}", block.getHash(), block.getNumber()); } catch (IOException e) { logger.error("【获取交易数据失败】 错误信息: {}", e.getMessage()); return null; } return transactions; } /** * 根据txid获取交易信息 * @param txid 交易哈希 * @return */ public static Transaction getTxByTxid(String txid) { Transaction transaction = null; try { transaction = web3j.ethGetTransactionByHash(txid).send().getTransaction().orElse(null); logger.info("【获取交易信息成功】 {} : {}", txid, new Gson().toJson(transaction)); } catch (IOException e) { logger.info("【获取交易信息失败】 交易哈希: {}, 错误信息: {}", txid, e.getMessage()); return null; } return transaction; } /** * 解析代币交易 * @param transaction 交易对象 * @return */ public static Map<String, Object> getTokenTxInfo(Transaction transaction){ Map<String, Object> result = new HashMap<>(); String input = transaction.getInput(); if(!Erc20Util.isTransferFunc(input)) { return null; } result.put("to", Erc20Util.getToAddress(input)); result.put("amount", Erc20Util.getTransferValue(input).divide(BigDecimal.valueOf(Math.pow(10, getTokenDecimal(transaction.getTo()))))); result.put("txid", transaction.getHash()); result.put("from", transaction.getFrom()); result.put("height", transaction.getBlockNumber()); result.put("txFee", Convert.fromWei(transaction.getGasPrice().multiply(transaction.getGas()).toString(10), Convert.Unit.ETHER)); result.put("gas", transaction.getGas()); result.put("gasPrice", transaction.getGasPrice()); return result; } /** * 解析ETH交易 * @param transaction 交易对象 * @return */ public static Map<String, Object> getEthTxInfo(Transaction transaction){ Map<String, Object> result = new HashMap<>(); result.put("to", transaction.getTo()); result.put("amount", Convert.fromWei(transaction.getValue().toString(10), Convert.Unit.ETHER)); result.put("txid", transaction.getHash()); result.put("from", transaction.getFrom()); result.put("height", transaction.getBlockNumber()); result.put("txFee", Convert.fromWei(transaction.getGasPrice().multiply(transaction.getGas()).toString(10), Convert.Unit.ETHER)); result.put("gas", transaction.getGas()); result.put("gasPrice", transaction.getGasPrice()); return result; }
-
测试代码
/** * 根据txid获取ETH/代币交易信息 */ @Test public void testGetTransactionByTxid(){ Transaction ethTx = EthUtil.getTxByTxid("0xd05798408be19ec0adc5e0a7397b4e9d294b8e136eacc1eb606be45533eb97f1"); Map<String, Object> ethTxInfo = EthUtil.getEthTxInfo(ethTx); Transaction usdtTx = EthUtil.getTxByTxid("0xd5443fad2feafd309f28d86d39af2e3f112b1ca1b8cdce8a2b6b9cdcdef5ad59"); Map<String, Object> usdtTxInfo = EthUtil.getTokenTxInfo(usdtTx); logger.warn("txInfo: {}, usdtTxInfo: {}", new Gson().toJson(ethTxInfo), new Gson().toJson(usdtTxInfo)); } /** * 根据区块高度获取交易 */ @Test public void testGetTransactionByBlockHeight(){ List<Transaction> transactions = EthUtil.getTxByHeight(new BigInteger("9159698")); logger.warn("txCount: {}", transactions.size()); }
ETH/代币离线签名转账
-
参考代码
/** * 发送eth离线交易 * * @param from eth持有地址 * @param to 发送目标地址 * @param amount 金额(单位:eth) * @param credentials 秘钥对象 * @return 交易hash */ public static String sendEthTx(String from, String to, BigInteger gasLimit, BigInteger gasPrice, BigDecimal amount, Credentials credentials) { try { BigInteger nonce = web3j.ethGetTransactionCount(from, DefaultBlockParameterName.PENDING).send().getTransactionCount(); BigInteger amountWei = Convert.toWei(amount, Convert.Unit.ETHER).toBigInteger(); RawTransaction rawTransaction = RawTransaction.createTransaction(nonce, gasPrice, gasLimit, to, amountWei, ""); byte[] signMessage = TransactionEncoder.signMessage(rawTransaction, credentials); return web3j.ethSendRawTransaction(Numeric.toHexString(signMessage)).sendAsync().get().getTransactionHash(); }catch (Exception e) { logger.error("【ETH离线转账失败】 错误信息: {}", e.getMessage()); return null; } } /** * 发送代币离线交易 * * @param from 代币持有地址 * @param to 代币目标地址 * @param value 金额(单位:代币最小单位) * @param coinAddress 代币合约地址 * @param credentials 秘钥对象 * @return 交易hash */ public static String sendTokenTx(String from, String to, BigInteger gasLimit, BigInteger gasPrice, BigInteger value, String coinAddress, Credentials credentials) { try { BigInteger nonce = web3j.ethGetTransactionCount(from, DefaultBlockParameterName.PENDING).send().getTransactionCount(); Function function = new Function( "transfer", Arrays.<Type>asList(new org.web3j.abi.datatypes.Address(to), new org.web3j.abi.datatypes.generated.Uint256(value)), Collections.<TypeReference<?>>emptyList()); String data = FunctionEncoder.encode(function); RawTransaction rawTransaction = RawTransaction.createTransaction(nonce, gasPrice, gasLimit, coinAddress, data); byte[] signMessage = TransactionEncoder.signMessage(rawTransaction, credentials); return web3j.ethSendRawTransaction(Numeric.toHexString(signMessage)).sendAsync().get().getTransactionHash(); }catch (Exception e) { logger.error("【代币离线转账失败】 错误信息: {}", e.getMessage()); return null; } }
-
测试代码
/** * 测试ETH转账 */ @Test public void testETHTransfer() throws Exception{ String from = "0xB7Cd09d73a1719b90469Edf7Aa1942d8f89Ba21f"; String to = "0xF0B8412C211261B68bc797f31F642Aa14fbDC007"; String privateKey = "密钥不可见"; BigDecimal value = Convert.toWei("1", Convert.Unit.WEI); BigInteger gasPrice = EthUtil.web3j.ethGasPrice().send().getGasPrice(); BigInteger gasLimit = EthUtil.web3j.ethEstimateGas(new Transaction(from, null, null, null, to, value.toBigInteger(), null)).send().getAmountUsed(); String txid = EthUtil.sendEthTx(from, to, gasLimit, gasPrice, value, Credentials.create(privateKey)); logger.warn("txid: {}", txid); } /** * 测试代币转账 */ @Test public void testTokenTransfer() throws Exception{ String from = "0xB7Cd09d73a1719b90469Edf7Aa1942d8f89Ba21f"; String to = "0xF0B8412C211261B68bc797f31F642Aa14fbDC007"; String contractAddress = "0x6a26797a73f558a09a47d2dd56fbe03227a31dbb"; String privateKey = "密钥不可见"; BigInteger value = BigDecimal.valueOf(Math.pow(10, EthUtil.getTokenDecimal(contractAddress))).toBigInteger(); BigInteger gasPrice = EthUtil.web3j.ethGasPrice().send().getGasPrice(); BigInteger gasLimit = EthUtil.getTransactionGasLimit(from, to, contractAddress, value); String txid = EthUtil.sendTokenTx(from, to, gasLimit, gasPrice, gasLimit, contractAddress, Credentials.create(privateKey)); logger.warn("txid: {}", txid); }
代码里的合约地址是我在rinkeby测试网络发布的一个测试币:TestCoin Token(TCT),不想自己部署合约的同学可以关注我的公众号,发钱包地址给我,我会发一些测试币到你的钱包地址。在上面的代码里,比较玩味的是关于nonce值的管理,关于nonce值的解释可以参考这篇文章。在上面的代码里,nonce值我们是通过RPC接口直接获取,这样的操作是相对简单但是却耗时最长,因为调用RPC存在网络上的开销。比较成熟的处理方式是在交易信息表中维护一个nonce字段,这样做一方面是发起一笔新的交易的时候可以更快的获取nonce值,另一方面,当交易发生错误(发起了一笔金额错误的交易)的时候,可以及时进行修改,因为以太坊的设计是:你可以发起一笔与之前nonce值一样的交易,去覆盖处于pending状态的交易。
另外,关于矿工费的计算,正常的以太坊交易是矿工费=gasPrice*gasLimit,gasPrice是每一步计算消耗的费用,gasLimit是允许接受的最大计算次数,它们的单位是WEI,关于以太坊的单位,请参考这篇文章。而当你使用这个计算公式来计算代币的手续费时就不怎么精确了,因为代币交易消耗的gas并不是百分百消耗完的,在区块链浏览器交易页面,你能看到,每一笔代币交易都有一个gas使用率,而由于每种代币输入的脚本大小不同,因而无法确定gas的使用率。目前为止,我还没找到一个方法可以去精确计算代币的手续费。
共同学习,写下你的评论
评论加载中...
作者其他优质文章