Posted in

京东链Golang单元测试覆盖率为何卡在63.7%?突破瓶颈的4种Mock策略(含wire依赖注入实战案例)

第一章:京东链Golang单元测试覆盖率瓶颈现象剖析

在京东链(JD Chain)核心模块的持续集成实践中,go test -cover 报告长期稳定在 62%–65% 区间,多个关键包如 core/ledger、crypto/signature 和 network/transport 的覆盖率持续低于 50%,形成显著的“覆盖 plateau”现象。该瓶颈并非源于测试缺失,而是由特定代码结构与测试可及性矛盾导致的系统性障碍。

测试不可达路径的典型成因

  • 私有接口强耦合:ledger.BlockValidator 中调用未导出的 verifyBlockHeaderIntegrity() 函数,其参数含 *internal.HeaderMeta 类型,外部测试无法构造合法实例;
  • 运行时依赖硬编码:network/transport/grpc.go 初始化时直接调用 grpc.Dial("localhost:8080", ...),未提供接口注入点,导致 TestGRPCDialFailure 无法触发连接异常分支;
  • 竞态敏感逻辑:crypto/signature/ecdsa.go 的 SignWithRetry() 方法内嵌 time.Sleep(100 * time.Millisecond),使超时路径在常规测试中几乎不可复现。

覆盖率失真验证方法

执行以下命令可定位“伪高覆盖”区域:

# 生成带行号的详细覆盖报告
go test -coverprofile=coverage.out -covermode=count ./core/ledger/...
go tool cover -func=coverage.out | grep -E "(BlockValidator|verifyBlockHeaderIntegrity)" 
# 输出示例:core/ledger/validator.go:47: verifyBlockHeaderIntegrity 0.00%

该命令揭示:函数被调用但内部分支未执行,count 模式显示具体行命中次数为 0。

关键瓶颈代码片段示例

// crypto/signature/ecdsa.go
func (s *ECDSASigner) SignWithRetry(data []byte, maxRetries int) ([]byte, error) {
    for i := 0; i <= maxRetries; i++ {
        sig, err := s.signOnce(data) // 此函数可能返回 nil, nil(模拟瞬时故障)
        if err == nil && len(sig) > 0 {
            return sig, nil
        }
        if i < maxRetries {
            time.Sleep(time.Duration(i+1) * 100 * time.Millisecond) // 阻塞导致测试超时跳过重试逻辑
        }
    }
    return nil, errors.New("sign failed after retries")
}

此处 i < maxRetries 分支在默认测试中因 time.Sleep 耗时过长而被跳过,需通过 s.signOnce 注入可控错误并替换 time.Sleep 为可 mock 的 sleepFn 才能激活。

瓶颈类型 影响模块 解决前置条件
私有类型封闭 core/ledger 提供 NewHeaderMetaForTest() 工厂函数
硬编码网络地址 network/transport 将 Dial 封装为可替换的 Dialer 接口
时间敏感逻辑 crypto/signature 引入 clock.Clock 依赖注入

第二章:Mock策略原理与京东链场景适配性分析

2.1 接口Mock:基于go-mock实现链上合约调用隔离

在单元测试中,直接调用真实区块链节点会引入网络延迟、状态依赖与不可控性。go-mock 提供轻量级接口模拟能力,精准隔离合约调用逻辑。

核心设计思路

  • 定义 ContractCaller 接口,抽象 CallContract(address, method, args) 方法
  • 使用 mockgen 自动生成 MockContractCaller 实现
  • 在测试中注入 mock 实例,绕过 RPC 调用

示例代码(带注释)

// 定义被 mock 的接口
type ContractCaller interface {
    CallContract(addr string, method string, args ...interface{}) (interface{}, error)
}

// 测试中注入 mock
func TestTransferWithMock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mock := NewMockContractCaller(ctrl)
    mock.EXPECT().CallContract("0xabc", "transfer", "0xdef", big.NewInt(100)).
        Return(map[string]interface{}{"success": true}, nil) // 预设返回值

    service := NewTransferService(mock)
    result, _ := service.Transfer("0xabc", "0xdef", 100)
    assert.True(t, result)
}

