Solana Pay 开发入门全指南:从基础到实战
2025-05-09 13:38
OpenBuild
2025-05-09 13:38
OpenBuild
2025-05-09 13:38
订阅此专栏
收藏此文章

简介

Solana 社区正在不断改进并拓展网络的功能。但这并不总是意味着要开发全新的技术,有时也意味着以创新的方式利用现有的网络特性。

Solana Pay 就是一个很好的例子。它并没有为网络引入新的功能,而是巧妙地利用了 Solana 网络已有的签名机制,使商家和应用可以发起交易请求,并基于特定交易类型构建 “门控机制”(gating mechanism)。


在本教程中,你将学习如何使用 Solana Pay 创建转账与交易请求、将请求编码为二维码、对交易进行部分签名,并根据你设定的条件对交易进行访问控制。我们希望你不仅能掌握这些技巧,更能将其视为一种创新思路 - 利用网络现有能力进行创造性开发,为你后续构建独特的客户端交互提供灵感与跳板。


Solana Pay

Solana Pay 规范[1]是一组标准,允许用户在各种 Solana 应用程序和钱包中统一使用 URL 请求付款和启动交易。


请求 URL 以 solana: 为前缀,以便平台可以将链接定向到适当的应用程序。例如,在移动设备上,以 solana: 开头的 URL 将被定向到支持 Solana Pay 规范的钱包应用程序。从那里,钱包可以使用 URL 的其余部分来适当地处理请求。


Solana Pay 规范定义了两种类型的请求:

1. 传输请求:用于简单的 SOL 或 SPL Token 传输

2. 交易请求:用于请求任何类型的 Solana 交易


转账请求

转账请求规范描述了一种非交互式的 SOL 或 SPL Token 转账请求方式。转账请求的 URL 格式如下:

solana:<recipient>?<optional-query-params>

其中:

• recipient:是必填项,必须是一个 base58 编码的公钥,表示被请求进行转账的账户地址。

此外,还支持以下可选查询参数:

• amount:一个非负整数或小数,表示请求转账的 Token 数量;

• spl-token:若请求的是 SPL Token(而非 SOL),则该字段为 SPL Token 的铸币账户(mint account)地址,需为 base58 编码的公钥;

• reference:一个或多个 base58 编码的 32 字节数组,用作交易的参考值。由于客户端可能无法获得交易签名,该字段可用于链上识别交易;

* label:一个 URL 编码的 UTF-8 字符串,用于标识此次请求的来源;

• message:一个 URL 编码的 UTF-8 字符串,用于描述此次转账请求的用途或背景;

• memo:一个 UR L 编码的 UTF-8 字符串,必须作为 SPL Memo 指令写入转账交易中。


示例

以下是一个请求转账 1 SOL 的 URL 示例:

solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?

amount=1&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId1234

下面是一个请求 0.1 USDC 转账的 URL 示例:

solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v


交易请求

Solana Pay 的交易请求与转账请求类似,都是钱包可以识别的 URL。但不同的是,交易请求是交互式的,且格式更具扩展性。格式如下:

solana:<link>

其中,link 是一个 URL,钱包会向它发起 HTTP 请求来获取实际的交易信息,而不是像转账请求那样直接包含所有交易数据。


当钱包收到交易请求 URL 时,会发生四件事:

1. GET 请求: 钱包向 link 发起 GET 请求,获取 label(描述)和 icon(图标)用于展示。

2. POST 请求: 钱包随后会发送用户的公钥(public key)到该链接。

3. 服务端构建交易:根据用户公钥(及其他参数),构建交易并返回序列化后的 base64 格式交易。

4. 用户签名并广播交易: 钱包解码交易,提示用户签名并发送。


鉴于交易请求比传输请求更复杂,本教程的其余部分将重点介绍创建交易请求。


创建交易请求

定义 API 端点

开发者需要做的第一步是设置一个 REST API 端点,并将其 URL 包含在交易请求中。在本教程中,我们将使用 Next.jsAPIRoutes[2] ,但您可以选择您最熟悉的堆栈和工具。

