Web3 中级实战:从钱包签名到链上交互,构建一个可用的 dApp 前端登录与授权流程
很多人第一次做 dApp 前端时,都会把“连接钱包”当成完成了一半。实际上,真正可用的流程至少包括这几步:
- 连接钱包,拿到地址
- 通过签名完成“链下登录”
- 在前端维护会话状态
- 发起链上读写
- 必要时做授权(approve / permit)
- 对失败、拒签、切链、账户切换做完整兜底
我当时第一次把这套流程串起来时,最大的感受是:每一步单看都不难,但串起来就很容易掉坑。比如签名能成功,但后端校验失败;或者交易明明发出去了,前端状态却没更新;又或者授权额度写得太大,埋下安全隐患。
这篇文章我们不讲太虚的概念,而是直接从一个可运行的前端示例入手,带你搭建一套**“登录 + 授权 + 链上交互”**的 dApp 基础流程。技术栈选用:
- React
- ethers v6
- MetaMask / EIP-1193 钱包
- 一个简单的 ERC-20 授权示例
背景与问题
传统 Web 应用里,登录通常是“用户名密码 + 服务端 session / JWT”。但在 Web3 里,用户未必愿意注册账号,钱包地址天然就是身份入口。
于是就出现了一个典型问题:
- 如何证明这个地址真的是用户本人控制的?
- 如何在不发送链上交易的前提下完成登录?
- 登录后如何继续完成链上授权与业务操作?
答案通常是这条链路:
- 钱包连接:拿到地址
- 钱包签名:证明地址控制权
- 服务端验签:换取登录态
- 前端持有 token/session
- 后续链上交互:读合约、发交易、等待确认
- 如果涉及 ERC-20 扣款:先授权,再执行主业务
这套流程听起来很顺,但实际开发中常见的问题是:
- 钱包签名文案不规范,容易被重放攻击
chainId变化后,前端缓存失效- 用户切账户后仍沿用旧登录态
- 授权与主交易分成两笔,用户体验差
- 交易 hash 已返回,但前端误以为“成功”
- 前端只处理 happy path,没有处理拒签、余额不足、gas 估算失败
所以这篇文章的目标不是“能跑就行”,而是做一个接近真实生产流程的中级版模板。
前置知识
如果你已经熟悉这些内容,可以直接跳到实战部分:
- 了解 EOA 钱包地址与私钥签名的基本概念
- 知道链上交易和链下签名不是一回事
- 用过 React 基础 hooks
- 用过
ethers.js
这里补一句容易混淆的点:
- 签名登录:通常不消耗 gas,是链下行为
- 链上交易:会广播到区块链,需要 gas
这两者都可能弹出钱包确认框,但意义完全不同。
环境准备
1. 初始化项目
npm create vite@latest web3-login-demo -- --template react
cd web3-login-demo
npm install
npm install ethers
npm run dev
2. 准备钱包与网络
你需要:
- 安装 MetaMask
- 切到你要测试的网络
- 准备一个测试账户
- 账户里有少量测试币
3. 示例场景说明
为了让示例可运行,我们做这三件事:
- 前端连接 MetaMask
- 通过
personal_sign完成登录签名 - 读取 ERC-20
allowance并执行approve
文中“后端验签”我会给出思路和示例代码,但主项目以“前端演示完整流程”为主。生产环境里,验签必须放服务端。
核心原理
先把整个流程画出来。理解流程图之后,代码会顺很多。
flowchart TD
A[用户打开 dApp] --> B[连接钱包]
B --> C[获取 address 和 chainId]
C --> D[向服务端请求 nonce]
D --> E[拼接签名消息]
E --> F[用户钱包签名]
F --> G[服务端验签]
G --> H[签发 session/JWT]
H --> I[前端保存登录态]
I --> J[读取链上余额/授权状态]
J --> K{是否已授权}
K -- 否 --> L[发起 approve 交易]
K -- 是 --> M[执行主业务交易]
L --> M
1. 钱包连接不是登录
调用 eth_requestAccounts 只能说明用户愿意把地址暴露给你的站点,不代表他完成了身份认证。
也就是说:
- 连接钱包 = 获取地址
- 钱包签名 = 证明地址控制权
- 服务端验签 = 完成登录认证
2. 签名登录为什么能证明身份
因为私钥只有地址控制者拥有。服务端给一个随机 nonce,前端把它放进消息里,请用户签名。服务端再用签名反推出签名者地址,如果等于用户声称的地址,就能认为认证成立。
一个合格的签名消息,至少要包含:
- 域名或应用名
- 钱包地址
- nonce
- chainId
- 时间戳 / 过期时间
- 用途说明(例如“仅用于登录,不会触发链上交易”)
3. 链上授权的本质
很多 dApp 在执行主逻辑前,需要用户允许某个合约代扣代转自己的 ERC-20。这个授权一般通过 approve(spender, amount) 完成。
典型顺序:
- 查询当前
allowance(owner, spender) - 不足则发
approve - 等待交易确认
- 再发主业务交易
4. 为什么前端必须监听钱包事件
钱包状态是会变的,不是一次性静态数据。最常见两个事件:
accountsChangedchainChanged
如果你不监听,用户切了账户或网络,前端还拿旧状态继续请求,很容易出现“地址对不上”或“签名验签失败”。
流程时序图
sequenceDiagram
participant U as 用户
participant W as 钱包
participant F as dApp前端
participant S as 服务端
participant C as 智能合约
U->>F: 打开页面并点击连接钱包
F->>W: eth_requestAccounts
W-->>F: address, chainId
F->>S: 请求 nonce
S-->>F: nonce
F->>W: signMessage(message)
W-->>F: signature
F->>S: address + message + signature
S->>S: recoverAddress 验签
S-->>F: session/JWT
F->>C: allowance(owner, spender)
C-->>F: allowance
alt 授权不足
F->>W: approve 交易签名
W-->>F: txHash
F->>C: 等待确认
end
F->>W: 主业务交易签名
W-->>F: txHash
实战代码(可运行)
下面我们做一个最小可用版本。为了让你复制后能直接看懂,我把代码按模块拆开。
项目结构可以是这样:
src/
main.jsx
App.jsx
abi/
erc20.js
lib/
wallet.js
auth.js
1. ERC-20 ABI
src/abi/erc20.js
export const ERC20_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 amount) returns (bool)"
];
2. 钱包与链上工具函数
src/lib/wallet.js
import { BrowserProvider, Contract, formatUnits, parseUnits } from "ethers";
import { ERC20_ABI } from "../abi/erc20";
export async function getProvider() {
if (!window.ethereum) {
throw new Error("未检测到钱包,请先安装 MetaMask");
}
return new BrowserProvider(window.ethereum);
}
export async function connectWallet() {
const provider = await getProvider();
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
return {
provider,
signer,
address,
chainId: Number(network.chainId)
};
}
export async function getCurrentWallet() {
const provider = await getProvider();
const accounts = await provider.send("eth_accounts", []);
if (!accounts.length) return null;
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
return {
provider,
signer,
address,
chainId: Number(network.chainId)
};
}
export async function signLoginMessage(message) {
const provider = await getProvider();
const signer = await provider.getSigner();
return await signer.signMessage(message);
}
export async function readTokenInfo(tokenAddress, ownerAddress, spenderAddress) {
const provider = await getProvider();
const token = new Contract(tokenAddress, ERC20_ABI, provider);
const [name, symbol, decimals, balance, allowance] = await Promise.all([
token.name(),
token.symbol(),
token.decimals(),
token.balanceOf(ownerAddress),
token.allowance(ownerAddress, spenderAddress)
]);
return {
name,
symbol,
decimals,
balanceRaw: balance,
allowanceRaw: allowance,
balance: formatUnits(balance, decimals),
allowance: formatUnits(allowance, decimals)
};
}
export async function approveToken(tokenAddress, spenderAddress, amount, decimals) {
const provider = await getProvider();
const signer = await provider.getSigner();
const token = new Contract(tokenAddress, ERC20_ABI, signer);
const tx = await token.approve(spenderAddress, parseUnits(amount, decimals));
const receipt = await tx.wait();
return {
hash: tx.hash,
receipt
};
}
3. 签名登录工具函数
src/lib/auth.js
import { verifyMessage } from "ethers";
export function createLoginMessage({ domain, address, chainId, nonce, issuedAt }) {
return [
`${domain} 请求您签名登录`,
``,
`地址: ${address}`,
`链 ID: ${chainId}`,
`Nonce: ${nonce}`,
`签发时间: ${issuedAt}`,
``,
`此签名仅用于登录认证,不会发起链上交易。`
].join("\n");
}
export function mockFetchNonce(address) {
// 实际生产环境应从服务端获取随机 nonce,并与地址绑定存储
const nonce = `nonce-${address.slice(0, 6)}-${Date.now()}`;
return Promise.resolve(nonce);
}
export function mockVerifySignature({ address, message, signature }) {
// 实际生产环境应由服务端完成验签并颁发 session / JWT
const recovered = verifyMessage(message, signature);
const ok = recovered.toLowerCase() === address.toLowerCase();
if (!ok) {
throw new Error("签名验证失败");
}
return Promise.resolve({
token: `mock-jwt-${Date.now()}`,
user: { address }
});
}
4. 主界面
src/App.jsx
import { useEffect, useMemo, useState } from "react";
import { createLoginMessage, mockFetchNonce, mockVerifySignature } from "./lib/auth";
import {
approveToken,
connectWallet,
getCurrentWallet,
readTokenInfo,
signLoginMessage
} from "./lib/wallet";
const DEMO_TOKEN_ADDRESS = "0xYourTokenAddress";
const DEMO_SPENDER_ADDRESS = "0xYourSpenderAddress";
export default function App() {
const [wallet, setWallet] = useState(null);
const [sessionToken, setSessionToken] = useState("");
const [status, setStatus] = useState("未连接");
const [tokenInfo, setTokenInfo] = useState(null);
const [approveAmount, setApproveAmount] = useState("10");
const [loading, setLoading] = useState(false);
const isLoggedIn = useMemo(() => !!sessionToken, [sessionToken]);
async function handleConnect() {
try {
setStatus("连接钱包中...");
const data = await connectWallet();
setWallet(data);
setStatus(`已连接: ${data.address}`);
} catch (err) {
setStatus(`连接失败: ${err.message}`);
}
}
async function handleLogin() {
try {
if (!wallet) throw new Error("请先连接钱包");
setLoading(true);
setStatus("请求 nonce...");
const nonce = await mockFetchNonce(wallet.address);
const message = createLoginMessage({
domain: window.location.host,
address: wallet.address,
chainId: wallet.chainId,
nonce,
issuedAt: new Date().toISOString()
});
setStatus("请在钱包中确认签名...");
const signature = await signLoginMessage(message);
setStatus("验签中...");
const result = await mockVerifySignature({
address: wallet.address,
message,
signature
});
setSessionToken(result.token);
setStatus("登录成功");
} catch (err) {
setStatus(`登录失败: ${err.message}`);
} finally {
setLoading(false);
}
}
async function handleLoadTokenInfo() {
try {
if (!wallet) throw new Error("请先连接钱包");
setLoading(true);
setStatus("读取链上授权状态...");
const data = await readTokenInfo(
DEMO_TOKEN_ADDRESS,
wallet.address,
DEMO_SPENDER_ADDRESS
);
setTokenInfo(data);
setStatus("已获取代币信息");
} catch (err) {
setStatus(`读取失败: ${err.message}`);
} finally {
setLoading(false);
}
}
async function handleApprove() {
try {
if (!wallet) throw new Error("请先连接钱包");
if (!tokenInfo) throw new Error("请先读取代币信息");
setLoading(true);
setStatus("请在钱包中确认授权交易...");
const result = await approveToken(
DEMO_TOKEN_ADDRESS,
DEMO_SPENDER_ADDRESS,
approveAmount,
tokenInfo.decimals
);
setStatus(`授权成功, tx: ${result.hash}`);
await handleLoadTokenInfo();
} catch (err) {
setStatus(`授权失败: ${err.message}`);
} finally {
setLoading(false);
}
}
function handleLogout() {
setSessionToken("");
setStatus("已退出登录");
}
useEffect(() => {
getCurrentWallet().then(setWallet).catch(() => {});
}, []);
useEffect(() => {
if (!window.ethereum) return;
const onAccountsChanged = async (accounts) => {
if (!accounts.length) {
setWallet(null);
setSessionToken("");
setTokenInfo(null);
setStatus("钱包已断开");
return;
}
const current = await getCurrentWallet();
setWallet(current);
setSessionToken("");
setTokenInfo(null);
setStatus("账户已切换,请重新登录");
};
const onChainChanged = async () => {
const current = await getCurrentWallet();
setWallet(current);
setSessionToken("");
setTokenInfo(null);
setStatus("网络已切换,请重新登录并刷新链上状态");
};
window.ethereum.on("accountsChanged", onAccountsChanged);
window.ethereum.on("chainChanged", onChainChanged);
return () => {
window.ethereum.removeListener("accountsChanged", onAccountsChanged);
window.ethereum.removeListener("chainChanged", onChainChanged);
};
}, []);
return (
<div style={{ maxWidth: 820, margin: "40px auto", fontFamily: "sans-serif" }}>
<h1>dApp 登录与授权示例</h1>
<div style={{ padding: 16, border: "1px solid #ddd", borderRadius: 8 }}>
<p><strong>状态:</strong>{status}</p>
<p><strong>地址:</strong>{wallet?.address || "-"}</p>
<p><strong>链 ID:</strong>{wallet?.chainId || "-"}</p>
<p><strong>登录态:</strong>{isLoggedIn ? "已登录" : "未登录"}</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<button onClick={handleConnect} disabled={loading}>连接钱包</button>
<button onClick={handleLogin} disabled={loading || !wallet}>签名登录</button>
<button onClick={handleLogout} disabled={loading}>退出登录</button>
<button onClick={handleLoadTokenInfo} disabled={loading || !wallet}>读取代币信息</button>
</div>
</div>
<div style={{ marginTop: 24, padding: 16, border: "1px solid #ddd", borderRadius: 8 }}>
<h2>授权操作</h2>
<p><strong>Token:</strong> {DEMO_TOKEN_ADDRESS}</p>
<p><strong>Spender:</strong> {DEMO_SPENDER_ADDRESS}</p>
{tokenInfo && (
<div>
<p><strong>名称:</strong>{tokenInfo.name}</p>
<p><strong>符号:</strong>{tokenInfo.symbol}</p>
<p><strong>余额:</strong>{tokenInfo.balance}</p>
<p><strong>当前授权额度:</strong>{tokenInfo.allowance}</p>
</div>
)}
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<input
value={approveAmount}
onChange={(e) => setApproveAmount(e.target.value)}
placeholder="授权数量"
/>
<button onClick={handleApprove} disabled={loading || !tokenInfo}>
发起 approve
</button>
</div>
</div>
</div>
);
}
5. 入口文件
src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
如何让示例真正跑起来
你需要把这两个地址换成真实值:
const DEMO_TOKEN_ADDRESS = "0xYourTokenAddress";
const DEMO_SPENDER_ADDRESS = "0xYourSpenderAddress";
推荐测试方式
- 找一个测试网 ERC-20 合约地址
spender用你自己的测试合约地址,或者先随便填一个你控制的地址用于观察授权状态变化- 保证当前钱包所在网络与 token 所在网络一致
如果地址、网络不匹配,读取 name/symbol/allowance 时就可能直接报错。
生产环境中的服务端验签示例
上面的 mockVerifySignature 只是前端演示。真实项目里一定要服务端验签,否则“登录”没有意义。
下面给一个 Node.js 的最小示例思路:
import express from "express";
import cors from "cors";
import { verifyMessage } from "ethers";
const app = express();
app.use(cors());
app.use(express.json());
const nonceStore = new Map();
app.post("/auth/nonce", (req, res) => {
const { address } = req.body;
const nonce = `nonce-${Date.now()}-${Math.random().toString(36).slice(2)}`;
nonceStore.set(address.toLowerCase(), nonce);
res.json({ nonce });
});
app.post("/auth/verify", (req, res) => {
const { address, message, signature } = req.body;
const lower = address.toLowerCase();
const savedNonce = nonceStore.get(lower);
if (!savedNonce) {
return res.status(400).json({ error: "nonce 不存在或已失效" });
}
if (!message.includes(savedNonce)) {
return res.status(400).json({ error: "消息与 nonce 不匹配" });
}
const recovered = verifyMessage(message, signature);
if (recovered.toLowerCase() !== lower) {
return res.status(401).json({ error: "验签失败" });
}
nonceStore.delete(lower);
res.json({
token: `server-jwt-${Date.now()}`,
address
});
});
app.listen(3001, () => {
console.log("auth server listening on 3001");
});
这里有两个关键点:
nonce必须一次性使用- 验签通过后立即删除
nonce
否则你会面临重放攻击风险。
状态变化模型
前端做 dApp 时,我很建议你把状态机意识带进来。尤其登录和交易,不要只用一个 loading=true/false 糊过去。
stateDiagram-v2
[*] --> 未连接
未连接 --> 已连接未登录: connectWallet
已连接未登录 --> 签名中: 请求签名
签名中 --> 已登录: 验签成功
签名中 --> 已连接未登录: 用户拒签/验签失败
已登录 --> 授权检查中: 查询 allowance
授权检查中 --> 待授权: allowance 不足
授权检查中 --> 可执行业务: allowance 足够
待授权 --> 授权交易中: approve
授权交易中 --> 可执行业务: 交易确认
已登录 --> 已连接未登录: 账户切换/网络切换
这张图的价值在于:你会很清楚什么时候该清 session,什么时候该重新拉链上数据。
逐步验证清单
我建议你按下面顺序验证,不要一上来就全串:
第一步:验证钱包连接
- 点击“连接钱包”
- 页面显示地址与 chainId
- 切换 MetaMask 账户,页面能感知变化
第二步:验证签名登录
- 点击“签名登录”
- 钱包弹出签名框
- 页面显示“登录成功”
- 切换账户后自动失效
第三步:验证链上读取
- 点击“读取代币信息”
- 能看到
name/symbol/balance/allowance
第四步:验证授权
- 输入一个较小额度,如
1 - 点击
approve - 钱包弹出交易确认框
- 交易上链后,重新读取
allowance,额度应变化
如果这四步你都通了,说明整个登录与授权主链路已经打通。
常见坑与排查
这一部分很重要。我踩过不少坑,很多不是代码写错,而是“边界没处理”。
1. 钱包已连接,但 eth_accounts 返回空
现象: 刷新页面后,前端认为用户已安装钱包,但拿不到地址。
原因: 用户没有授权当前站点访问账户,或者钱包处于锁定状态。
排查:
- 先调
eth_accounts - 若为空,再引导用户点击“连接钱包”触发
eth_requestAccounts
2. 签名验签失败
现象: 前端签名成功,服务端却验不过。
常见原因:
- 前后端消息文本不完全一致
- 多了空格、换行不同
- 验签时地址大小写处理不一致
- nonce 已被消费
- 用户切了账户,还是拿旧地址去验
建议:
- 签名前打印 message
- 服务端收到后原样记录 message
- 永远不要在服务端“重拼接一个看起来差不多的 message”去验
3. CALL_EXCEPTION 或读取合约报错
现象:
读取 name()、allowance() 时报错。
常见原因:
- 合约地址不在当前网络
- 地址不是 ERC-20 合约
- RPC 节点异常
- ABI 与目标合约不匹配
排查顺序:
- 确认当前
chainId - 去区块浏览器检查地址是否为合约
- 确认 ABI 是否正确
- 换个 RPC 或钱包网络重试
4. approve 成功返回 tx hash,但前端没更新
现象: 钱包提示交易已提交,但页面 allowance 没变。
原因:
你只是拿到了 tx.hash,还没有等交易确认。
正确做法: 调用:
const receipt = await tx.wait();
之后再重新读取链上状态。
5. 用户拒签或拒绝交易
现象: 钱包弹窗被用户点了取消。
原因: 这是正常用户行为,不是异常系统错误。
建议: 前端提示要明确区分:
- 用户主动拒绝
- 系统执行失败
这样日志和运营分析会清楚很多。
6. accountsChanged 后仍然保留旧 session
现象: 用户切换钱包账户后,页面还显示已登录。
风险: 前端 session 与当前钱包身份不一致,可能导致严重业务混乱。
建议: 一旦账户切换:
- 清 session token
- 清缓存的链上数据
- 强制重新签名登录
安全/性能最佳实践
这部分是中级开发者最该关注的地方。因为“跑通”不难,“用得住”才难。
安全实践 1:签名消息必须包含 nonce 与时效
推荐至少带上:
- 域名
- 地址
- chainId
- nonce
- issuedAt
- statement
原因很简单:避免签名被别处复用、被旧消息重放。
安全实践 2:nonce 一次性、短时有效
服务端规则建议:
- 每次登录请求生成新 nonce
- 验签成功后立刻作废
- 给 nonce 设置过期时间,比如 5 分钟
安全实践 3:不要默认无限授权
很多项目为了减少交互,会直接授权 MaxUint256。这确实方便,但风险也最大。
更稳妥的策略是:
- 高频操作、可信合约:可提供“推荐授权上限”
- 风险较高或新业务:默认最小必要额度
- 在 UI 上明确提示授权对象和额度
我个人建议:默认小额授权,给高级用户自己选择是否无限授权。
安全实践 4:显示 spender 信息
授权页面不要只写“请授权”。要至少显示:
- Token 名称
- 授权额度
- Spender 地址
- 当前网络
否则用户根本不知道自己在批准谁使用资产。
安全实践 5:账户切换、网络切换后立即失效登录态
签名登录本质上是与地址绑定的。账户切换后继续沿用旧 token,非常危险。
最简单可靠的办法是:
- 监听
accountsChanged - 监听
chainChanged - 触发后清空本地会话
性能实践 1:并发读取链上只读数据
像示例中的:
const [name, symbol, decimals, balance, allowance] = await Promise.all([...]);
比串行请求快得多,前端体验会明显更好。
性能实践 2:避免不必要的重复请求
这些数据可以做短时缓存:
- token metadata:
name/symbol/decimals - 当前账户的 allowance
- 登录 session
但注意缓存边界:
- 切链要失效
- 切账户要失效
- 交易确认后要刷新
性能实践 3:交易发送后分阶段反馈
建议把交易状态拆成:
- 等待钱包确认
- 交易已提交
- 链上确认中
- 已确认 / 已失败
用户最怕的是“点了按钮没反应”,而不是多看几行状态提示。
可继续升级的方向
如果你已经把本文示例跑通,下一步可以考虑升级为更贴近生产的方案:
1. 用 SIWE(Sign-In with Ethereum)
比自定义字符串更规范,字段结构更统一,和服务端协作更稳。
2. 接入 wagmi / viem
如果你的项目越来越复杂,手写钱包状态管理会很快变重。wagmi + viem 在 React 生态里会更省心。
3. 支持 Permit / Permit2
有些 token 支持链下签名授权,可以减少一次 approve 交易,显著优化体验。
不过边界也要注意:
- 不是所有 token 都支持
- 前后端与合约都要配合
- 签名结构更复杂,测试成本更高
总结
我们这篇文章完整走了一遍 dApp 前端中非常核心的一条链路:
- 连接钱包
- 使用签名完成链下登录
- 维护前端登录态
- 读取 ERC-20 链上状态
- 发起授权交易
- 监听账户/网络变化并清理状态
如果你只记住三件事,我建议是这三条:
- 连接钱包不等于登录,登录一定要走签名 + 服务端验签
- 授权交易不是“发出去就算完”,要等确认并刷新链上状态
- 账户切换、网络切换必须清理 session 和缓存
最后给一个很实用的落地建议:
- Demo 阶段先用本文这种最小链路跑通
- 上生产前补齐 nonce、过期时间、事件监听、错误分类
- 如果业务涉及资金操作,默认小额授权,不要一上来就无限授权
你可以把这篇里的代码直接作为 dApp 登录与授权模块的起点。先搭起一条稳的主链路,再逐步加上 SIWE、Permit、交易追踪和更精细的状态管理,这样会比一开始堆框架更靠谱。