逻辑分析:mock.EXPECT() 声明期望调用签名与返回值;gomock 在运行时校验实际调用是否匹配——确保合约交互行为可断言、可复现。参数 addr/method/args 构成链上调用的最小契约单元,mock 仅关注输入输出语义,不触达底层 RPC。

Mock 行为对照表

场景 输入参数 返回值 用途
成功转账 "0xabc", "transfer", ["0xdef", 100] {"success":true} 验证业务流程通路
合约异常 "0xabc", "transfer", ["0xdef", -1] nil, ErrInvalidAmount 测试错误处理分支
graph TD
    A[测试用例] --> B[调用 TransferService.Transfer]
    B --> C[委托 ContractCaller.CallContract]
    C --> D{Mock 拦截}
    D -->|匹配期望| E[返回预设响应]
    D -->|不匹配| F[测试失败 panic]

2.2 HTTP客户端Mock:使用httptest模拟跨链通信网关行为

在跨链网关开发中,依赖真实链端点会导致测试不稳定、耗时且难以覆盖异常场景。httptest.Server 提供轻量级、可编程的HTTP服务桩,是模拟下游链节点响应的理想选择。

构建可定制的网关Mock服务

// 启动模拟跨链查询服务(如返回IBC通道状态)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" || r.URL.Path != "/ibc/core/channel/v1/channels/testchannel/ports/testport" {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "channel": map[string]string{"state": "STATE_OPEN", "ordering": "ORDER_UNORDERED"},
    })
}))
defer mockServer.Close()

该代码启动一个仅响应特定IBC通道查询路径的本地服务器;mockServer.URL 可直接注入网关客户端配置,实现零外部依赖测试。http.Error 显式控制错误路径,便于验证网关的容错逻辑。

关键参数说明

  • httptest.NewServer 返回带自动端口分配与生命周期管理的*httptest.Server
  • HandlerFunc 内可自由匹配路径、方法、Header,精准复现链端点契约
  • defer mockServer.Close() 确保资源及时释放,避免端口占用
模拟场景 响应状态 用途
正常通道查询 200 验证数据解析与路由逻辑
通道未找到 404 测试错误传播与重试机制
网络超时(阻塞) — 配合Client.Timeout验证
graph TD
    A[网关客户端] -->|GET /ibc/channels/...| B(mockServer)
    B --> C{路径/方法匹配?}
    C -->|是| D[返回预设JSON]
    C -->|否| E[返回404]

2.3 数据库Mock:通过sqlmock拦截LevelDB封装层读写路径

LevelDB 本身不支持 SQL 接口,因此“sqlmock”无法直接拦截其原生 API。实际工程中,常见做法是在 LevelDB 封装层之上构建一层 SQL 兼容的抽象层(如用 go-sql-driver 风格接口包装 github.com/syndtr/goleveldb),再通过 sqlmock 拦截该抽象层的 Query/Exec 调用。

Mock 层定位与适配逻辑

  • 封装层需实现 database/sql/driver.Driver 接口
  • 所有 Put/Get/Delete 操作映射为虚拟 SQL(如 INSERT INTO kv (k,v) VALUES (?,?))
  • sqlmock 仅感知 SQL 字符串,不关心底层存储

示例:注册 Mock Driver 并拦截写入

// 注册自定义 LevelDB SQL 驱动
sql.Register("leveldb-mock", &leveldbDriver{})
db, mock, _ := sqlmock.New(sqlmock.WithDriverName("leveldb-mock"))

// 拦截 INSERT 操作(对应 LevelDB Put)
mock.ExpectExec(`INSERT INTO kv \(k,v\) VALUES \(\?,\?\)`).
    WithArgs("user:1001", `{"id":1001,"name":"Alice"}`).
    WillReturnResult(sqlmock.NewResult(1, 1))

逻辑分析:WithArgs 断言传入的 key-value 对;NewResult(1,1) 表示影响 1 行、LastInsertId=1(语义模拟);WillReturnResult 触发封装层内部 db.Put() 调用。

拦截路径对比表