在 Next.js 中,可以通过在 pages/api 文件夹中创建一个文件并导出一个处理请求和响应的函数来实现:

 import { NextApiRequest, NextApiResponse } from "next";

 export default async function handler(


 request: NextApiRequest,

 response: NextApiResponse,


){

 // Handle the requlest

 }


处理 GET 请求

钱包在消费交易请求 URL 时会首先发起一个 GET 请求到该端点。你的端点需要返回一个包含以下两个字段的 JSON 对象:

1. label:描述交易请求来源的字符串。

2. icon:一个 URL,指向可供用户查看的图标。

以下是实现的示例代码:

import { NextApiRequest, NextApiResponse } from"next";


exportdefaultasyncfunctionhandler(

  request: NextApiRequest,

  response: NextApiResponse,

) {

if (request.method === "GET") {

   returnget(response);

 }

return response.status(405).json({ error: "Method not allowed" });

}


functionget(response: NextApiResponse) {

 response.status(200).json({

   label: "Store Name",

   icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",

 });

}


当钱包对 API 端点发起 GET 请求时,get 函数会被调用,返回状态码为 200 的响应以及包含 label 和 icon 的 JSON 对象。


处理 POST 请求并构建交易

在发出 GET 请求后,钱包会对同一 URL 发起 POST 请求。POST 请求的 body 会包含一个 JSON 对象,其中 account 字段是请求钱包提供的用户公钥字符串。

以下是处理 POST 请求的步骤:

1. 连接到 Solana 网络并获取最新的 Blockhash

2. 使用区块哈希创建新交易;

3. 向交易中添加指令(instructions);

4. 将交易序列化,并与提示用户的信息一同封装到 PostResponse 对象中返回。

以下是完整代码示例:

import { NextApiRequest, NextApiResponse } from"next";


exportdefaultasyncfunctionhandler(

  request: NextApiRequest,

  response: NextApiResponse,

) {

if (request.method === "GET") {

   returnget(response);

 }

if (request.method === "POST") {

   returnpost(request, response);

 }

return response.status(405).json({ error: "Method not allowed" });

}


functionget(response: NextApiResponse) {

 response.status(200).json({

   label: "Store Name",

   icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",

 });

}

asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {

const { account, reference } = request.body;

const connection = newConnection(clusterApiUrl("devnet"));

const { blockhash } = await connection.getLatestBlockhash();

const transaction = newTransaction({

   recentBlockhash: blockhash,

   feePayer: account,

 });


const instruction = SystemProgram.transfer({

   fromPubkey: newPublicKey(account),

   toPubkey: Keypair.generate().publicKey,

   lamports: 0.001 * LAMPORTS_PER_SOL,

 });


 instruction.keys.push({

   pubkey: reference,

   isSigner: false,

   isWritable: false,

 });


 transaction.add(instruction);

const serializedTransaction = transaction.serialize({

   requireAllSignatures: false,

 });

const base64 = serializedTransaction.toString("base64");

const message = "Simple transfer of 0.001 SOL";


 response.status(200).json({

   transaction: base64,

  message,

 });

}

这部分没有什么特别之处,它采用的交易构建方式与标准的客户端应用中所用的完全相同。唯一的区别在于: 你并不是将交易签名后提交到链上,而是将其作为一个 Base64 编码的字符串,通过 HTTP 响应返回。

随后,发起请求的钱包可以将这笔交易呈现给用户进行签名。


确认交易

您可能已经注意到,前面的示例假定提供了一个 reference 作为查询参数。虽然这不是请求钱包提供的值,但设置初始交易请求 URL 以包含此查询参数是有用的。

由于您的应用程序不是向网络提交交易的应用程序,因此您的代码将无法访问交易签名。这通常是您的应用程序在网络上定位交易并查看其状态的方式。

为了解决这个问题,您可以在每个交易请求中包含一个 reference 参数(一个 Base58 编码的 32 字节数组),并将其作为非签名密钥添加到交易中。这允许您的应用程序使用

getSignaturesForAddress RPC 方法来定位交易。随后,您的应用可以根据交易状态调整其用户界面。

如果使用 @solana/pay 库,可以使用 findReference 辅助函数,而不是直接调用 getSignaturesForAddress


