Posted in

Go发送带附件邮件总失败?multipart/mixed与multipart/alternative嵌套结构手写指南(绕过所有第三方库BUG)

第一章:Go发送带附件邮件总失败?multipart/mixed与multipart/alternative嵌套结构手写指南(绕过所有第三方库BUG)

Go 标准库 net/smtp 本身不提供高级邮件构造能力,而多数第三方库(如 gomailmailgun-go)在处理「HTML正文 + 纯文本备选 + 多个二进制附件」时,常因错误嵌套 multipart/alternativemultipart/mixed 导致收件端解析异常——Outlook 丢附件、Apple Mail 忽略 HTML、Gmail 显示乱码等现象,根源几乎都指向 MIME 结构违规。

正确结构必须严格遵循 RFC 2046:外层为 multipart/mixed(容纳正文与附件),其第一个部分为 multipart/alternative(内含 text/plaintext/html 两个同等优先级的备选正文),后续部分依次为每个附件(application/pdfimage/png 等),且所有部分须使用唯一 boundary 字符串,不可复用或硬编码。

手动构建 MIME 树的关键步骤

  1. 生成随机 boundary(如 bndry_ + base64.StdEncoding.EncodeToString(randBytes(12))
  2. 写入外层 multipart/mixed 头部(Content-Type: multipart/mixed; boundary="bndry_abc123"
  3. 写入 multipart/alternative 部分:先写其头部,再写 text/plain 部分(含 Content-Transfer-Encoding: quoted-printable),再写 text/html 部分(同编码)
  4. 对每个附件,写独立部分:Content-TypeContent-Disposition: attachment; filename="report.pdf"Content-Transfer-Encoding: base64,后接 base64 编码数据

示例核心代码片段

// 使用标准库 mime/multipart 构造(非 net/smtp 的 SendMail)
w := multipart.NewWriter(buf)
w.SetBoundary("bndry_" + randStr(12)) // 强制自定义 boundary

// 第一部分:multipart/alternative
alt, _ := w.CreatePart(map[string][]string{"Content-Type": {"multipart/alternative; boundary=\"" + w.Boundary() + "\""}})
altW := multipart.NewWriter(alt)
altW.SetBoundary(w.Boundary()) // 复用外层 boundary?❌ 错误!必须用新 boundary

// 正确做法:为 alternative 创建独立 boundary
altBoundary := "alt_" + randStr(10)
altW.SetBoundary(altBoundary)
// ……后续写 plain/html 子部分(略)

常见陷阱对照表

错误操作 后果 正确做法
multipart/alternative 作为外层 无法附加文件 外层必须是 multipart/mixed
所有部分共用同一 boundary MIME 解析器终止解析 mixedalternative 必须使用不同 boundary
附件未设置 Content-Disposition 文件被当作内联内容或丢失 必须显式声明 attachment; filename="x.pdf"

务必对每一段 MIME 部分手动调用 part.Close(),并在最终 w.Close() 前校验 buf.Len() 是否非零——空缓冲区意味着结构未写入。

第二章:SMTP协议底层原理与Go标准库net/smtp深度解析

2.1 SMTP会话生命周期与RFC 5321关键状态机实践

SMTP 会话严格遵循 RFC 5321 定义的四阶段状态机:连接建立 → 邮件事务(MAIL FROM → RCPT TO → DATA)→ 消息传输 → 会话终止

核心状态流转示意

graph TD
    A[CONNECT] --> B[HELO/EHLO]
    B --> C[MAIL FROM]
    C --> D[RCPT TO*]
    D --> E[DATA]
    E --> F[Message Body]
    F --> G[QUIT]

典型交互片段(含注释)

S: 220 mail.example.com ESMTP Postfix
C: EHLO client.example.net
S: 250-mail.example.com
S: 250-PIPELINING
S: 250-SIZE 52428800
S: 250 HELP
C: MAIL FROM:<alice@example.net> SIZE=1234
S: 250 2.1.0 Ok
C: RCPT TO:<bob@example.com>
S: 250 2.1.5 Ok
C: DATA
S: 354 End data with <CRLF>.<CRLF>
C: From: alice@example.net\r\nTo: bob@example.com\r\n\r\nHello!\r\n.
S: 250 2.0.0 Ok: queued as ABC123
C: QUIT
S: 221 2.0.0 Bye

逻辑分析SIZE 参数在 MAIL FROM 中声明,用于提前协商传输容量;354 响应表示服务端已准备好接收消息体,且要求以孤立句点 . 结束;250 OK 后的队列ID(如 ABC123)是后续日志追踪关键标识。

RFC 5321 关键约束对照表

状态 必需命令 可重入性 错误后恢复行为
连接建立 EHLO/HELO 必须重连
邮件事务起始 MAIL FROM 可覆盖前次发件人
收件人声明 RCPT TO 可追加多个收件人
数据传输 DATA 失败需重启整个事务

2.2 Go net/smtp源码级剖析:Auth机制、TLS握手与PIPELINING支持盲区

Auth机制的隐式依赖

net/smtpAuth 接口实现(如 PlainAuthCRAMMD5Auth不校验用户名/密码非空性,仅在 smtp.sendAuth() 中调用 a.Start() 后直接透传响应。若凭据为空,服务端可能静默拒绝,客户端无明确错误。

// smtp/auth.go 中关键片段
func (c *Client) sendAuth(a Auth) error {
    // ⚠️ 此处未验证 a.Start() 返回的 initialResp 是否为 nil
    if initialResp, err := a.Start(&c.text); err != nil {
        return err
    }
    // ...
}

逻辑分析:a.Start() 返回 initialRespnil 错误时,sendAuth 继续执行;但若认证机制本身未做输入校验(如 PlainAuth 构造时允许空密码),将导致协议级失败且无上下文提示。

TLS握手与PIPELINING的协同盲区

Go SMTP 客户端默认禁用 PIPELINING 扩展,即使 EHLO 响应中包含 PIPELINING*Client 也未提供启用开关;同时 TLS 握手完成前无法解析 EHLO 扩展列表,形成检测时序断层。

场景 行为 影响
显式 StartTLS() 后未重发 EHLO exts 字段仍为初始明文会话值 PIPELINING 等扩展不可用
Auth 在 TLS 升级前调用 text 连接未加密 认证凭据明文泄露
graph TD
    A[Connect] --> B[Send HELO/EHLO]
    B --> C{Response contains PIPELINING?}
    C -->|Yes| D[但 c.exts 未更新]
    C -->|No| E[跳过]
    D --> F[无API触发重协商或重发EHLO]

2.3 邮件体编码规范实战:RFC 2045 MIME头字段生成与Content-Transfer-Encoding自动协商

邮件体编码需严格遵循 RFC 2045,核心在于根据原始内容类型与字节特征动态协商 Content-Transfer-Encoding

自动协商决策逻辑

  • 检测是否含非 ASCII 字符(U+0080+)或 NUL 字节
  • 判断行长度是否超 998 字节(RFC 5322 硬限制)
  • 若含二进制数据(如图片 Base64 前缀),强制启用 base64

MIME 头字段生成示例

def generate_mime_headers(content: bytes, charset: str = "utf-8") -> dict:
    if b"\x00" in content or any(c > 127 for c in content):
        encoding = "base64"
        encoded_body = base64.b64encode(content).decode()
    else:
        encoding = "quoted-printable"
        encoded_body = quopri.encodestring(content).decode()
    return {
        "Content-Type": f"text/plain; charset={charset}",
        "Content-Transfer-Encoding": encoding
    }

此函数依据原始字节流特征选择编码:base64 用于二进制/高字节数据,quoted-printable 保留可读性并兼容 7-bit 通道。charset 参数确保 MIME 头语义准确。

编码策略对比表

编码方式 适用场景 空间开销 可读性
7bit 纯 ASCII、无换行超长文本 0%
quoted-printable 含少量非 ASCII 的文本 ~33%
base64 任意二进制/多字节 Unicode ~33%
graph TD
    A[原始字节流] --> B{含\\x00或>127?}
    B -->|是| C[base64]
    B -->|否| D{最长行≤998?}
    D -->|是| E[7bit]
    D -->|否| F[quoted-printable]

2.4 字符集与国际化处理:UTF-8邮件主题/正文的MIME-Encoded-Word构造与边界规避

当邮件主题含中文(如 订单确认 ✅),需按 RFC 2047 编码为 MIME-Encoded-Word:

=?UTF-8?B?57yW56iL5paHIOKCrMKh?=
  • UTF-8:指定原始字符集
  • B:Base64 编码方式(Q 表示 quoted-printable)
  • 57yW56iL5paHIOKCrMKh:Base64 编码后的 UTF-8 字节序列

编码边界约束

  • 单个 Encoded-Word ≤ 75 字符(含 =?...?=
  • 连续多个 Encoded-Word 间须用空格分隔,不可换行或插入制表符

常见陷阱对照表

错误示例 问题原因 正确形式
=?UTF-8?B?...?= =?UTF-8?B?...?= 中间双空格违反 RFC =?UTF-8?B?...?= =?UTF-8?B?...?=(单空格)
超长未拆分 触发 SMTP 截断 拆分为多个 ≤75 字符的 Encoded-Word

编码流程(mermaid)

graph TD
    A[原始字符串] --> B[UTF-8 编码为字节流]
    B --> C[Base64 编码]
    C --> D[按75字符截断并包裹为 =?UTF-8?B?...?=]
    D --> E[空格连接多个段]

2.5 SMTP错误码精准诊断:5xx/4xx响应语义映射与重试策略手写实现

SMTP响应码是邮件投递状态的唯一权威信源,但原始字符串(如 "550 5.1.1 User unknown")需结构化解析才能驱动智能决策。

响应码语义分类表

类别 状态码范围 语义含义 是否可重试
5xx 500–599 永久性失败 ❌ 否
4xx 400–499 临时性故障 ✅ 是(需退避)

重试策略核心逻辑(Python)

def should_retry(smtp_code: int) -> bool:
    """仅对4xx临时错误返回True;5xx一律拒绝重试"""
    return 400 <= smtp_code < 500  # 注意:400和499均属临时范畴

该函数剥离了响应文本干扰,仅依赖三位数字主码判断。400 <= code < 500 覆盖全部RFC 5321定义的临时错误区间,避免误判如450 Requested mail action not taken: mailbox unavailable

重试退避流程

graph TD
    A[收到4xx响应] --> B{code == 421?}
    B -->|是| C[立即重试,连接复用]
    B -->|否| D[指数退避:1s → 2s → 4s]
    D --> E[最大重试3次]

第三章:multipart/mixed与multipart/alternative的RFC合规嵌套模型

3.1 RFC 2046第5.1.3节精读:mixed与alternative的语义边界与嵌套约束

multipart/mixed 表示顺序不可变、语义无关的部件集合;multipart/alternative 则要求所有部件内容等价但格式不同(如纯文本与HTML邮件),客户端应选最适配者渲染。

核心约束规则

  • alternative 不得直接包含 mixed(违反“等价性”前提)
  • mixed 可嵌套 alternative,但仅当其子部件共同构成某逻辑单元的一部分
Content-Type: multipart/mixed; boundary="outer"
--outer
Content-Type: text/plain

Hello world.
--outer
Content-Type: multipart/alternative; boundary="alt"
--alt
Content-Type: text/plain
Hello (plain)
--alt
Content-Type: text/html
<h1>Hello</h1>
--alt--
--outer--

此结构合法:外层 mixed 表达“正文+附件”关系,内层 alternative 提供同一消息的多格式视图。若颠倒嵌套(alternative 包含 mixed),则 RFC 2046 §5.1.3 明确视为语义错误。

容器类型 是否可含 mixed 是否可含 alternative 语义前提
multipart/mixed ✅ 自身 部件独立、有序
multipart/alternative ❌ 禁止 ❌(递归禁止) 全部部件内容等价
graph TD
    A[Root Part] --> B{Content-Type}
    B -->|mixed| C[Ordered, Heterogeneous Parts]
    B -->|alternative| D[Semantically Equivalent Formats]
    C -->|may contain| D
    D -->|must NOT contain| C

3.2 手动构建嵌套MIME树:boundary唯一性生成、层级深度控制与空行规则校验

boundary唯一性保障

需避免随机字符串碰撞,推荐组合时间戳、进程ID与加密哈希:

import hashlib, os, time
def gen_boundary(depth=0):
    seed = f"{time.time_ns()}{os.getpid()}{depth}".encode()
    return hashlib.sha256(seed).hexdigest()[:16]  # 16字符十六进制,兼顾唯一性与可读性

depth 参数参与哈希输入,确保同层级边界符不重复;截取前16位平衡长度与熵值,符合RFC 2046对boundary的token格式要求。

层级深度控制策略

  • 每层multipart/*子部分深度+1
  • 超过6层触发警告(RFC建议最大嵌套为6)
  • 空行必须紧随boundary后、且前后各含一个CRLF
规则项 合法示例 违规示例
boundary后空行 --abc123\r\n\r\n... --abc123\r\n...
子部分间空行 ...\r\n--abc123\r\n\r\n ...\r\n--abc123\r\n

MIME结构校验流程

graph TD
    A[生成boundary] --> B{深度≤6?}
    B -->|否| C[抛出DepthError]
    B -->|是| D[写入boundary+双CRLF]
    D --> E[递归序列化子部分]

3.3 附件与HTML正文共存时的Content-Disposition与Content-ID协同实践

当HTML邮件同时嵌入图片(如<img src="cid:logo@ex.com">)与普通附件(如PDF说明书)时,Content-IDContent-Disposition必须精确协同,否则客户端解析错乱。

核心协同规则

  • Content-ID仅用于inline资源(如内嵌图片),值需与HTML中cid:引用严格一致;
  • Content-Disposition: inline + Content-ID → 触发内联渲染;
  • Content-Disposition: attachment + filename → 强制下载,忽略Content-ID

典型MIME结构片段

--boundary_123
Content-Type: image/png
Content-ID: <logo@ex.com>
Content-Disposition: inline

[base64-encoded PNG data]

--boundary_123
Content-Type: application/pdf
Content-Disposition: attachment; filename="manual.pdf"

[PDF binary data]

逻辑分析Content-ID是RFC 2387定义的唯一标识符,仅在multipart/related中生效;Content-Dispositioninline/attachment取值直接决定MUA(邮件用户代理)行为。若PDF误设Content-ID但未声明inline,多数客户端将忽略该ID。

字段 作用域 是否必需 示例值
Content-ID inline资源 是(若HTML引用) <chart@2024>
Content-Disposition 全局 inline; filename="img.png"
graph TD
    A[HTML正文] -->|src="cid:icon"@| B(Content-ID匹配)
    B --> C{Content-Disposition}
    C -->|inline| D[渲染为内联元素]
    C -->|attachment| E[忽略CID,显示为附件]

第四章:纯Go手写邮件构造器——零依赖实现高可靠性附件邮件

4.1 无第三方库的MIME multipart序列化引擎:Buffer复用与流式写入优化

传统 multipart 构建常依赖 form-data 等第三方库,带来包体积与内存抖动开销。本实现完全基于 Node.js 原生 Bufferstream.Writable,聚焦零拷贝与生命周期可控。

核心优化策略

  • Buffer 池复用:预分配固定大小 Buffer(如 8KB),通过 Buffer.allocUnsafeSlow() + pool.release() 实现跨请求复用
  • 边界流式注入:不拼接完整 payload,而是分块写入 boundary → headers → body → CRLF,避免中间字符串生成

关键代码片段

const BOUNDARY = '----Boundary' + Date.now().toString(36);
const BOUNDARY_LINE = `\r\n--${BOUNDARY}\r\n`;
const FINAL_BOUNDARY = `\r\n--${BOUNDARY}--\r\n`;

// 复用 buffer pool(简化示意)
const pool = new Pool(8192);
function writePart(writable, name, value) {
  const headerBuf = Buffer.from(
    `Content-Disposition: form-data; name="${name}"\r\n\r\n`
  );
  writable.write(BOUNDARY_LINE); // 写入分隔符
  writable.write(headerBuf);      // 写入头
  writable.write(value);          // 直接写入原始 Buffer 或 Stream
}

逻辑分析writePart 跳过 toString()Buffer.concat(),所有输入 value 若为字符串则由调用方提前转为 BufferBOUNDARY_LINE 预计算为常量 Buffer,避免每次重复构造。writable 可为 fs.createWriteStream()http.ServerResponse,天然支持背压。

优化维度 传统方式 本引擎
内存峰值 O(total size) O(chunk size)
GC 压力 高(频繁小 Buffer 分配) 低(池化复用)
流控支持 弱(需手动 chunk) 原生 Writable 背压
graph TD
  A[开始序列化] --> B{是否首 Part?}
  B -->|否| C[写入 \\r\\n--boundary\\r\\n]
  B -->|是| D[直接写入 --boundary\\r\\n]
  C --> E[写入 Header]
  D --> E
  E --> F[写入 Body Buffer]
  F --> G{是否末 Part?}
  G -->|是| H[写入 \\r\\n--boundary--\\r\\n]
  G -->|否| C

4.2 多类型附件支持:二进制文件、内存字节流、动态生成PDF的Content-Type自动探测

现代邮件服务需无缝处理异构附件源。核心挑战在于统一抽象:磁盘文件、bytes对象、io.BytesIO流及实时生成的PDF(如 reportlab 输出)应共享同一处理管道。

自动Content-Type推导策略

  • 优先读取文件扩展名(.pdf, .xlsx
  • 其次检查魔数(Magic Number)前128字节
  • 最终回退至 mimetypes.guess_type()
import mimetypes
from magic import Magic

def detect_mime(data: bytes, filename: str = None) -> str:
    # 尝试基于扩展名猜测(轻量)
    if filename:
        mime, _ = mimetypes.guess_type(filename)
        if mime: return mime
    # 基于二进制签名精准识别(需libmagic)
    return Magic(mime=True).from_buffer(data[:1024])

逻辑说明:data[:1024] 避免全量加载大文件;Magic(mime=True) 返回标准 MIME 类型(如 "application/pdf");mimetypes.guess_type() 仅依赖扩展名,性能高但精度低。

支持的附件来源类型对比

来源类型 是否支持流式处理 是否需临时文件 Content-Type可靠性
磁盘文件路径 中(扩展名+魔数)
bytes对象 高(纯魔数检测)
BytesIO 高(需getbuffer()
graph TD
    A[附件输入] --> B{是否为path?}
    B -->|是| C[stat + 扩展名 + 魔数]
    B -->|否| D[提取前1024字节]
    D --> E[libmagic检测]
    E --> F[标准化MIME类型]

4.3 HTML+Plain双格式正文同步注入:alternative子部分的顺序敏感性与客户端兼容性修复

数据同步机制

MIME multipart/alternative 要求子部分按递增优先级排列,即 text/plain 必须在 text/html 之前;否则 Outlook 等客户端将忽略 HTML 版本。

Content-Type: multipart/alternative; boundary="sep"
--sep
Content-Type: text/plain; charset=utf-8

Hello world (plain)

--sep
Content-Type: text/html; charset=utf-8

<p>Hello <strong>world</strong> (HTML)</p>
--sep--

✅ 正确顺序:plain → html;❌ 反序将导致 iOS Mail 渲染纯文本、Gmail 降级显示。参数 charset=utf-8 必须显式声明,避免 Mojibake。

客户端兼容性矩阵

客户端 plain 前置 html 前置 备注
Outlook 2019 ✅ 正常 ❌ 纯文本 忽略后续 HTML 子部分
Apple Mail ✅ 正常 ⚠️ 混乱 部分版本渲染空内容
Gmail Web ✅ 正常 ✅ 回退 但丢失样式与交互能力

修复流程

graph TD
    A[生成 plain 内容] --> B[生成 html 内容]
    B --> C[按 MIME 规范拼接 multipart]
    C --> D[强制 plain 在 html 之前]
    D --> E[添加统一 charset 声明]

4.4 完整端到端测试:Gmail/Outlook/Apple Mail三大客户端渲染差异验证与header补丁

邮件客户端对 <head> 中 CSS 的支持差异极大:Gmail 会剥离全部 <style> 标签,Outlook(Windows)依赖 VML + 行内样式,Apple Mail 则支持有限的嵌入式 CSS。

渲染差异速查表

客户端 <style> 支持 !important 有效 <meta> 视口生效 补丁关键点
Gmail (Web) ❌ 剥离 全部转行内 + <div> 伪类模拟
Outlook 2016 ❌(仅兼容VML) ⚠️ 部分失效 必须注入 [if mso] 条件注释
Apple Mail ✅(受限) 保留 <style> + @media 降级

header 补丁注入示例

<!-- 通用 header 补丁:兼容三端 -->
<head>
  <meta name="x-apple-disable-autocorrect" content="true">
  <!--[if mso]>
    <xml>
      <o:OfficeDocumentSettings>
        <o:AllowPNG/>
      </o:OfficeDocumentSettings>
    </xml>
  <![endif]-->
  <style type="text/css">
    /* Apple Mail & modern clients */
    @media screen and (-webkit-min-device-pixel-ratio: 0) {
      .btn { border-radius: 4px !important; }
    }
  </style>
</head>

该补丁通过条件注释为 Outlook 注入 Office XML 元数据,启用 PNG 支持;<meta> 禁用 iOS 自动更正;@media 选择器规避 Gmail 的 CSS 过滤机制。!important 在 Apple Mail 中生效,但 Outlook 中需配合行内 style="border-radius:4px" 才可靠。

端到端验证流程

graph TD
  A[生成含补丁的 HTML 邮件] --> B[通过 Litmus 截图比对]
  B --> C{Gmail 是否显示按钮?}
  C -->|否| D[检查是否遗漏行内 style]
  C -->|是| E[Outlook 是否渲染圆角?]
  E -->|否| F[验证 VML fallback 是否启用]

第五章:总结与展望

技术栈演进的实际影响

在某大型电商平台的微服务重构项目中,团队将原有单体架构迁移至基于 Kubernetes 的云原生体系。迁移后,平均部署耗时从 47 分钟压缩至 92 秒,CI/CD 流水线成功率由 63% 提升至 99.2%。关键指标变化如下表所示:

指标 迁移前 迁移后 变化幅度
服务平均启动时间 8.4s 1.2s ↓85.7%
日均故障恢复时长 28.6min 47s ↓97.3%
配置变更灰度覆盖率 0% 100% ↑∞
开发环境资源复用率 31% 89% ↑187%

生产环境可观测性落地细节

团队在生产集群中统一接入 OpenTelemetry SDK,并通过自研 Collector 插件实现日志、指标、链路三态数据同源打标。例如,订单服务 createOrder 接口的 trace 数据自动注入业务上下文字段 order_id=ORD-2024-778912tenant_id=taobao,使 SRE 工程师可在 Grafana 中直接下钻至特定租户的慢查询根因。以下为真实采集到的 trace 片段(简化):

{
  "traceId": "a1b2c3d4e5f67890",
  "spanId": "z9y8x7w6v5u4",
  "name": "payment-service/process",
  "attributes": {
    "order_id": "ORD-2024-778912",
    "payment_method": "alipay",
    "region": "cn-hangzhou"
  },
  "durationMs": 342.6
}

多云调度策略的实证效果

采用 Karmada 实现跨阿里云 ACK、腾讯云 TKE 与私有 OpenShift 集群的统一编排后,大促期间流量可按实时 CPU 负载动态调度。2024 年双 11 零点峰值时段,系统自动将 37% 的风控校验请求从 ACK 切至 TKE,避免 ACK 集群出现 Pod 驱逐——该策略使整体 P99 延迟稳定在 213ms(±8ms),未触发任何熔断降级。

工程效能瓶颈的新形态

尽管自动化程度提升,但团队发现新瓶颈正从“部署慢”转向“验证难”。例如,一个涉及 12 个微服务的订单履约链路变更,需在 4 类环境(dev/staging/preprod/prod)中完成 37 项契约测试+性能基线比对。目前正试点基于 GitOps 的声明式验证流水线,将环境一致性检查嵌入 Argo CD Sync Hook,已将端到端验证周期从 11 小时缩短至 2 小时 18 分钟。

flowchart LR
    A[Git Commit] --> B{Argo CD Sync}
    B --> C[执行 pre-sync Hook]
    C --> D[运行环境健康检查]
    D --> E[执行契约测试套件]
    E --> F[比对 Prometheus 基线]
    F --> G[允许 Sync 或阻断]

人机协同运维的边界探索

某次数据库主节点宕机事件中,AIOps 平台在 17 秒内完成根因定位(磁盘 IOPS 持续超 98%),并自动生成修复建议:“扩容 /var/lib/mysql 所在 PV 至 2TB 并启用 LVM 在线扩容”。但值班工程师发现该建议未考虑存储后端为 CephFS,实际应调整 rbd 映像配额而非文件系统层。这揭示出当前 AIOps 决策仍需深度绑定基础设施语义模型。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

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