操作类型 原生 LevelDB 调用 映射 SQL 语句 sqlmock 匹配方式
写入 db.Put(k,v,opt) INSERT INTO kv (k,v) ... ExpectExec()
查询 db.Get(k,opt) SELECT v FROM kv WHERE k=? ExpectQuery()
删除 db.Delete(k,opt) DELETE FROM kv WHERE k=? ExpectExec()
graph TD
    A[业务代码调用 db.Exec] --> B[leveldbDriver.ServeHTTP? No]
    B --> C[解析SQL → 提取key/value]
    C --> D[调用底层 goleveldb.Put/Get]
    D --> E[返回结果]
    F[sqlmock] -.拦截 .-> A

2.4 时间依赖Mock:利用clock包解耦区块时间戳生成逻辑

在区块链测试中,时间敏感逻辑(如质押解锁、投票窗口)常因真实时间不可控而难以验证。clock 包提供可替换的 Clock 接口,使时间生成逻辑彻底脱离系统时钟。

核心抽象与注入方式

  • clock.RealClock{}:生产环境默认实现,调用 time.Now()
  • clock.NewMock():测试专用,支持 Add() 和 Set() 精确控制虚拟时间
  • 通过构造函数或接口参数注入,实现零耦合

模拟区块时间推进示例

clk := clock.NewMock()
blockchain := NewChain(clk) // 注入Mock Clock

clk.Add(12 * time.Second) // 模拟推进12秒(一个区块间隔)
blockchain.MineBlock()      // 此时区块时间戳 = 初始时间 + 12s

该代码将区块生成时间锚定到虚拟时钟,避免竞态与等待;Add() 参数为相对偏移量,单位为 time.Duration,精确控制每轮共识的时间步进。

测试场景覆盖对比

场景 真实时钟 Mock Clock
跨时段状态验证 ❌ 需真实等待 ✅ Set() 快速跳转
时间边界条件测试 ❌ 难以触发 ✅ Add(1ns) 精确逼近
graph TD
    A[业务逻辑调用 Now()] --> B[Clock 接口]
    B --> C{实现选择}
    C -->|RealClock| D[time.Now()]
    C -->|MockClock| E[内存中当前虚拟时间]
    E --> F[Add/Set 修改]

2.5 共识模块Mock:定制ized MockConsensusEngine模拟PBFT状态机

为精准验证上层业务对PBFT状态机的依赖,MockConsensusEngine 提供可编程的状态跃迁控制能力。

核心设计原则

  • 状态隔离:每个 mock 实例维护独立的 view, seq, committed 三元组
  • 事件驱动:通过 AdvanceTo(seq, view) 主动触发预设状态迁移
  • 响应可控:支持返回 Prepared/Committed/ViewChange 等任意合法PBFT消息类型

关键代码片段

func (m *MockConsensusEngine) HandleRequest(req *pbft.Request) (*pbft.Reply, error) {
    m.mu.Lock()
    defer m.mu.Unlock()

    // 模拟节点本地状态检查(如是否在主节点、是否已提交该序号)
    if req.Seq <= m.committed {
        return &pbft.Reply{Type: pbft.REPLY_COMMITTED}, nil
    }
    if req.Seq == m.prepared+1 {
        m.prepared = req.Seq // 模拟Prepare阶段完成
        return &pbft.Reply{Type: pbft.REPLY_PREPARED}, nil
    }
    return &pbft.Reply{Type: pbft.REPLY_IGNORED}, errors.New("out-of-order")
}

逻辑分析:该方法模拟PBFT副本对客户端请求的本地响应逻辑。req.Seq 表示请求序列号;m.committed 和 m.prepared 是可注入的mock状态变量,用于控制“已提交”与“已准备”边界。返回 REPLY_IGNORED 可复现网络乱序或落后节点行为。

支持的模拟场景对照表

场景 设置参数 触发效果
正常三阶段提交 committed=10, prepared=10 直接返回 COMMITTED
Prepare卡顿 prepared=9, committed=8 对 seq=10 返回 PREPARED
视图切换中 view=5, isViewChanging=true 返回 VIEWCHANGE 消息
graph TD
    A[HandleRequest] --> B{req.Seq ≤ committed?}
    B -->|Yes| C[Return COMMITTED]
    B -->|No| D{req.Seq == prepared+1?}
    D -->|Yes| E[Update prepared; Return PREPARED]
    D -->|No| F[Return IGNORED]

第三章:Wire依赖注入在测试上下文中的深度集成