条件交易(Gated Transactions)

我们之前提到过,Solana Pay 是如何通过创造性使用现有功能实现创新网络用例的典型案例。在 Solana Pay 框架下的另一个小例子是:仅在满足特定条件时开放某些交易。

由于您控制着构建交易的端点,可以自主决定交易生效前需要满足的条件。例如,您可以通过 POST 请求中的 account 字段检查终端用户是否持有某个系列的 NFT,或验证该公钥是否位于允许执行此交易的预设账户列表中。

// retrieve array of nfts owned by the given wallet

const nfts = await metaplex.nfts().findAllByOwner({ owner: account }).run();


// iterate over the nfts array

for (let i = 0; i < nfts.length; i++) {

  // check if the current nft has a collection field with the desired value

  if (nfts[i].collection?.address.toString() == collection.toString()) {

   // build transaction

 } else {

   // return an error

 }

}


部分签名(PartialSigning)

如果您希望通过某种限制机制控制交易权限,该功能必须在链上强制执行。虽然从 Solana Pay 端点返回错误会增加终端用户手动发起交易的难度,但他们仍可绕过限制直接构建交易。

这意味着被调用的指令需要某种仅由您的应用程序提供的 "管理员" 签名。然而,这样做会导致之前的示例失效——交易虽然能被构建并发送到请求钱包等待用户签名,但由于缺少管理员签名,提交的交易最终会失败。

幸运的是,Solana 通过部分签名实现了签名可组合性。

在多重签名交易中进行部分签名,允许签名者在交易广播到网络前添加自己的签名。这在以下场景中非常实用:

• 多方授权交易: 例如需要商户和买家共同确认支付细节的场景

• 定制化程序调用:要求用户和管理员共同签名的场景,可限制程序指令的访问权限

const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash()


const transaction = new Transaction({

  feePayer: account,

 blockhash,

 lastValidBlockHeight,

})

...

transaction.partialSign(adminKeypair)


partialSign 函数可在不覆盖已有签名的情况下为交易添加签名。注意:若未指定 feePayer,系统将默认首个签名者作为手续费支付方。为避免混淆,建议始终显式设置该参数。

在前文的 NFT 验证示例中,您只需在将交易编码为 base64 字符串并通过 HTTP 响应返回之前,使用 partialSign 添加管理员签名即可。


Solana Pay 二维码

Solana Pay 的突出特性之一是便捷的二维码集成。由于转账和交易请求本质上是 URL,您可以直接将其嵌入应用程序内外的二维码中。

而 @solana/pay 库通过提供的 createQR 帮助函数简化了这一过程。该函数需要以下参数:

• url:交易请求的 URL(必填)

• size:二维码尺寸(像素,可选,默认 512)

• background:背景色(可选,默认白色)

• color:前景色(可选,默认黑色)

 $const$ $qr=createQR(url$ ,400,"transparent");


实验:使用 Solana Pay 构建寻宝游戏

现在您已经掌握了 Solana Pay 的概念,让我们通过实践来加深理解。我们将使用 Solana Pay 生成一系列二维码,打造一个顺序寻宝游戏。参与者必须按顺序访问每个藏宝点,并使用提供的二维码向追踪用户进度的智能合约提交对应交易。


1. 初始准备

第一步: 从 代码仓库 的 starter 分支下载初始代码。这个基于 Next.js 的应用已包含 Solana Pay 二维码展示功能。注意菜单栏可切换不同二维码(默认展示 SOL 转账示例)。本实验将逐步为各个藏宝点添加功能。

为此,我们将为一个事务请求创建一个新的端点,该端点构建一个用于在 Devnet 上调用 Anchor 程序的事务。该程序专门为这个 “寻宝游戏” 应用程序制作,包含两条指令:

initialize:初始化用户状态

check_in:记录寻宝游戏中某个位置的签入。

程序代码已预置,本实验无需修改,但如果您想熟悉该程序,请随时查看源代码[3]。

在继续之前,请确保熟悉寻宝游戏应用程序的起始代码。

查看 pages/index.tsx、utils/createQrCode/simpleTransfer 和 utils/checkTransaction,以了解发送 SOL 的交易请求是如何设置的。我们将在为签到位置创建交易请求时遵循类似的模式。


