Posted in

Go语言对接钉钉开放平台:从入门到信创适配(麒麟V10+达梦DB+国密SM2全流程验证)

第一章:Go语言对接钉钉开放平台的核心价值与信创适配意义

Go语言凭借其轻量级协程、静态编译、内存安全及跨平台能力,成为构建高并发、低延迟企业级集成服务的理想选择。在对接钉钉开放平台时,Go原生支持HTTP/2、JSON高效序列化与TLS 1.3,显著提升消息推送、审批流回调、群机器人等高频API调用的稳定性与吞吐量。

信创生态下的技术自主性保障

在国产化替代背景下,Go语言对主流信创环境(如麒麟V10、统信UOS、海光/鲲鹏CPU)提供开箱即用的官方支持。通过GOOS=linux GOARCH=arm64 go build即可生成免依赖二进制文件,无需安装运行时,完美契合信创环境中对软件供应链安全与部署轻量化的严苛要求。

钉钉开放平台对接的工程优势

  • SDK轻量化:官方dingtalk-sdk-go体积仅约200KB,无第三方框架耦合,便于审计与定制;
  • 错误处理标准化:统一返回*dingtalk.Error结构体,含Code、Message、RequestID三元组,利于日志追踪与熔断策略实施;
  • 签名机制内建支持:内置HMAC-SHA256签名生成器,可直接复用钉钉加签逻辑:
import "crypto/hmac"
// 示例:生成timestamp + sign签名串(用于获取access_token)
func generateSign(secret, timestamp string) string {
    key := []byte(secret)
    data := []byte(timestamp)
    mac := hmac.New(sha256.New, key)
    mac.Write(data)
    return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

与国产中间件协同能力

组件类型 兼容方案 说明
国产数据库 github.com/mojocn/mysql 支持达梦、人大金仓SQL语法扩展
消息队列 github.com/apache/pulsar/pulsar-go 已验证与东方通TongLINK适配
API网关 使用gin+jwt-go实现国密SM2鉴权插件 符合《GB/T 39786-2021》密码应用要求

这种深度适配能力,使Go语言不仅成为连接钉钉能力的“高速通道”,更成为信创环境下构建安全、可控、可审计数字办公底座的关键技术支点。

第二章:钉钉消息API原理与Go客户端基础构建

2.1 钉钉开放平台认证机制解析(AppKey/AppSecret + AccessToken生命周期)

钉钉开放平台采用双因子认证模型:应用身份凭证(AppKey/AppSecret)用于获取短期访问令牌,AccessToken则承载实际API调用权限。

认证流程概览

graph TD
    A[客户端持有AppKey/AppSecret] --> B[向https://oapi.dingtalk.com/gettoken发起POST]
    B --> C[钉钉校验凭证有效性]
    C --> D[返回有效期2小时的AccessToken]
    D --> E[后续API请求携带access_token参数]

获取AccessToken示例

import requests

url = "https://oapi.dingtalk.com/gettoken"
params = {
    "appkey": "dingabc123",      # 应用唯一标识,控制台生成
    "appsecret": "xyz789def"     # 高敏感密钥,严禁前端暴露
}
resp = requests.get(url, params=params).json()
# 响应含 access_token、expires_in(单位:秒,默认7200)

该请求无需OAuth授权,但需HTTPS+服务端调用。expires_in决定本地缓存策略——建议提前30秒刷新,避免并发请求重复刷新。

AccessToken关键属性对比

属性 类型 说明 安全建议
access_token string JWT-like token,非JWT格式,不可解码 存于内存/Redis,禁止日志打印
expires_in int 有效时长(秒),固定7200 必须结合定时刷新机制

AccessToken失效后将返回errcode: 40014,需触发自动续期逻辑。

2.2 Go语言HTTP客户端封装实践:支持重试、限流与日志追踪的REST调用层

核心设计原则

  • 单一职责:每个中间件只处理一类关注点(重试/限流/追踪)
  • 可组合性:通过 RoundTripper 链式装饰实现功能叠加
  • 上下文传递:全程透传 context.Context 支持超时与取消

重试策略实现

func NewRetryTransport(base http.RoundTripper, maxRetries int) http.RoundTripper {
    return &retryTransport{
        base:      base,
        maxRetries: maxRetries,
        backoff:   time.Millisecond * 100,
    }
}

// 逻辑分析:采用指数退避(2^n × base),仅对5xx和网络错误重试;
// maxRetries=3时,总等待时间上限约700ms;backoff可随环境动态调整。

限流与追踪协同

组件 作用 依赖注入方式
golang.org/x/time/rate 请求速率控制 http.Client.Transport
opentelemetry-go 生成Span ID并注入Header context.WithValue()
graph TD
    A[HTTP Request] --> B{限流器 Check}
    B -->|允许| C[添加TraceID Header]
    B -->|拒绝| D[返回429]
    C --> E[执行请求]
    E --> F[记录响应延迟与状态码]

2.3 消息体结构建模:基于钉钉文档的Struct设计与JSON序列化容错处理

为适配钉钉文档事件推送的多态性(如 document_update、document_share),采用分层 Struct 设计:

type DingTalkMessage struct {
    EventID     string                 `json:"event_id"`
    EventType   string                 `json:"event_type"`
    Payload     map[string]interface{} `json:"payload"` // 动态字段,避免强绑定
    Timestamp   int64                  `json:"timestamp"`
}

// 容错反序列化:先解析基础字段,再按 eventType 分支解析 payload
func ParseMessage(data []byte) (*DingTalkMessage, error) {
    var base struct {
        EventID     string          `json:"event_id"`
        EventType   string          `json:"event_type"`
        Payload     json.RawMessage `json:"payload"` // 延迟解析
        Timestamp   int64           `json:"timestamp"`
    }
    if err := json.Unmarshal(data, &base); err != nil {
        return nil, fmt.Errorf("parse base fields failed: %w", err)
    }

    // 允许 payload 缺失或为空对象
    if len(base.Payload) == 0 {
        base.Payload = json.RawMessage(`{}`)
    }

    return &DingTalkMessage{
        EventID:     base.EventID,
        EventType:   base.EventType,
        Payload:     make(map[string]interface{}),
        Timestamp:   base.Timestamp,
    }, nil
}

该设计将结构解耦为「骨架+载荷」两层:Payload 使用 json.RawMessage 延迟解析,规避因字段缺失、类型错位导致的 json.Unmarshal panic;同时默认空 payload 为 {},保障后续 map[string]interface{} 安全遍历。

关键容错策略

  • 字段缺失 → 默认零值(如 string 为空串,int64 为 0)
  • 类型冲突(如期望 string,实际 number)→ json.Unmarshal 自动转换(需启用 UseNumber)
  • 额外字段 → 忽略,不报错
容错场景 处理方式
payload 为 null 替换为 {} 空对象
timestamp 非数字 解析失败,保留原始字节流诊断
event_type 为空 允许,由业务层校验合法性
graph TD
A[原始 JSON 字节流] --> B[Unmarshal 到 base 结构]
B --> C{payload 是否为空/nil?}
C -->|是| D[设为 {}]
C -->|否| E[保留 raw bytes]
D --> F[构造 DingTalkMessage]
E --> F

2.4 签名验签全流程实现:HMAC-SHA256签名生成与服务端回调验签验证

签名生成核心逻辑

客户端需按约定顺序拼接参数(timestamp、nonce、body),使用密钥通过 HMAC-SHA256 生成 Base64 编码签名:

import hmac, hashlib, base64

def generate_signature(timestamp: str, nonce: str, body: str, secret: str) -> str:
    message = f"{timestamp}{nonce}{body}"
    key = secret.encode()
    digest = hmac.new(key, message.encode(), hashlib.sha256).digest()
    return base64.b64encode(digest).decode()  # 输出32字节→43字符Base64字符串

timestamp 为毫秒级 UNIX 时间戳;nonce 为16位随机字符串防重放;body 为原始 JSON 字符串(不含空格);secret 为平台分配的 API 密钥。

服务端验签流程

接收请求后,服务端执行相同拼接与计算,并比对签名:

步骤 操作 安全要求
1 校验 timestamp 是否在 ±5 分钟窗口内 防重放攻击
2 查询 nonce 是否已存在(Redis 去重) 防重复提交
3 重新计算 HMAC-SHA256 并恒定时间比较 避免时序侧信道
graph TD
    A[客户端请求] --> B[携带 timestamp/nonce/signature]
    B --> C[服务端校验时效性 & nonce唯一性]
    C --> D[重构 message 并 HMAC-SHA256]
    D --> E[恒定时间字符串比较]
    E --> F{验签通过?}
    F -->|是| G[处理业务逻辑]
    F -->|否| H[返回 401 Unauthorized]

2.5 错误码体系映射与Go错误分类:将钉钉HTTP错误码转化为可捕获的自定义error类型

设计目标

统一处理钉钉 OpenAPI 返回的 errcode(如 40001 权限不足、403 IP受限),避免散落各处的字符串比对。

自定义错误类型

type DingTalkError struct {
    Code    int    `json:"errcode"`
    Message string `json:"errmsg"`
    ReqID   string `json:"request_id,omitempty"`
}

func (e *DingTalkError) Error() string {
    return fmt.Sprintf("dingtalk[%d]: %s", e.Code, e.Message)
}

逻辑分析:结构体嵌入标准 error 接口实现,Code 为钉钉官方错误码(int型便于switch分支),Message 提供上下文,ReqID 支持链路追踪;Error() 方法确保兼容 Go 原生错误处理生态。

错误码映射表

钉钉 errcode 语义 对应 Go error 类型
40001 invalid appkey ErrInvalidAppKey
403 ip not in whitelist ErrIPForbidden
50006 user not exist ErrUserNotFound

分类捕获示例

if dtErr, ok := err.(*DingTalkError); ok {
    switch dtErr.Code {
    case 40001:
        return ErrInvalidAppKey.Wrap(dtErr)
    case 403:
        return ErrIPForbidden.Wrap(dtErr)
    }
}

逻辑分析:先类型断言提取原始错误,再按 Code 分流至领域语义错误(如 ErrInvalidAppKey 实现 Unwrap() 和 Is() 方法),支持 errors.Is(err, ErrInvalidAppKey) 精准判断。

第三章:企业级消息发送能力落地实践

3.1 文本与Markdown消息的Go构造与异步推送实战(含@全员与指定用户逻辑)

消息结构建模

使用 struct 统一承载文本、Markdown、提及类型(@all / @user(id)):

type Message struct {
    Content     string   `json:"content"`     // 原始文本或Markdown
    MsgType     string   `json:"msg_type"`    // "text" | "markdown"
    Mentions    []string `json:"mentions"`    // ["all"] 或 ["u_abc123", "u_def456"]
}

Mentions 字段解耦提及逻辑:空切片表示无人提及;"all" 触发全员通知;其余为用户唯一ID,供下游精准路由。

异步推送核心流程

graph TD
A[构造Message] --> B{含@all?}
B -->|是| C[广播至所有在线连接]
B -->|否| D[查用户在线状态]
D --> E[并发推送至匹配ws连接]

提及渲染规则

类型 渲染示例 客户端行为
@all <at user_id="all">全体成员</at> 触发强提醒+声音
@user(id) <at user_id="u_abc123">张三</at> 高亮+私信红点提示

并发安全推送

func (s *Service) PushAsync(msg Message) {
    go func() {
        if contains(msg.Mentions, "all") {
            s.broadcast(msg) // 全员广播(无锁,基于channel)
            return
        }
        for _, uid := range msg.Mentions {
            if conn := s.connManager.Get(uid); conn != nil {
                conn.WriteJSON(msg) // 非阻塞写入
            }
        }
    }()
}

PushAsync 启动goroutine避免HTTP响应阻塞;conn.WriteJSON 使用预设超时与错误重试,确保单连接可靠性。

3.2 卡片消息(ActionCard/FeedCard)的Go模板渲染与动态字段绑定

钉钉卡片消息需在服务端动态生成结构化 JSON,Go 的 html/template 提供安全、可扩展的渲染能力。

模板结构设计

// card_template.go
const actionCardTpl = `{
  "msgtype": "actionCard",
  "actionCard": {
    "title": "{{.Title}}",
    "text": "{{.Content | markdownToText}}",
    "btnOrientation": "0",
    "btns": [
      {{range .Buttons}}
      {"title":"{{.Title}}","actionURL":"{{.URL}}"}{{if not (last . $)}} , {{end}}
      {{end}}
    ]
  }
}`

{{range .Buttons}} 实现按钮列表动态展开;| markdownToText 是自定义函数,将 Markdown 转为纯文本适配钉钉限制;last 辅助函数避免末尾逗号错误。

动态字段绑定关键点

  • 结构体字段需导出(首字母大写)
  • 时间戳、用户ID等敏感字段应在 Execute 前完成校验与脱敏
  • template.FuncMap 中注册 markdownToText 等工具函数
字段 类型 必填 说明
.Title string 是 卡片主标题(≤100字符)
.Content string 是 支持简单 Markdown 渲染
.Buttons []Button 否 最多4个按钮,URL需HTTPS
graph TD
  A[Go Struct] --> B[Template Parse]
  B --> C[Data Bind & Execute]
  C --> D[JSON Output]
  D --> E[钉钉API POST]

3.3 消息幂等性保障:基于Redis+UUID的消息去重与状态持久化方案

核心设计思想

利用 UUID 作为全局唯一消息标识,结合 Redis 的原子操作(SETNX + EXPIRE)实现高效去重,并通过哈希结构持久化处理状态,兼顾性能与可靠性。

关键流程

def consume_message(msg_id: str, payload: dict) -> bool:
    # 使用 SETNX 实现原子性插入,避免重复消费
    is_new = redis.setnx(f"msg:seen:{msg_id}", "1")
    if not is_new:
        return False  # 已处理,直接丢弃

    # 设置过期时间(如 24 小时),防止 key 泄漏
    redis.expire(f"msg:seen:{msg_id}", 86400)

    # 同步记录完整状态到 hash,支持审计与重放
    redis.hset("msg:state", msg_id, json.dumps({
        "status": "processed",
        "ts": time.time(),
        "payload_hash": hashlib.sha256(str(payload).encode()).hexdigest()
    }))
    return True

msg_id 必须由生产端生成并随消息透传;SETNX 确保首次写入成功才执行业务逻辑;EXPIRE 防止长期占用内存;hset 提供可追溯的状态快照。

状态对比表

字段 类型 说明
msg:seen:{id} String 布隆式存在标记,超时自动清理
msg:state Hash 结构化存储,支持按 ID 查询详情

处理流程图

graph TD
    A[接收消息] --> B{msg_id 是否已存在?}
    B -- 是 --> C[丢弃]
    B -- 否 --> D[SETNX 写入 seen key]
    D --> E[EXPIRE 设置 TTL]
    E --> F[写入 msg:state Hash]
    F --> G[执行业务逻辑]

第四章:信创环境全栈适配验证(麒麟V10+达梦DB+国密SM2)

4.1 麒麟V10操作系统下Go交叉编译与钉钉SDK兼容性验证

麒麟V10(基于Linux内核5.4,glibc 2.28)对Go 1.19+的CGO支持存在运行时符号差异,需针对性适配。

环境准备要点

  • 安装麒麟V10官方源提供的gcc-golang工具链
  • 设置CGO_ENABLED=1并指定系统级CFLAGS:-I/usr/include/dingtalk-sdk-c

交叉编译关键命令

# 在x86_64麒麟V10宿主机上构建ARM64钉钉应用
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 \
CC=/usr/bin/aarch64-linux-gnu-gcc \
CXX=/usr/bin/aarch64-linux-gnu-g++ \
go build -ldflags="-s -w" -o dd-bot-arm64 .

此命令启用CGO以调用钉钉SDK C接口;CC/CXX指向麒麟适配的交叉工具链;-ldflags精简二进制体积,规避麒麟GLIBC版本校验失败风险。

兼容性验证结果

SDK功能 麒麟V10实测状态 备注
消息发送(HTTP) ✅ 成功 使用net/http标准库
加解密(SM4) ⚠️ 需替换为OpenSSL 1.1.1k 原生crypto/tls不兼容国密
graph TD
    A[源码编译] --> B{CGO_ENABLED=1?}
    B -->|是| C[链接麒麟版libdingtalk.so]
    B -->|否| D[纯Go HTTP fallback]
    C --> E[SM4加解密调用成功]

4.2 达梦DB作为消息审计日志存储:GORM适配达梦驱动与DDL自动迁移实践

GORM达梦驱动集成要点

需引入 github.com/opensg/dm-gorm-driver(达梦官方维护的GORM v2兼容驱动),替代原生database/sql裸调用,支持事务、预编译及结构体标签映射。

import (
    _ "github.com/opensg/dm-gorm-driver"
    "gorm.io/gorm"
)

db, err := gorm.Open(dameng.Open("sysdba:SYSDBA@127.0.0.1:5236?database=MYDB"), &gorm.Config{
    NamingStrategy: schema.NamingStrategy{SingularTable: true},
})
// dameng.Open 返回 *sql.DB;SingularTable=true 避免复数表名(如 audit_log → audit_log 而非 audit_logs)

DDL自动迁移关键配置

达梦不支持CREATE TABLE IF NOT EXISTS全语法,GORM需禁用自动创建外键,并显式启用字段注释同步:

选项 值 说明
DisableForeignKeyConstraintWhenMigrating true 达梦v8对FK约束DDL兼容性弱,迁移时跳过
CommentTag "comment" 启用结构体gorm:"comment:审计操作类型"生成COMMENT语句

数据同步机制

审计日志写入采用双写+异步补偿:主业务流程同步写Kafka,后台Worker消费后批量UPSERT至达梦。GORM批量插入需指定CreateInBatches(&logs, 1000)以规避单条SQL长度超限。

4.3 国密SM2算法集成:使用gmgo库替换OpenSSL签名模块,实现钉钉回调验签国密化改造

为满足等保2.0与商用密码应用安全性评估要求,需将原有基于OpenSSL的ECDSA-SHA256验签逻辑升级为国密SM2算法。

改造核心路径

  • 替换签名验证底层:crypto/ecdsa → github.com/tjfoc/gmsm/sm2
  • 重构验签入口:解析钉钉回调的sign、timestamp、suite_ticket三元组
  • 密钥体系切换:X.509 PEM格式SM2公钥替代传统EC公钥

gmgo验签关键代码

// 使用gmgo/sm2进行国密验签(PKCS#1 v2.1填充)
func VerifySM2(signData, signature, pubkeyPEM string) bool {
    pubKey, _ := sm2.ParseSm2PublicKey([]byte(pubkeyPEM))
    hash := sm2.NewSm3Hash()
    hash.Write([]byte(signData))
    return pubKey.Verify(hash.Sum(nil), hex.DecodeString(signature))
}

signData为timestamp\nsuite_ticket拼接字符串(LF分隔);signature为钉钉Base64编码后转HEX的小端字节序签名;sm2.Verify内部自动执行SM3哈希+SM2解密+Z值校验三步。

验签流程对比

环节 OpenSSL(ECDSA) gmgo(SM2)
哈希算法 SHA256 SM3
签名标准 RFC 6979 GM/T 0003.2-2012
公钥格式 SEC1 PEM SM2 PEM(含OID)
graph TD
    A[钉钉回调请求] --> B{提取sign/timestamp/suite_ticket}
    B --> C[拼接signData = timestamp\\n + suite_ticket]
    C --> D[SM3哈希signData]
    D --> E[SM2公钥验签]
    E --> F[返回true/false]

4.4 全链路信创合规性检查清单:从TLS证书(SM2证书)、数据库连接、到HTTP传输层加密验证

SM2证书部署验证

需确认Web服务器使用国密SM2非对称算法签发的X.509证书,且私钥受国产密码模块(如TPM/USB-Key)保护:

# 检查证书签名算法与公钥类型
openssl x509 -in server.crt -text -noout | grep -E "(Signature Algorithm|Subject Public Key Info)"

输出应含 sm2sign 和 id-sm2 OID;若显示 rsaEncryption 或 ecdsa-with-SHA256,则不合规。

数据库连接加密强制策略

PostgreSQL需启用sslmode=verify-full并信任国密CA根证书:

参数 推荐值 合规依据
sslmode verify-full 防中间人劫持
sslrootcert /etc/ssl/certs/gmca.crt 国产CA证书路径
sslcert / sslkey SM2格式PEM 需通过openssl pkey -in key.pem -text验证为id-sm2

HTTP传输层加密验证流程

graph TD
  A[客户端发起HTTPS请求] --> B{Nginx是否加载SM2证书?}
  B -->|是| C[握手阶段协商SM2+SM4-GCM套件]
  B -->|否| D[拒绝连接并记录审计日志]
  C --> E[后端服务校验TLS会话密钥来源]

关键验证点:抓包确认ClientHello中supported_groups含ffdhe-sm2,signature_algorithms含sm2sig。

第五章:生产环境部署建议与未来演进方向

容器化部署最佳实践

在金融级API网关项目中,我们采用Docker + Kubernetes组合实现灰度发布。生产集群使用3节点etcd高可用集群,Ingress Controller配置nginx.ingress.kubernetes.io/canary: "true"实现基于Header的流量切分。关键指标监控项包括:nginx_ingress_controller_requests_total{status=~"5.."} > 10触发告警,配合Prometheus Alertmanager自动创建Jira工单。某次v2.3版本上线时,通过kubectl patch deployment api-gateway -p '{"spec":{"template":{"metadata":{"labels":{"version":"v2.3"}}}}}'完成滚动更新,将错误率从0.8%降至0.02%。

数据库连接池调优方案

生产环境MySQL连接池配置需匹配实际负载特征:

参数 推荐值 依据
maxActive 64 基于TPS=1200、平均响应时间85ms计算得出
minIdle 16 避免连接重建开销
testOnBorrow false 启用validationQueryTimeout=2替代

实测显示,在电商大促期间,将timeBetweenEvictionRunsMillis从30000调整为15000后,连接泄漏率下降73%,该配置已固化到Ansible playbooks的templates/db-pool.yml中。

# 生产环境Helm values.yaml关键片段
redis:
  sentinel:
    enabled: true
    masterName: "mymaster"
    endpoints:
      - "redis-sentinel-0.redis-sentinel-headless:26379"
      - "redis-sentinel-1.redis-sentinel-headless:26379"
      - "redis-sentinel-2.redis-sentinel-headless:26379"

多云灾备架构设计

采用跨云双活架构,主中心部署在阿里云华东1区,灾备中心部署在腾讯云华南3区。通过双向DNS解析实现故障切换:

  • 正常状态下,api.prod.example.com TTL=30秒指向主中心VIP
  • 当检测到主中心/healthz连续3次超时(阈值>5s),Cloudflare Workers自动修改DNS记录指向灾备中心
  • 灾备中心同步写入Kafka消息队列,使用Debezium捕获MySQL binlog变更,确保RPO

可观测性体系升级路径

当前ELK栈日志分析存在延迟问题,演进路线如下:

  1. 将Filebeat替换为OpenTelemetry Collector,启用OTLP协议传输
  2. 在应用层注入otel.instrumentation.common.skip-classes=org.springframework.web.servlet.DispatcherServlet避免框架内耗
  3. 使用Jaeger UI的Service Graph功能定位慢SQL调用链,某次优化将订单查询P99从1.2s降至320ms
graph LR
A[应用Pod] -->|OTLP gRPC| B(OpenTelemetry Collector)
B --> C[Tempo分布式追踪]
B --> D[Loki日志存储]
B --> E[Prometheus指标]
C --> F[Grafana Service Map]
D --> F
E --> F

安全加固实施清单

  • 所有生产镜像启用Trivy扫描,阻断CVE-2023-27997等高危漏洞
  • API网关强制执行JWT验证,密钥轮换周期设为72小时,密钥存储于HashiCorp Vault的kv-v2/prod/api-gateway/jwt路径
  • 网络策略限制Pod仅能访问10.96.0.0/12内服务,外部访问必须经由AWS ALB的WAF规则过滤

技术债偿还计划

遗留的Spring Boot 2.3.12版本存在Log4j2 RCE风险,已制定迁移路线图:

  • Q3完成JUnit5迁移,覆盖核心模块85%测试用例
  • Q4切换至Spring Boot 3.2.x,启用GraalVM原生镜像构建,启动时间从3.2s压缩至420ms
  • 2024年Q1完成所有RESTful接口向OpenAPI 3.1规范对齐,Swagger UI集成到内部开发者门户

边缘计算场景适配

在IoT设备管理平台中,将API网关下沉至NVIDIA Jetson边缘节点,采用轻量级Envoy代理替代Spring Cloud Gateway。通过envoy.filters.http.lua脚本实现设备认证,单节点支撑2000+ MQTT连接,内存占用从1.2GB降至280MB。

关注异构系统集成,打通服务之间的最后一公里。

发表回复

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