3.1 Wire Provider重构:为测试场景声明式注入Mock依赖树

Wire Provider重构的核心目标是解耦依赖构建逻辑与运行时环境,使测试阶段能精准替换整棵依赖树。

声明式Mock注入机制

通过wire.Build()参数化注入策略,将*wire.Set与*wire.Value组合成可插拔的Provider链:

// test_wire.go
func TestSet() *wire.ProviderSet {
  return wire.NewSet(
    wire.Struct(new(MockDB), "*"),
    wire.Struct(new(MockCache), "*"),
    wire.Bind(new(Repository), new(*MockDB)),
  )
}

wire.Struct声明实例构造方式;wire.Bind建立接口→实现的映射关系;*通配符自动注入所有字段依赖,避免手动传递Mock。

依赖树对比表

场景 真实Provider Mock Provider
数据源 *sql.DB *MockDB
缓存层 redis.Client *MockCache
注入方式 wire.NewSet(...) wire.NewSet(TestSet())

流程可视化

graph TD
  A[wire.Build] --> B{环境检测}
  B -->|test| C[TestSet Provider]
  B -->|prod| D[ProdSet Provider]
  C --> E[MockDB → MockCache → Service]
  D --> F[SQLDB → Redis → Service]

3.2 测试专用Injector构建:分离prod与test的依赖图拓扑

在依赖注入框架中,生产环境与测试环境需拥有独立的依赖图拓扑,避免测试污染真实服务或引入不可控副作用。

为何需要专用Injector?

  • 测试应使用内存数据库、Mock服务、同步调度器等轻量替代品
  • 生产Injector必须禁用测试桩,反之亦然
  • 单一Injector易导致@Inject行为在不同环境不一致

构建方式示例(Dagger 2)

// TestAppModule.kt
@Module(includes = [TestNetworkModule::class, InMemoryDatabaseModule::class])
object TestAppModule {
    @Provides
    fun provideScheduler(): Scheduler = Schedulers.trampoline() // 确保同步执行,便于断言
}

此模块仅被TestAppComponent引用,与ProdAppComponent完全隔离;Schedulers.trampoline()确保Rx链立即执行,消除异步竞态,参数trampoline()表示将任务压入当前线程队列顺序执行。

环境隔离策略对比

维度 生产Injector 测试Injector
数据源 PostgreSQL H2 in-memory
网络客户端 OkHttp + TLS MockWebServer
调度器 IoScheduler TrampolineScheduler
graph TD
    A[TestAppComponent] --> B[TestAppModule]
    B --> C[InMemoryDatabaseModule]
    B --> D[MockNetworkModule]
    E[ProdAppComponent] --> F[ProdAppModule]
    F --> G[PostgresModule]
    F --> H[OkHttpModule]

3.3 依赖生命周期管理:确保Mock实例在TestMain中正确初始化与清理

Go 测试中,TestMain 是全局测试入口,适用于需跨包/跨测试用例共享的 Mock 初始化与销毁。

为何必须在 TestMain 中统一管理?

  • 避免 TestXxx 中重复构造/释放开销
  • 防止并发测试间 Mock 状态污染
  • 确保 os.Exit 前资源被显式回收

典型安全初始化模式

func TestMain(m *testing.M) {
    // 初始化全局 Mock 实例
    mockDB = NewMockDB()
    mockHTTP = NewMockHTTPClient()

    // 执行测试套件
    code := m.Run()

    // 清理:关闭连接、释放 goroutine、重置状态
    mockDB.Close()
    mockHTTP.Reset()

    os.Exit(code)
}

mockDB.Close() 释放数据库连接池;mockHTTP.Reset() 清空已注册的 stub 请求队列,避免残留响应干扰后续测试。

生命周期关键阶段对比

阶段 操作 风险点
初始化 构造 Mock 实例 未加锁时并发写入导致 panic
执行测试 复用 Mock 实例 状态未隔离引发测试污染
清理 显式释放资源 忘记调用 → 资源泄漏
graph TD
    A[TestMain 开始] --> B[Mock 初始化]
    B --> C[运行所有测试]
    C --> D[Mock 清理]
    D --> E[os.Exit]

第四章:京东链核心模块覆盖率攻坚实战