2. 环境配置

在继续之前,请确保您可以在本地运行该应用程序。

首先,将 frontend 目录中的 .env.example 文件重命名为 .env。此文件包含一个密钥对,将在本次实验中用于部分签署交易。

接下来,使用 yarn 安装依赖项,然后运行 yarn dev 并在浏览器中打开 localhost:3000(如果端口 3000 已被占用,请使用控制台中显示的端口)。

现在,如果您尝试从移动设备扫描页面上显示的二维码,您会收到一个错误。这是因为二维码设置为将您引导至计算机的 localhost:3000,而这并不是手机可以访问的地址。此外,Solana Pay 需要使用 HTTPSURL 才能正常工作。

为了解决这个问题,你可以使用 ngrok[4]。如果您之前未使用过它,则需要先安装。安装完成后,在终端中运行以下命令,将 3000 替换为您用于此项目的任何端口:

ngrok http 3000

这将为您提供一个唯一的 URL,您可以使用它远程访问本地服务器。输出示例如下:

Session Status        online

Account           your_email@gmail.com (Plan: Free)

Update            update available (version 3.1.0, Ctrl-U to update)

Version            3.0.6

Region            United States (us)

Latency            45ms

Web Interface        http://127.0.0.1:4040

Forwarding          https://7761-24-28-107-82.ngrok.io -> http://localhost:3000

