Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
whqwjb
go-ethereum
提交
4bb25042
G
go-ethereum
项目概览
whqwjb
/
go-ethereum
与 Fork 源项目一致
从无法访问的项目Fork
通知
1
Star
0
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
G
go-ethereum
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
体验新版 GitCode,发现更多精彩内容 >>
未验证
提交
4bb25042
编写于
9月 10, 2018
作者:
P
Péter Szilágyi
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
consensus/clique, core: chain maker clique + error tests
上级
10bac366
变更
5
隐藏空白更改
内联
并排
Showing
5 changed file
with
193 addition
and
73 deletion
+193
-73
consensus/clique/clique.go
consensus/clique/clique.go
+32
-17
consensus/clique/snapshot.go
consensus/clique/snapshot.go
+8
-8
consensus/clique/snapshot_test.go
consensus/clique/snapshot_test.go
+144
-46
core/chain_makers.go
core/chain_makers.go
+8
-2
miner/worker_test.go
miner/worker_test.go
+1
-0
未找到文件。
consensus/clique/clique.go
浏览文件 @
4bb25042
...
...
@@ -93,27 +93,33 @@ var (
// errMissingSignature is returned if a block's extra-data section doesn't seem
// to contain a 65 byte secp256k1 signature.
errMissingSignature
=
errors
.
New
(
"extra-data 65 byte s
uffix signature
missing"
)
errMissingSignature
=
errors
.
New
(
"extra-data 65 byte s
ignature suffix
missing"
)
// errExtraSigners is returned if non-checkpoint block contain signer data in
// their extra-data fields.
errExtraSigners
=
errors
.
New
(
"non-checkpoint block contains extra signer list"
)
// errInvalidCheckpointSigners is returned if a checkpoint block contains an
// invalid list of signers (i.e. non divisible by 20 bytes, or not the correct
// ones).
// invalid list of signers (i.e. non divisible by 20 bytes).
errInvalidCheckpointSigners
=
errors
.
New
(
"invalid signer list on checkpoint block"
)
// errMismatchingCheckpointSigners is returned if a checkpoint block contains a
// list of signers different than the one the local node calculated.
errMismatchingCheckpointSigners
=
errors
.
New
(
"mismatching signer list on checkpoint block"
)
// errInvalidMixDigest is returned if a block's mix digest is non-zero.
errInvalidMixDigest
=
errors
.
New
(
"non-zero mix digest"
)
// errInvalidUncleHash is returned if a block contains an non-empty uncle list.
errInvalidUncleHash
=
errors
.
New
(
"non empty uncle hash"
)
// errInvalidDifficulty is returned if the difficulty of a block is not either
// of 1 or 2, or if the value does not match the turn of the signer.
// errInvalidDifficulty is returned if the difficulty of a block neither 1 or 2.
errInvalidDifficulty
=
errors
.
New
(
"invalid difficulty"
)
// errWrongDifficulty is returned if the difficulty of a block doesn't match the
// turn of the signer.
errWrongDifficulty
=
errors
.
New
(
"wrong difficulty"
)
// ErrInvalidTimestamp is returned if the timestamp of a block is lower than
// the previous block's timestamp + the minimum block period.
ErrInvalidTimestamp
=
errors
.
New
(
"invalid timestamp"
)
...
...
@@ -122,8 +128,12 @@ var (
// be modified via out-of-range or non-contiguous headers.
errInvalidVotingChain
=
errors
.
New
(
"invalid voting chain"
)
// errUnauthorized is returned if a header is signed by a non-authorized entity.
errUnauthorized
=
errors
.
New
(
"unauthorized"
)
// errUnauthorizedSigner is returned if a header is signed by a non-authorized entity.
errUnauthorizedSigner
=
errors
.
New
(
"unauthorized signer"
)
// errRecentlySigned is returned if a header is signed by an authorized entity
// that already signed a header recently, thus is temporarily not allowed to.
errRecentlySigned
=
errors
.
New
(
"recently signed"
)
// errWaitTransactions is returned if an empty block is attempted to be sealed
// on an instant chain (0 second period). It's important to refuse these as the
...
...
@@ -205,6 +215,9 @@ type Clique struct {
signer
common
.
Address
// Ethereum address of the signing key
signFn
SignerFn
// Signer function to authorize hashes with
lock
sync
.
RWMutex
// Protects the signer fields
// The fields below are for testing only
fakeDiff
bool
// Skip difficulty verifications
}
// New creates a Clique proof-of-authority consensus engine with the initial
...
...
@@ -359,7 +372,7 @@ func (c *Clique) verifyCascadingFields(chain consensus.ChainReader, header *type
}
extraSuffix
:=
len
(
header
.
Extra
)
-
extraSeal
if
!
bytes
.
Equal
(
header
.
Extra
[
extraVanity
:
extraSuffix
],
signers
)
{
return
err
Invalid
CheckpointSigners
return
err
Mismatching
CheckpointSigners
}
}
// All basic checks passed, verify the seal and return
...
...
@@ -481,23 +494,25 @@ func (c *Clique) verifySeal(chain consensus.ChainReader, header *types.Header, p
return
err
}
if
_
,
ok
:=
snap
.
Signers
[
signer
];
!
ok
{
return
errUnauthorized
return
errUnauthorized
Signer
}
for
seen
,
recent
:=
range
snap
.
Recents
{
if
recent
==
signer
{
// Signer is among recents, only fail if the current block doesn't shift it out
if
limit
:=
uint64
(
len
(
snap
.
Signers
)
/
2
+
1
);
seen
>
number
-
limit
{
return
err
Unauthoriz
ed
return
err
RecentlySign
ed
}
}
}
// Ensure that the difficulty corresponds to the turn-ness of the signer
inturn
:=
snap
.
inturn
(
header
.
Number
.
Uint64
(),
signer
)
if
inturn
&&
header
.
Difficulty
.
Cmp
(
diffInTurn
)
!=
0
{
return
errInvalidDifficulty
}
if
!
inturn
&&
header
.
Difficulty
.
Cmp
(
diffNoTurn
)
!=
0
{
return
errInvalidDifficulty
if
!
c
.
fakeDiff
{
inturn
:=
snap
.
inturn
(
header
.
Number
.
Uint64
(),
signer
)
if
inturn
&&
header
.
Difficulty
.
Cmp
(
diffInTurn
)
!=
0
{
return
errWrongDifficulty
}
if
!
inturn
&&
header
.
Difficulty
.
Cmp
(
diffNoTurn
)
!=
0
{
return
errWrongDifficulty
}
}
return
nil
}
...
...
@@ -613,7 +628,7 @@ func (c *Clique) Seal(chain consensus.ChainReader, block *types.Block, results c
return
err
}
if
_
,
authorized
:=
snap
.
Signers
[
signer
];
!
authorized
{
return
errUnauthorized
return
errUnauthorized
Signer
}
// If we're amongst the recent signers, wait for the next block
for
seen
,
recent
:=
range
snap
.
Recents
{
...
...
consensus/clique/snapshot.go
浏览文件 @
4bb25042
...
...
@@ -57,12 +57,12 @@ type Snapshot struct {
Tally
map
[
common
.
Address
]
Tally
`json:"tally"`
// Current vote tally to avoid recalculating
}
// signers implements the sort interface to allow sorting a list of addresses
type
signers
[]
common
.
Address
// signers
Ascending
implements the sort interface to allow sorting a list of addresses
type
signers
Ascending
[]
common
.
Address
func
(
s
signers
)
Len
()
int
{
return
len
(
s
)
}
func
(
s
signers
)
Less
(
i
,
j
int
)
bool
{
return
bytes
.
Compare
(
s
[
i
][
:
],
s
[
j
][
:
])
<
0
}
func
(
s
signers
)
Swap
(
i
,
j
int
)
{
s
[
i
],
s
[
j
]
=
s
[
j
],
s
[
i
]
}
func
(
s
signers
Ascending
)
Len
()
int
{
return
len
(
s
)
}
func
(
s
signers
Ascending
)
Less
(
i
,
j
int
)
bool
{
return
bytes
.
Compare
(
s
[
i
][
:
],
s
[
j
][
:
])
<
0
}
func
(
s
signers
Ascending
)
Swap
(
i
,
j
int
)
{
s
[
i
],
s
[
j
]
=
s
[
j
],
s
[
i
]
}
// newSnapshot creates a new snapshot with the specified startup parameters. This
// method does not initialize the set of recent signers, so only ever use if for
...
...
@@ -214,11 +214,11 @@ func (s *Snapshot) apply(headers []*types.Header) (*Snapshot, error) {
return
nil
,
err
}
if
_
,
ok
:=
snap
.
Signers
[
signer
];
!
ok
{
return
nil
,
errUnauthorized
return
nil
,
errUnauthorized
Signer
}
for
_
,
recent
:=
range
snap
.
Recents
{
if
recent
==
signer
{
return
nil
,
err
Unauthoriz
ed
return
nil
,
err
RecentlySign
ed
}
}
snap
.
Recents
[
number
]
=
signer
...
...
@@ -298,7 +298,7 @@ func (s *Snapshot) signers() []common.Address {
for
sig
:=
range
s
.
Signers
{
sigs
=
append
(
sigs
,
sig
)
}
sort
.
Sort
(
signers
(
sigs
))
sort
.
Sort
(
signers
Ascending
(
sigs
))
return
sigs
}
...
...
consensus/clique/snapshot_test.go
浏览文件 @
4bb25042
...
...
@@ -19,24 +19,18 @@ package clique
import
(
"bytes"
"crypto/ecdsa"
"
math/big
"
"
sort
"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/params"
)
type
testerVote
struct
{
signer
string
voted
string
auth
bool
}
// testerAccountPool is a pool to maintain currently active tester accounts,
// mapped from textual names used in the tests below to actual Ethereum private
// keys capable of signing transactions.
...
...
@@ -50,17 +44,26 @@ func newTesterAccountPool() *testerAccountPool {
}
}
func
(
ap
*
testerAccountPool
)
sign
(
header
*
types
.
Header
,
signer
string
)
{
// Ensure we have a persistent key for the signer
if
ap
.
accounts
[
signer
]
==
nil
{
ap
.
accounts
[
signer
],
_
=
crypto
.
GenerateKey
()
// checkpoint creates a Clique checkpoint signer section from the provided list
// of authorized signers and embeds it into the provided header.
func
(
ap
*
testerAccountPool
)
checkpoint
(
header
*
types
.
Header
,
signers
[]
string
)
{
auths
:=
make
([]
common
.
Address
,
len
(
signers
))
for
i
,
signer
:=
range
signers
{
auths
[
i
]
=
ap
.
address
(
signer
)
}
sort
.
Sort
(
signersAscending
(
auths
))
for
i
,
auth
:=
range
auths
{
copy
(
header
.
Extra
[
extraVanity
+
i
*
common
.
AddressLength
:
],
auth
.
Bytes
())
}
// Sign the header and embed the signature in extra data
sig
,
_
:=
crypto
.
Sign
(
sigHash
(
header
)
.
Bytes
(),
ap
.
accounts
[
signer
])
copy
(
header
.
Extra
[
len
(
header
.
Extra
)
-
65
:
],
sig
)
}
// address retrieves the Ethereum address of a tester account by label, creating
// a new account if no previous one exists yet.
func
(
ap
*
testerAccountPool
)
address
(
account
string
)
common
.
Address
{
// Return the zero account for non-addresses
if
account
==
""
{
return
common
.
Address
{}
}
// Ensure we have a persistent key for the account
if
ap
.
accounts
[
account
]
==
nil
{
ap
.
accounts
[
account
],
_
=
crypto
.
GenerateKey
()
...
...
@@ -69,32 +72,38 @@ func (ap *testerAccountPool) address(account string) common.Address {
return
crypto
.
PubkeyToAddress
(
ap
.
accounts
[
account
]
.
PublicKey
)
}
// testerChainReader implements consensus.ChainReader to access the genesis
// block. All other methods and requests will panic.
type
testerChainReader
struct
{
db
ethdb
.
Database
// sign calculates a Clique digital signature for the given block and embeds it
// back into the header.
func
(
ap
*
testerAccountPool
)
sign
(
header
*
types
.
Header
,
signer
string
)
{
// Ensure we have a persistent key for the signer
if
ap
.
accounts
[
signer
]
==
nil
{
ap
.
accounts
[
signer
],
_
=
crypto
.
GenerateKey
()
}
// Sign the header and embed the signature in extra data
sig
,
_
:=
crypto
.
Sign
(
sigHash
(
header
)
.
Bytes
(),
ap
.
accounts
[
signer
])
copy
(
header
.
Extra
[
len
(
header
.
Extra
)
-
extraSeal
:
],
sig
)
}
func
(
r
*
testerChainReader
)
Config
()
*
params
.
ChainConfig
{
return
params
.
AllCliqueProtocolChanges
}
func
(
r
*
testerChainReader
)
CurrentHeader
()
*
types
.
Header
{
panic
(
"not supported"
)
}
func
(
r
*
testerChainReader
)
GetHeader
(
common
.
Hash
,
uint64
)
*
types
.
Header
{
panic
(
"not supported"
)
}
func
(
r
*
testerChainReader
)
GetBlock
(
common
.
Hash
,
uint64
)
*
types
.
Block
{
panic
(
"not supported"
)
}
func
(
r
*
testerChainReader
)
GetHeaderByHash
(
common
.
Hash
)
*
types
.
Header
{
panic
(
"not supported"
)
}
func
(
r
*
testerChainReader
)
GetHeaderByNumber
(
number
uint64
)
*
types
.
Header
{
if
number
==
0
{
return
rawdb
.
ReadHeader
(
r
.
db
,
rawdb
.
ReadCanonicalHash
(
r
.
db
,
0
),
0
)
}
return
nil
// testerVote represents a single block signed by a parcitular account, where
// the account may or may not have cast a Clique vote.
type
testerVote
struct
{
signer
string
voted
string
auth
bool
checkpoint
[]
string
newbatch
bool
}
// Tests that voting is evaluated correctly for various simple and complex scenarios.
func
TestVoting
(
t
*
testing
.
T
)
{
// Tests that Clique signer voting is evaluated correctly for various simple and
// complex scenarios, as well as that a few special corner cases fail correctly.
func
TestClique
(
t
*
testing
.
T
)
{
// Define the various voting scenarios to test
tests
:=
[]
struct
{
epoch
uint64
signers
[]
string
votes
[]
testerVote
results
[]
string
failure
error
}{
{
// Single signer, no votes cast
...
...
@@ -322,10 +331,49 @@ func TestVoting(t *testing.T) {
votes
:
[]
testerVote
{
{
signer
:
"A"
,
voted
:
"C"
,
auth
:
true
},
{
signer
:
"B"
},
{
signer
:
"A"
},
// Checkpoint block, (don't vote here, it's validated outside of snapshots)
{
signer
:
"A"
,
checkpoint
:
[]
string
{
"A"
,
"B"
}},
{
signer
:
"B"
,
voted
:
"C"
,
auth
:
true
},
},
results
:
[]
string
{
"A"
,
"B"
},
},
{
// An unauthorized signer should not be able to sign blocks
signers
:
[]
string
{
"A"
},
votes
:
[]
testerVote
{
{
signer
:
"B"
},
},
failure
:
errUnauthorizedSigner
,
},
{
// An authorized signer that signed recenty should not be able to sign again
signers
:
[]
string
{
"A"
,
"B"
},
votes
:
[]
testerVote
{
{
signer
:
"A"
},
{
signer
:
"A"
},
},
failure
:
errRecentlySigned
,
},
{
// Recent signatures should not reset on checkpoint blocks imported in a batch
epoch
:
3
,
signers
:
[]
string
{
"A"
,
"B"
,
"C"
},
votes
:
[]
testerVote
{
{
signer
:
"A"
},
{
signer
:
"B"
},
{
signer
:
"A"
,
checkpoint
:
[]
string
{
"A"
,
"B"
,
"C"
}},
{
signer
:
"A"
},
},
failure
:
errRecentlySigned
,
},
{
// Recent signatures should not reset on checkpoint blocks imported in a new
// batch (https://github.com/ethereum/go-ethereum/issues/17593). Whilst this
// seems overly specific and weird, it was a Rinkeby consensus split.
epoch
:
3
,
signers
:
[]
string
{
"A"
,
"B"
,
"C"
},
votes
:
[]
testerVote
{
{
signer
:
"A"
},
{
signer
:
"B"
},
{
signer
:
"A"
,
checkpoint
:
[]
string
{
"A"
,
"B"
,
"C"
}},
{
signer
:
"A"
,
newbatch
:
true
},
},
failure
:
errRecentlySigned
,
},
}
// Run through the scenarios and test them
...
...
@@ -356,28 +404,78 @@ func TestVoting(t *testing.T) {
genesis
.
Commit
(
db
)
// Assemble a chain of headers from the cast votes
headers
:=
make
([]
*
types
.
Header
,
len
(
tt
.
votes
))
for
j
,
vote
:=
range
tt
.
votes
{
headers
[
j
]
=
&
types
.
Header
{
Number
:
big
.
NewInt
(
int64
(
j
)
+
1
),
Time
:
big
.
NewInt
(
int64
(
j
)
*
15
),
Coinbase
:
accounts
.
address
(
vote
.
voted
),
Extra
:
make
([]
byte
,
extraVanity
+
extraSeal
),
config
:=
*
params
.
TestChainConfig
config
.
Clique
=
&
params
.
CliqueConfig
{
Period
:
1
,
Epoch
:
tt
.
epoch
,
}
engine
:=
New
(
config
.
Clique
,
db
)
engine
.
fakeDiff
=
true
blocks
,
_
:=
core
.
GenerateChain
(
&
config
,
genesis
.
ToBlock
(
db
),
engine
,
db
,
len
(
tt
.
votes
),
func
(
j
int
,
gen
*
core
.
BlockGen
)
{
// Cast the vote contained in this block
gen
.
SetCoinbase
(
accounts
.
address
(
tt
.
votes
[
j
]
.
voted
))
if
tt
.
votes
[
j
]
.
auth
{
var
nonce
types
.
BlockNonce
copy
(
nonce
[
:
],
nonceAuthVote
)
gen
.
SetNonce
(
nonce
)
}
})
// Iterate through the blocks and seal them individually
for
j
,
block
:=
range
blocks
{
// Geth the header and prepare it for signing
header
:=
block
.
Header
()
if
j
>
0
{
header
s
[
j
]
.
ParentHash
=
header
s
[
j
-
1
]
.
Hash
()
header
.
ParentHash
=
block
s
[
j
-
1
]
.
Hash
()
}
if
vote
.
auth
{
copy
(
headers
[
j
]
.
Nonce
[
:
],
nonceAuthVote
)
header
.
Extra
=
make
([]
byte
,
extraVanity
+
extraSeal
)
if
auths
:=
tt
.
votes
[
j
]
.
checkpoint
;
auths
!=
nil
{
header
.
Extra
=
make
([]
byte
,
extraVanity
+
len
(
auths
)
*
common
.
AddressLength
+
extraSeal
)
accounts
.
checkpoint
(
header
,
auths
)
}
header
.
Difficulty
=
diffInTurn
// Ignored, we just need a valid number
// Generate the signature, embed it into the header and the block
accounts
.
sign
(
header
,
tt
.
votes
[
j
]
.
signer
)
blocks
[
j
]
=
block
.
WithSeal
(
header
)
}
// Split the blocks up into individual import batches (cornercase testing)
batches
:=
[][]
*
types
.
Block
{
nil
}
for
j
,
block
:=
range
blocks
{
if
tt
.
votes
[
j
]
.
newbatch
{
batches
=
append
(
batches
,
nil
)
}
accounts
.
sign
(
headers
[
j
],
vote
.
signer
)
batches
[
len
(
batches
)
-
1
]
=
append
(
batches
[
len
(
batches
)
-
1
],
block
)
}
// Pass all the headers through clique and ensure tallying succeeds
head
:=
headers
[
len
(
headers
)
-
1
]
chain
,
err
:=
core
.
NewBlockChain
(
db
,
nil
,
&
config
,
engine
,
vm
.
Config
{})
if
err
!=
nil
{
t
.
Errorf
(
"test %d: failed to create test chain: %v"
,
i
,
err
)
continue
}
failed
:=
false
for
j
:=
0
;
j
<
len
(
batches
)
-
1
;
j
++
{
if
k
,
err
:=
chain
.
InsertChain
(
batches
[
j
]);
err
!=
nil
{
t
.
Errorf
(
"test %d: failed to import batch %d, block %d: %v"
,
i
,
j
,
k
,
err
)
failed
=
true
break
}
}
if
failed
{
continue
}
if
_
,
err
=
chain
.
InsertChain
(
batches
[
len
(
batches
)
-
1
]);
err
!=
tt
.
failure
{
t
.
Errorf
(
"test %d: failure mismatch: have %v, want %v"
,
i
,
err
,
tt
.
failure
)
}
if
tt
.
failure
!=
nil
{
continue
}
// No failure was produced or requested, generate the final voting snapshot
head
:=
blocks
[
len
(
blocks
)
-
1
]
snap
,
err
:=
New
(
&
params
.
CliqueConfig
{
Epoch
:
tt
.
epoch
},
db
)
.
snapshot
(
&
testerChainReader
{
db
:
db
},
head
.
Number
.
Uint64
(),
head
.
Hash
(),
headers
)
snap
,
err
:=
engine
.
snapshot
(
chain
,
head
.
NumberU64
(),
head
.
Hash
(),
nil
)
if
err
!=
nil
{
t
.
Errorf
(
"test %d: failed to
creat
e voting snapshot: %v"
,
i
,
err
)
t
.
Errorf
(
"test %d: failed to
retriev
e voting snapshot: %v"
,
i
,
err
)
continue
}
// Verify the final list of signers against the expected ones
...
...
core/chain_makers.go
浏览文件 @
4bb25042
...
...
@@ -67,6 +67,11 @@ func (b *BlockGen) SetExtra(data []byte) {
b
.
header
.
Extra
=
data
}
// SetNonce sets the nonce field of the generated block.
func
(
b
*
BlockGen
)
SetNonce
(
nonce
types
.
BlockNonce
)
{
b
.
header
.
Nonce
=
nonce
}
// AddTx adds a transaction to the generated block. If no coinbase has
// been set, the block's coinbase is set to the zero address.
//
...
...
@@ -190,13 +195,14 @@ func GenerateChain(config *params.ChainConfig, parent *types.Block, engine conse
if
config
.
DAOForkSupport
&&
config
.
DAOForkBlock
!=
nil
&&
config
.
DAOForkBlock
.
Cmp
(
b
.
header
.
Number
)
==
0
{
misc
.
ApplyDAOHardFork
(
statedb
)
}
// Execute any user modifications to the block
and finalize it
// Execute any user modifications to the block
if
gen
!=
nil
{
gen
(
i
,
b
)
}
if
b
.
engine
!=
nil
{
// Finalize and seal the block
block
,
_
:=
b
.
engine
.
Finalize
(
b
.
chainReader
,
b
.
header
,
statedb
,
b
.
txs
,
b
.
uncles
,
b
.
receipts
)
// Write state changes to db
root
,
err
:=
statedb
.
Commit
(
config
.
IsEIP158
(
b
.
header
.
Number
))
if
err
!=
nil
{
...
...
miner/worker_test.go
浏览文件 @
4bb25042
...
...
@@ -161,6 +161,7 @@ func testPendingStateAndBlock(t *testing.T, chainConfig *params.ChainConfig, eng
t
.
Errorf
(
"account balance mismatch: have %d, want %d"
,
balance
,
1000
)
}
b
.
txPool
.
AddLocals
(
newTxs
)
// Ensure the new tx events has been processed
time
.
Sleep
(
100
*
time
.
Millisecond
)
block
,
state
=
w
.
pending
()
...
...
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录