4.1 区块同步器模块:Mock P2P网络层实现全路径覆盖

数据同步机制

区块同步器需覆盖主链同步、分叉回滚、空块跳过等全路径。Mock P2P 层通过状态机模拟网络行为,避免真实网络依赖。

核心 Mock 实现

class MockNetwork:
    def __init__(self):
        self.peers = {"node-001": "main", "node-002": "fork-A"}  # 模拟节点角色
        self.latency_ms = {("node-001", "node-002"): 85}         # 精确延迟建模

    def broadcast_block(self, block: Block) -> List[str]:
        return [peer for peer in self.peers if self.peers[peer] == block.chain_id]

逻辑分析:broadcast_block() 根据区块所属链标识(如 "main")筛选目标对等节点,实现链隔离同步;latency_ms 字典支持按路径注入可变延迟,用于验证超时重传逻辑。

路径覆盖能力对比

场景 Mock 支持 真实 P2P 难度
网络分区 ✅ ⚠️ 难稳定复现
分叉共识竞争 ✅ ❌ 需多节点协调
异常消息丢包 ✅ ⚠️ 依赖底层工具

同步状态流转

graph TD
    A[Idle] -->|StartSync| B[FetchingHeaders]
    B --> C[ValidatingChain]
    C -->|Success| D[DownloadingBlocks]
    C -->|ReorgDetected| E[RollbackAndSwitch]

4.2 交易池模块:注入可控TxValidator与MockMempool突破分支盲区

测试驱动的验证器注入机制

通过依赖注入替换 TxValidator 实现,使交易校验逻辑可预测、可断言:

// 构建可控验证器:始终接受指定txHash,其余拒绝
mockValidator := &MockTxValidator{
    AcceptHash: common.HexToHash("0xabc123..."),
    RejectOnMismatch: true,
}
mempool := NewMempool(mockValidator, mockBackend)

该实现绕过真实签名/nonce校验,聚焦路径覆盖——当 tx.Hash() == AcceptHash 时返回 nil(验证通过),否则返回 ErrInvalidTx。RejectOnMismatch 控制失败行为,支持模拟不同错误分支。

MockMempool 的状态隔离能力

特性 真实 Mempool MockMempool
并发安全 ✅ ❌(单goroutine)
交易去重 基于Hash 可禁用
分支覆盖率提升点 — 支持空池/满池/冲突池三态切换

验证路径可视化

graph TD
    A[SubmitTx] --> B{TxValidator.Validate}
    B -->|AcceptHash匹配| C[AddToPool]
    B -->|不匹配| D[Return ErrInvalidTx]
    C --> E[NotifyNewTx]

4.3 智能合约沙箱:替换WASM runtime为可断点MockExecutor

为支持合约调试,需将原生WASM runtime替换为具备断点能力的MockExecutor,其本质是轻量级指令模拟器,不依赖底层引擎,却能精确复现执行上下文。

核心设计原则

  • 指令级单步控制(step() + set_breakpoint())
  • 内存与寄存器状态快照可序列化
  • 兼容WASI ABI接口,零修改接入现有合约

MockExecutor关键API

pub struct MockExecutor {
    pub state: ExecutorState, // 包含pc、stack、memory等
    breakpoints: HashMap<u32, BreakpointAction>,
}

impl MockExecutor {
    pub fn step(&mut self) -> Result<StepResult, ExecError> { /* 单步执行并检查断点 */ }
    pub fn set_breakpoint(&mut self, pc: u32) { self.breakpoints.insert(pc, Default::default()); }
}

step()返回StepResult含当前PC、内存变化diff及是否命中断点;set_breakpoint()以WASM字节码偏移地址为键,支持条件断点扩展。

能力 WASM Runtime MockExecutor
断点支持 ❌ ✅
启动开销 高(JIT编译) 极低(纯解释)
调试信息粒度 函数级 指令级
graph TD
    A[合约字节码] --> B[MockExecutor加载]
    B --> C{是否命中断点?}
    C -->|是| D[暂停+导出state快照]
    C -->|否| E[继续step执行]

4.4 跨链中继模块:构造双链MockChainClient验证消息路由逻辑

为验证跨链消息在源链与目标链间的精准路由,我们构建一对轻量级 MockChainClient 实例——分别模拟 ChainA(发送端)与 ChainB(接收端)。