现在,在浏览器中打开控制台中显示的 HTTPS ngrokURL(例如 https://7761-24-28-107-82.ngrok.io[5])。这将允许您在本地测试时从移动设备扫描二维码。


在撰写本文时,这个实验在 Solflare 钱包中效果最佳。一些钱包在扫描 Solana Pay 二维码时可能会显示错误警告信息。无论您使用哪个钱包,请确保将钱包切换到 Devnet。

然后扫描主页上标记为 "SOL Transfer" 的二维码。此二维码是一个参考实现,用于执行简单的 SOL 转账交易请求。它还调用了 requestAirdrop 函数,为您的移动钱包提供 Devnet SOL,因为大多数人没有可用于测试的 Devnet SOL。

如果您能够成功使用二维码执行交易,那么您可以继续进行下一步了!


3. 创建签到交易请求端点

现在您已准备就绪,是时候创建一个支持签到交易请求的端点了,该端点将使用寻宝游戏程序。

首先在 page/api/checkIn.ts 打开文件。请注意,它有一个帮助函数,用于从密钥环境变量初始化 eventOrganer。我们将在这个文件中做的第一件事是:

1. 导出处理程序 函数以处理任意 HTTP 请求

2. 添加 get 和 post 函数来处理这些 HTTP 方法

3. 在 handler 函数体中添加逻辑,根据 HTTP 请求方法调用 get、post 或返回 405 错误

import { NextApiRequest, NextApiResponse } from"next";

exportdefaultasyncfunctionhandler(

  request: NextApiRequest,

  response: NextApiResponse,

) {

if (request.method === "GET") {

   returnget(response);

 }

if (request.method === "POST") {

   returnawaitpost(request, response);

 }

return response.status(405).json({ error: "Method not allowed" });

}


functionget(response: NextApiResponse) {}


asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {}


4. 更新 get 函数

请记住,钱包的第一个请求将是一个 GET 请求,期望端点返回一个标签和图标。更新 get 函数以发送带有 “Scavenger Hunt!” 标签和 Solana 图标的响应。

function get(response: NextApiResponse) {

  response.status(200).json({

     label: "Scavenger Hunt!",

     icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",

  });

}


5. 更新 post 函数

在 GET 请求之后,钱包将向端点发出一个 POST 请求。请求的正文将包含一个 JSON 对象,其中包含一个表示最终用户公钥的 account 字段。

此外,查询参数将包含您编码到二维码中的任何内容。

如果您查看 utils/createQrCode/checkIn.ts,您会注意到此特定应用程序包括以下参数:

1. reference:用于标识交易的随机生成公钥

2. id:位置 ID,表示为整数

继续更新 post 函数以从请求中提取 account、reference 和 id。如果缺少其中任何一个,您应该会返回错误。

接下来,添加一个 try catch 语句,其中 catch 块响应一个错误,try 块调用一个新函数 buildTransaction。如果 buildTransaction 成功,则返回状态码 200 和包含交易及用户已找到该位置的消息的 JSON 对象。暂时不用担心 buildTransaction 函数的逻辑,我们稍后会实现它。

请注意,您还需要从 @solana/web3.js 导入 PublicKey 和 Transaction。

import { NextApiRequest, NextApiResponse } from"next"

import { PublicKey, Transaction } from"@solana/web3.js"

...

asyncfunctionpost(request: NextApiRequest, response: NextApiResponse) {

const { account } = request.body;

const { reference, id } = request.query;


if (!account || !reference || !id) {

  response.status(400).json({ error: "Missing required parameter(s)" });

   return;

 }


try {

   const transaction = awaitbuildTransaction(

    newPublicKey(account),

    newPublicKey(reference),

   id.toString(),

  );


  response.status(200).json({

    transaction: transaction,

    message: You've found location ${id}!,

  });

 } catch (error) {

  console.log(error);

  response.status(500).json({ transaction: "", message: error.message });

  return;

 }

}


async function buildTransaction(

  account: PublicKey,

  reference: PublicKey,

  id: string

): Promise<string> {

  return new Transaction()

}


6. 实现 buildTransaction 函数

接下来,让我们实现 buildTransaction 函数。它应该构建、部分签名并返回签入交易。它需要执行以下步骤:

1. 获取用户状态

2. 使用 locationAtIndex 辅助函数和位置 ID 获取一个 Location 对象

3. 验证用户是否在正确的位置

4. 从连接中获取最新的区块哈希和最后有效区块高度

5. 创建一个新的交易对象

6. 如果用户状态不存在,则向交易中添加 initialize 指令

7. 向交易中添加 check_in 指令

8. 将 reference 公钥添加到 check_in 指令

9. 使用交易组织者的密钥对部分签署交易

10. 使用 base64 编码序列化交易并返回事务

为了简化函数,我们将创建空的辅助函数,稍后将为步骤 1、3、6 和 7-8 填充这些函数。

我们将分别称这些为 fetchUserState 、 verifyCorrectLocation 、 createInitUserInstruction 和 createCheckInInstruction 。

此外,我们需要添加以下导入:

import { NextApiRequest, NextApiResponse } from "next";

import {

  PublicKey,

  Transaction,

  TransactionInstruction,

} from "@solana/web3.js";

import { locationAtIndex, Location, locations } from "../../utils/locations";

import { connection, gameId, program } from "../../utils/programSetup";

使用这些空的辅助函数和新的导入,我们可以填写 buildTransaction 函数:

async functionbuildTransaction(

  account: PublicKey,

  reference: PublicKey,

  id: string,

): Promise<string> {

const userState = awaitfetchUserState(account);

const currentLocation = locationAtIndex(newNumber(id).valueOf());


if (!currentLocation) {

   throw { message: "Invalid location id" };

 }


if (!verifyCorrectLocation(userState, currentLocation)) {

   throw { message: "You must visit each location in order!" };

 }


const { blockhash, lastValidBlockHeight } =

   await connection.getLatestBlockhash();


const transaction = newTransaction({

   feePayer: account,

  blockhash,

  lastValidBlockHeight,

 });


if (!userState) {

  transaction.add(awaitcreateInitUserInstruction(account));

 }


 transaction.add(

   awaitcreateCheckInInstruction(account, reference, currentLocation),

 );

 transaction.partialSign(eventOrganizer);


const serializedTransaction = transaction.serialize({

   requireAllSignatures: false,

 });


const base64 = serializedTransaction.toString("base64");

return base64;

}


interfaceUserState {

user: PublicKey;

gameId: PublicKey;

lastLocation: PublicKey;

}


asyncfunctionfetchUserState(account: PublicKey): Promise<UserState | null> {

returnnull;

}


functionverifyCorrectLocation(

  userState: UserState | null,

  currentLocation: Location,

): boolean {

returnfalse;

}


asyncfunctioncreateInitUserInstruction(

  account: PublicKey,

): Promise<TransactionInstruction> {

throw"";

}


asyncfunctioncreateCheckInInstruction(

  account: PublicKey,

  reference: PublicKey,

  location: Location,

): Promise<TransactionInstruction> {

throw"";

}


7. 实现 fetchUserState 函数

完成 buildTransaction 函数后,我们可以开始实现我们创建的空辅助函数,从 fetchUserState 开始。该函数使用 gameId 和用户的帐户来派生用户状态 PDA,然后获取该账户,如果不存在则返回 null。

async functionfetchUserState(account: PublicKey): Promise<UserState | null> {

const userStatePDA = PublicKey.findProgramAddressSync(

  [gameId.toBuffer(), account.toBuffer()],

  program.programId,

 )[0];


try {

   returnawait program.account.userState.fetch(userStatePDA);

 } catch {

   returnnull;

 }

}


8. 实现 verifyCorrectLocation 函数

接下来,我们实现 verifyCorrectLocation 辅助函数。该函数用于验证用户在寻宝游戏中的正确位置。

如果 userState 为 null,这意味着用户应该访问第一个位置。否则,用户应该访问索引比其上次访问位置大 1 的位置。

如果满足这些条件,该函数将返回 true。否则,它将返回 false。

function verifyCorrectLocation(

  userState: UserState | null,

  currentLocation: Location,

): boolean {

if (!userState) {

   return currentLocation.index === 1;

 }


const lastLocation = locations.find(

   (location) => location.key.toString() === userState.lastLocation.toString(),

 );


if (!lastLocation || currentLocation.index !== lastLocation.index + 1) {

   returnfalse;

 }

returntrue;

}


9. 实现指令创建功能

最后,我们实现 createInitUserInstruction 和 createCheckInInstruction 函数。

它们可以使用 Anchor 来生成并返回相应的指令。唯一需要注意的是,createCheckInInstruction 需要将 reference 添加到指令的密钥列表中。

async functioncreateInitUserInstruction(

  account: PublicKey,

): Promise<TransactionInstruction> {

const initializeInstruction = await program.methods

  .initialize(gameId)

  .accounts({ user: account })

  .instruction();


return initializeInstruction;

}


asyncfunctioncreateCheckInInstruction(

  account: PublicKey,

  reference: PublicKey,

  location: Location,

): Promise<TransactionInstruction> {

const checkInInstruction = await program.methods

  .checkIn(gameId, location.key)

  .accounts({

    user: account,

    eventOrganizer: eventOrganizer.publicKey,

  })

  .instruction();


 checkInInstruction.keys.push({

   pubkey: reference,

   isSigner: false,

   isWritable: false,

 });


return checkInInstruction;

}


10. 测试应用程序

此时,您的应用程序应该可以正常工作了!继续使用您的移动钱包进行测试。首先扫描 Location 1 的二维码。记得确保您的前端使用 ngrokURL 而不是 localhost 运行。

扫描二维码后,您应该会看到一条消息,表明您在 Loacation 1。从那里,扫描 Location 2 页面上的二维码。在继续之前,您可能需要等待几秒钟才能完成上一笔交易。

恭喜,您已成功使用 Solana Pay 完成了寻宝游戏演示!根据您的背景,这可能感觉不直观或不直接。如果是这种情况,请随意再次进行实验或自己制作一些东西。Solana Pay 为弥合现实生活和链上交互之间的差距打开了很多大门。

如果您想查看最终的解决方案代码,您可以在同一存储库[6]的解决方案分支上找到它。



引用链接

[1] Solana Pay 规范:https://docs.solanapay.com/spec

[2]Next.jsAPIRoutes:https://nextjs.org/docs/api-routes/introduction

[3]源代码:https://github.com/Unboxed-Software/anchor-scavenger-hunt

[4]ngrok:https://ngrok.com/

[5]https://7761-24-28-107-82.ngrok.io:https://7761-24-28-107-82.ngrok.io/

[6]同一存储库:https://github.com/Unboxed-Software/solana-scavenger-hunt-app/tree/solution

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

OpenBuild
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开