消息路由核心流程

// MockChainClient.ts 中关键路由方法
public async relayMessage(msg: CrossChainMessage): Promise<boolean> {
  const route = this.routeTable[msg.targetChainId]; // 查路由表
  return route && route.isActive ? this.sendTo(route.endpoint) : false;
}

routeTable 是预置的映射结构,targetChainId 决定是否触发转发;isActive 校验链状态,避免向离线链投递。

双链协作验证要点

  • ✅ 消息携带唯一 nonce 与 sourceChainId
  • ✅ ChainA 客户端调用 relayMessage() 后,ChainB 客户端需在 onMessageReceived 回调中校验签名与路由路径
  • ✅ 路由失败时返回明确错误码(如 ERR_NO_ROUTE)
错误码 含义
ERR_NO_ROUTE 目标链未注册路由
ERR_INVALID_SIG 签名验证失败
graph TD
  A[ChainA 发送 msg] --> B{路由表查询}
  B -->|匹配成功| C[转发至 ChainB endpoint]
  B -->|匹配失败| D[返回 ERR_NO_ROUTE]

第五章:从63.7%到89.2%——京东链测试工程化演进启示

京东链(JD Chain)作为京东自主研发的高性能企业级区块链平台,在2021年启动大规模金融级场景落地时,面临严峻的质量保障挑战:核心合约模块的自动化测试覆盖率长期徘徊在63.7%,CI流水线平均失败率高达22.4%,每次版本发布前需投入超120人日进行手工回归验证。

构建可插拔的合约测试沙箱

团队摒弃传统Mock方式,基于Docker+QEMU构建轻量级链下执行环境,支持Solidity与Rust双语言合约秒级加载与状态快照。关键改进包括:

  • 实现@testnet注解驱动的自动上下文注入;
  • 提供ChainStateRecorder工具,可录制真实交易并生成可复现的测试向量;
  • 沙箱启动耗时从17.3s压缩至≤800ms,单次测试执行吞吐提升4.2倍。

测试资产全生命周期治理

建立统一测试资产元数据中心(TAM),对用例、桩数据、断言规则实施版本化管理:

资产类型 管理方式 关联指标
合约接口测试用例 Git分支+语义化标签(v1.2.0-erc20) 用例复用率↑68%
链上状态快照 S3分片存储+SHA256校验 加载失败率↓至0.03%
异常场景断言库 NPM私有包发布(@jdchain/assertions) 断言编写效率↑3.5倍

智能测试策略调度引擎

引入强化学习(PPO算法)动态优化测试执行序列。引擎实时采集以下信号:

  • 代码变更影响图(基于AST diff)
  • 历史失败模式聚类(K-means on failure logs)
  • 当前CI队列负载(CPU/Mem/IO wait)
    在双周迭代中,该引擎将高风险路径优先执行率从51%提升至89%,配合覆盖率热点分析,使新增代码的首轮缺陷检出率达76.3%。

生产环境影子流量回放系统

在支付链路网关层部署流量镜像代理,将生产环境1%的交易请求(含加密密钥脱敏)实时同步至测试集群。回放系统支持:

# 示例:触发指定区块范围的确定性重放
jdchain-shadow --block-range 12884421-12884430 \
               --replay-mode deterministic \
               --assertion-set payment-consistency-v3

上线后3个月内捕获4类共识层竞态缺陷,其中2例在灰度阶段即被拦截,避免潜在资金损失超¥270万元。

质量门禁与研发行为联动

将测试质量指标嵌入Git Hook与Jenkins Pipeline:

  • 单次PR提交若导致覆盖率下降≥0.5%,自动拒绝合并;
  • 新增智能合约未通过gas-benchmark阈值(≤230k gas/func),CI直接中断;
  • 开发者IDE插件实时显示当前函数的历史缺陷密度热力图。

经过14个月持续迭代,京东链核心模块自动化测试覆盖率稳定达89.2%,CI平均成功率跃升至99.17%,发布周期由原来的14天缩短为3.2天,支撑了供应链金融、跨境溯源等17个高合规要求场景的快速交付。

专治系统慢、卡、耗资源,让服务飞起来。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注