Posted in

【Go模块代理灾难预警】:GOPROXY=direct导致go get失败的11种网络/证书/协议组合故障(含企业内网适配清单)

第一章:Go模块代理灾难的底层原理与现象总览

Go模块代理(如 proxy.golang.org 或私有代理如 Athens、JFrog GoCenter)本意是加速依赖拉取、缓存校验与规避网络限制,但其设计中的强缓存语义、弱一致性模型与不可变性假设,在真实生产环境中极易引发级联式故障。

代理如何悄然破坏模块完整性

go getgo build 执行时,Go 工具链默认向代理发起 GET /<module>/@v/<version>.infoGET /<module>/@v/<version>.mod 请求。代理若已缓存过某版本(即使原始仓库中该 tag 已被 force-push 覆盖或删除),将直接返回旧内容——而 Go 不验证响应是否与源仓库当前状态一致。这种“缓存即真理”的行为导致:

  • 模块校验和(go.sum)与实际代码不匹配却无警告;
  • 团队成员因代理节点差异获取不同代码,构建结果不可复现;
  • 安全补丁发布后,旧代理缓存仍分发含漏洞的版本。

典型灾难场景再现

执行以下命令可复现缓存污染导致的构建漂移:

# 1. 强制清除本地模块缓存(仅影响本地,不触达代理)
go clean -modcache

# 2. 设置高延迟代理以放大竞态(模拟跨区域代理同步滞后)
export GOPROXY="https://proxy.golang.org,direct"
export GONOPROXY=""  # 确保所有模块走代理

# 3. 构建同一 commit,两次执行可能产出不同二进制
go build -o app-v1 ./cmd/app
go build -o app-v2 ./cmd/app
diff <(sha256sum app-v1) <(sha256sum app-v2)  # 可能输出非空结果

代理响应与源仓库的语义鸿沟

行为 源仓库(VCS) Go模块代理
删除已发布 tag 操作立即生效 缓存永驻,除非手动失效
修改 go.mod 文件 需新版本号才可见 同版本 .mod 文件被静默覆盖
提交哈希变更 go.sum 必然更新 代理返回旧哈希,校验失败静默忽略

根本矛盾在于:代理将模块视为不可变快照,而开发者实践常需紧急修订已发布小版本(如 v1.2.3 的 hotfix)。当代理拒绝“重写历史”,它便成了确定性的混沌之源。

第二章:网络层故障导致go get失败的五种典型场景

2.1 DNS解析异常与自定义域名劫持的交叉验证实践

在混合云环境中,DNS解析异常常与恶意域名劫持现象交织,需通过多维度信号交叉验证定位根因。

验证流程设计

# 同时发起系统解析、DoH查询与自定义权威比对
dig @8.8.8.8 example.com +short \
  && curl -s "https://dns.google/resolve?name=example.com&type=A" \
  && nslookup example.com 192.168.10.10  # 企业自建DNS

该命令串联公共DNS、加密DNS(DoH)与内网权威DNS三路响应。@8.8.8.8指定上游服务器;+short精简输出;nslookup直连内网DNS服务,暴露策略性重定向行为。

响应一致性比对表

解析源 IP地址 TTL 是否匹配
公共DNS(8.8.8.8) 192.0.2.1 300
DoH(Google) 192.0.2.1 298
内网DNS(192.168.10.10) 10.1.1.100 60 ❌(劫持嫌疑)

判定逻辑流图

graph TD
    A[发起三路DNS查询] --> B{IP是否全一致?}
    B -->|是| C[判定为正常解析]
    B -->|否| D[检查TTL差异 & 网段归属]
    D --> E[内网DNS返回私有IP且TTL极短?]
    E -->|是| F[触发劫持告警]

2.2 TCP连接超时与企业防火墙策略的深度抓包分析

企业级防火墙常对空闲连接实施主动回收,典型表现为 FINRST 强制中断。Wireshark 中可观察到 TCP Keep-Alive 探测失败后,防火墙在 180s(常见默认值)后注入 RST

常见超时参数对照表

设备类型 默认空闲超时 Keep-Alive 间隔 触发 RST 条件
Cisco ASA 300s 无内置 连接无数据且无 ACK 响应
Palo Alto 3600s 可配(默认禁用) 会话表项老化
Linux netfilter 600s tcp_keepalive_time=7200 内核探测失败×9次

抓包关键过滤表达式

# 捕获被防火墙重置的长连接(排除客户端主动断开)
tcp.flags.reset == 1 && !(tcp.flags.fin == 1 && tcp.flags.ack == 1)

该过滤排除了标准四次挥手中的 FIN+ACK 组合,聚焦于非协商式 RST 注入。tcp.flags.reset == 1 精确匹配 RST 标志位,是识别防火墙干预的核心依据。

超时演进路径(mermaid)

graph TD
    A[应用层发送最后数据] --> B[TCP Keep-Alive 启动]
    B --> C{防火墙是否透传探测包?}
    C -->|否| D[探测包丢弃 → 无响应]
    C -->|是| E[探测响应正常]
    D --> F[防火墙老化计时器超时 → 插入 RST]

2.3 HTTP代理链路中断与GOPROXY=direct隐式绕过的冲突复现

当企业网络强制启用 HTTP 代理(如 http://proxy.corp:8080),而代理服务意外宕机时,Go 模块下载会因超时失败。此时若未显式设置 GOPROXY,Go 默认使用 https://proxy.golang.org,direct —— 即先尝试公共代理,失败后自动 fallback 到 direct

冲突根源

GOPROXY=direct 并非“禁用代理”,而是跳过所有代理中间件,直连模块服务器;但若环境变量中仍残留 HTTP_PROXY/HTTPS_PROXY,Go 的底层 net/http 客户端仍会读取并尝试通过该代理发起请求,导致 direct 语义被隐式覆盖。

复现场景验证

# 清理环境(关键!)
unset HTTP_PROXY HTTPS_PROXY
export GOPROXY=direct
go mod download golang.org/x/net@v0.14.0

逻辑分析:GOPROXY=direct 强制 Go 跳过代理解析逻辑,直接构造 https://golang.org/x/net/@v/v0.14.0.info 请求。若 HTTP_PROXY 未清除,net/http 仍会按 http.Transport 默认行为代理该请求,造成“名义 direct、实际走断连代理”的矛盾状态。

典型错误组合对照表

环境变量状态 GOPROXY 值 实际行为
HTTP_PROXY 已设 direct ❌ 仍经故障代理(隐式覆盖)
HTTP_PROXY 已清 direct ✅ 直连模块服务器
HTTP_PROXY 已设 https://proxy.golang.org,direct ⚠️ 先连 proxy.golang.org(可能成功),失败后 fallback 到 direct(但受代理干扰)
graph TD
    A[go mod download] --> B{GOPROXY=direct?}
    B -->|是| C[跳过代理解析]
    C --> D[构造直连URL]
    D --> E{HTTP_PROXY 是否存在?}
    E -->|是| F[net/http 自动注入代理Transport]
    E -->|否| G[真正直连]

2.4 IPv6优先栈下模块服务器不可达的协议降级调试方案

当IPv6栈启用且默认路由仅含fe80::/10或无有效全局地址时,应用层调用getaddrinfo()可能仅返回IPv6地址,导致连接AF_INET6套接字超时——而目标服务实际仅监听IPv4。

核心诊断步骤

  • 使用 ip -6 route show 验证是否存在有效IPv6默认网关
  • 执行 ss -tuln | grep :<port> 确认服务绑定地址族(::: 表示双栈,0.0.0.0 仅IPv4)
  • 检查 /etc/gai.confprecedence ::ffff:0:0/96 100 是否被注释(影响IPv4映射地址优先级)

协议降级触发逻辑

// 示例:显式启用AI_ADDRCONFIG + AI_V4MAPPED 组合
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;      // 允许IPv4/IPv6
hints.ai_flags = AI_ADDRCONFIG | AI_V4MAPPED; // 仅返回本机配置的协议族
hints.ai_socktype = SOCK_STREAM;

AI_ADDRCONFIG 阻止返回未配置地址族的记录;AI_V4MAPPED 允许在IPv6套接字中使用IPv4-mapped地址(如 ::ffff:192.0.2.1),为双栈兼容提供基础支持。

常见降级策略对比

策略 触发条件 风险
强制AF_INET 服务端仅监听IPv4 丢失IPv6直连能力
getaddrinfo()重试(AF_UNSPEC → AF_INET) 首次IPv6连接失败 增加延迟,需合理超时控制
graph TD
    A[发起连接] --> B{getaddrinfo 返回IPv6地址?}
    B -->|是| C[尝试IPv6 connect]
    C --> D{超时/ENETUNREACH?}
    D -->|是| E[回退调用 getaddrinfo with AF_INET]
    D -->|否| F[成功]
    E --> G[使用IPv4地址连接]

2.5 网络中间设备(如SSL解密网关)篡改TLS SNI字段的取证与规避

TLS握手中的SNI脆弱性

SNI(Server Name Indication)在ClientHello明文传输,中间设备可合法修改其值以重定向流量或实施策略控制,但该行为会破坏证书链一致性。

取证方法:Wireshark过滤与比对

# 捕获并提取SNI字段(需TLS解密密钥或使用RSA密钥)
tshark -r traffic.pcap -Y "ssl.handshake.type == 1" \
  -T fields -e ssl.handshake.extensions_server_name

此命令提取所有ClientHello中的SNI域名。若发现SNI与后续Certificate消息中Subject CN/SAN不匹配,即存在篡改嫌疑;-Y 过滤仅保留ClientHello(type=1),-e 指定扩展字段路径。

规避方案对比

方案 原理 兼容性 部署复杂度
ESNI(已弃用) 加密SNI(DNS+TLS 1.2) 低(Chrome 80+停用)
ECH(RFC 9250) Encrypted Client Hello,端到端加密整个ClientHello 中(Firefox 120+/Chrome 127+支持)

ECH协商流程

graph TD
    A[Client] -->|ECHConfig from DNS HTTPS RR| B[Server]
    A -->|Encrypted ClientHello| C[Intermediary]
    C -->|转发至Server| B
    B -->|解密ECH并验证| D[原始SNI+ALPN]
  • ECH使SNI、ALPN、密钥共享等关键字段均加密,中间设备无法解析或篡改;
  • 依赖DNS HTTPS记录分发公钥,需服务端与DNS协同配置。

第三章:证书体系失效引发的模块拉取中断

3.1 私有CA证书未注入Go信任链的跨平台校验修复流程

当Go程序在Linux/macOS/Windows上访问使用私有CA签发的HTTPS服务时,常因系统信任库与Go运行时信任链分离导致x509: certificate signed by unknown authority错误。

核心修复路径

  • 方案一:显式加载私有CA证书到http.Transport.TLSClientConfig.RootCAs
  • 方案二:通过环境变量GODEBUG=x509ignoreCN=0(仅调试)+ 自定义tls.Config
  • 方案三(推荐):统一注入证书并复用系统信任库逻辑

Go代码示例(跨平台安全加载)

// 加载私有CA证书到自定义CertPool
caCert, err := os.ReadFile("/path/to/private-ca.crt")
if err != nil {
    log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

client := &http.Client{
    Transport: &http.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs: caCertPool, // 强制覆盖默认信任链
        },
    },
}

此代码绕过Go默认信任机制(crypto/tls不自动读取/etc/ssl/certs或Windows根存储),显式绑定私有CA。RootCAs非nil时,Go完全忽略系统信任库,确保行为一致。

平台适配要点对比

平台 系统证书路径 Go是否自动识别
Linux /etc/ssl/certs/ca-certificates.crt
macOS Keychain(System Roots)
Windows CryptoAPI根存储
graph TD
    A[发起HTTPS请求] --> B{Go TLS配置中RootCAs是否为nil?}
    B -->|是| C[尝试加载系统证书<br/>(仅Linux部分发行版支持)]
    B -->|否| D[使用显式指定的CertPool校验]
    D --> E[私有CA证书验证通过]

3.2 时间偏差(Clock Skew)导致X.509证书过期误判的自动化检测脚本

当客户端与CA服务器时钟不同步时,NotBefore/NotAfter 时间戳可能被本地系统错误解析为已过期或未生效——即使证书在全局时间视图下完全有效。

核心检测逻辑

需比对本地系统时间、证书内嵌UTC时间、以及可信NTP源时间三者偏移量。

时间偏差容忍阈值建议

  • 允许偏差:≤5分钟(RFC 5280 推荐)
  • 警告阈值:3–5 分钟
  • 错误阈值:>5 分钟
# 获取证书有效期(UTC)并转换为Unix时间戳
openssl x509 -in cert.pem -noout -dates | \
  awk -F'=' '/notBefore|notAfter/ {print $2}' | \
  xargs -I{} date -d "{} UTC" +%s 2>/dev/null

逻辑说明:提取 notBeforenotAfter 的原始字符串,强制按UTC解析并转为秒级时间戳,规避本地时区干扰;2>/dev/null 忽略解析失败项(如格式异常)。

检测流程示意

graph TD
  A[读取证书] --> B[解析NotBefore/NotAfter UTC时间]
  B --> C[获取本地系统UTC时间]
  C --> D[查询权威NTP服务器时间]
  D --> E[计算三者两两偏差]
  E --> F{任一偏差>300s?}
  F -->|是| G[标记clock_skew_warning]
  F -->|否| H[视为时间可信]
偏差组合 安全影响
本地 vs NTP >5m 可能误判证书过期
证书 vs NTP >5m 证书签发/过期时间异常
本地 vs 证书 >5m 系统时钟严重漂移

3.3 通配符证书与模块域名不匹配的Subject Alternative Name动态验证方法

当微服务模块注册域名(如 api.pay-svc.v2.example.com)与通配符证书 *.svc.example.com 不满足子域包含关系时,静态 SAN 校验必然失败。需引入运行时动态解析与上下文感知验证。

验证流程设计

graph TD
    A[获取请求Host] --> B{是否匹配通配符主域?}
    B -->|否| C[提取模块前缀 pay-svc.v2]
    C --> D[查询服务注册中心获取真实归属域]
    D --> E[构造临时SAN候选集]
    E --> F[逐项执行DNS+TLS握手验证]

动态SAN构造逻辑

def build_dynamic_san_candidates(host: str, cert: x509.Certificate) -> List[str]:
    # host = "api.pay-svc.v2.example.com"
    base_domain = extract_wildcard_base(cert)  # "*.svc.example.com" → "svc.example.com"
    parts = host.split('.')
    # 尝试向上归并:pay-svc.v2.svc.example.com, v2.svc.example.com, svc.example.com
    return [f"{'.'.join(parts[:i])}.{base_domain}" 
            for i in range(1, min(4, len(parts))) 
            if parts[i-1] not in ['www', 'api']]  # 过滤通用前缀

该函数基于证书通配符基域反向推导合法子域组合,避免硬编码规则;i 控制深度防止过度泛化,parts[:i] 提取原始主机中模块标识段。

候选域名验证优先级

优先级 候选 SAN 匹配依据
1 pay-svc.v2.svc.example.com 模块全路径+基域
2 v2.svc.example.com 版本号+基域(降级兼容)
3 svc.example.com 通配符原始基域

第四章:HTTP/HTTPS协议交互异常的四类深层诱因

4.1 HTTP/2 ALPN协商失败与Go默认客户端强制升级的兼容性补丁

当Go 1.18+客户端连接仅支持HTTP/1.1的旧服务端时,http.DefaultClient会主动发起h2 ALPN协商;若服务端未响应h2而回退至http/1.1,Go默认不重试,直接返回x509: certificate signed by unknown authority(实为ALPN mismatch误报)。

根本原因

  • Go crypto/tls 默认启用NextProtos: []string{"h2", "http/1.1"}
  • 服务端若忽略ALPN或仅声明"http/1.1",TLS握手成功但RoundTrip因协议不匹配静默失败

补丁方案

tr := &http.Transport{
    TLSClientConfig: &tls.Config{
        NextProtos: []string{"http/1.1"}, // 禁用h2协商,强制降级
    },
}
client := &http.Client{Transport: tr}

此配置绕过ALPN协商阶段,使TLS层仅声明http/1.1,避免服务端因不支持h2导致的连接中断。NextProtos为空切片时Go会使用默认值,显式设为[]string{"http/1.1"}可确保协议栈不尝试HTTP/2。

场景 NextProtos 值 行为
默认(空) ["h2","http/1.1"] 强制协商h2,失败则不回退
显式指定 ["http/1.1"] 跳过ALPN协商,直连HTTP/1.1
graph TD
    A[Client发起TLS握手] --> B{NextProtos包含“h2”?}
    B -->|是| C[服务端必须响应h2]
    B -->|否| D[接受任意ALPN响应或无响应]
    C -->|不支持h2| E[连接关闭]
    D --> F[继续HTTP/1.1通信]

4.2 服务端返回302重定向但Location含非标准端口时的go mod proxy fallback机制缺陷

GOPROXY 指向的代理(如 https://proxy.golang.org)返回 302 Found,且 Location: https://example.com:8080/v2/... 含显式非标准端口(:8080)时,Go 的 cmd/go 内部 modfetch 会触发 fallback 行为:忽略该 Location,转而尝试直接连接原始模块路径的默认端口(443)

根本原因

Go 官方实现中,modfetch/http.goparseRedirURL 函数仅校验 scheme 和 host,未保留 port 字段,导致 url.Port() 调用返回空,后续 net/http 构建请求时强制使用默认端口。

// src/cmd/go/internal/modfetch/http.go(简化)
func parseRedirURL(base, loc string) (*url.URL, error) {
    u, err := url.Parse(loc)
    if u.Port() == "" && u.Scheme == "https" {
        u.Host = u.Host + ":443" // ❌ 强制覆盖端口,丢弃原始 :8080
    }
    return u, err
}

此逻辑误将“显式非标端口”等同于“非法端口”,跳过合法重定向目标,造成 404connection refused

影响范围

  • go get example.com:8080/pkg@v1.0.0(直连)正常
  • GOPROXY=https://my-proxy.io go get example.com/pkg@v1.0.0(经 proxy 302 到 :8080)失败
场景 是否触发 fallback 结果
Location: https://a.b/c 成功
Location: https://a.b:8080/c 请求 https://a.b:443/c → 404
graph TD
    A[收到302] --> B{Location含非标端口?}
    B -->|是| C[strip port → :443]
    B -->|否| D[保留原Host]
    C --> E[发起错误请求]

4.3 TLS 1.3 Early Data(0-RTT)被中间设备丢弃引发的模块索引获取失败

当客户端启用 0-RTT 发起 GET /api/modules 请求时,Early Data 载荷可能被企业防火墙、负载均衡器或 DPI 设备静默丢弃,仅保留初始 ClientHello,导致服务端无法解析后续 HTTP 请求。

常见丢弃场景

  • 中间设备未实现 TLS 1.3 0-RTT 状态机
  • 启用“TLS 握手严格校验”策略
  • 缓存代理强制降级至 1-RTT

协议层表现

# 客户端实际发送(含 0-RTT)
GET /api/modules HTTP/1.1
Host: api.example.com
...
# → 中间设备截断 Early Data,仅透传握手帧

服务端收到完整 TLS 握手但无应用数据,HttpRequest 解析超时,模块索引接口返回 504 Gateway Timeout

兼容性恢复策略

措施 生效层级 风险
禁用 0-RTT(ssl_conf_cmd SSL_OP_NO_0RTT OpenSSL 降低首屏延迟
启用重试退避(指数回退 + 1-RTT fallback) 应用层 需幂等设计
graph TD
    A[Client sends 0-RTT request] --> B{Middlebox drops Early Data?}
    B -->|Yes| C[Server waits for second flight]
    B -->|No| D[Normal 0-RTT processing]
    C --> E[Timeout → module index fetch fails]

4.4 Content-Type非application/vnd.goproxy+json响应体的静默解析错误定位与mock测试

当Go Proxy客户端收到 Content-Type: application/json(而非规范要求的 application/vnd.goproxy+json)时,现有解析器可能跳过MIME校验,直接尝试JSON反序列化——导致结构错位却无日志告警。

错误传播路径

// proxy/client.go 中易忽略的 MIME 检查点
resp, _ := http.DefaultClient.Do(req)
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "vnd.goproxy+json") {
    // ❌ 缺失此处 error return 或 warn log → 静默进入 decode 流程
}

该分支缺失处理,使非法类型响应被 json.Unmarshal() 强行解析,字段映射失效但不 panic。

Mock 测试关键断言

场景 响应 Header 预期行为
合规响应 Content-Type: application/vnd.goproxy+json 正常解析
降级响应 Content-Type: application/json 触发 ErrInvalidContentType
graph TD
    A[HTTP Response] --> B{Content-Type match?}
    B -->|Yes| C[Decode as GoProxy JSON]
    B -->|No| D[Return ErrInvalidContentType]

第五章:企业内网适配清单与灾备切换终极指南

内网环境核心适配项校验清单

企业内网常存在协议白名单、端口封锁、DNS劫持、SSL中间人代理等隐性限制。必须逐项验证:

  • HTTP/HTTPS出口是否强制经由统一安全网关(如启明星辰UTM),证书链是否被替换;
  • WebSocket连接在Nginx反向代理后是否因Upgrade头被过滤而中断;
  • gRPC服务在启用TLS时,客户端是否预置了内网CA根证书(常见于金融行业PKI体系);
  • 容器平台(如Kubernetes)Pod间通信是否依赖Calico BGP模式,而内网交换机未开启BGP路由同步。

灾备切换前的七层连通性压测模板

使用k6脚本模拟真实业务流量进行灾备链路验证:

import http from 'k6/http';
import { sleep } from 'k6';

export default function () {
  const res = http.post('https://api-dr.internal.company.com/v2/order', 
    JSON.stringify({ orderId: `TEST-${__ENV.TEST_ID}` }), 
    { headers: { 'X-Internal-Token': __ENV.INTERNAL_TOKEN } }
  );
  sleep(1);
}

执行命令:k6 run --vus 50 --duration 5m --env TEST_ID=DR-2024Q3 --env INTERNAL_TOKEN=$(cat /etc/secrets/dr-token) load-test.js

主备中心DNS解析策略对照表

域名 生产中心TTL 灾备中心TTL 切换生效窗口 解析异常兜底方案
auth.company.com 300s 60s ≤90秒 强制本地hosts映射至DR VIP
db-proxy.company.com 60s 30s ≤45秒 应用层连接池自动重试+熔断降级
file-gateway.company.com 1800s 120s ≤3分钟 CDN边缘节点缓存回源策略临时关闭

真实故障复盘:某省农信社核心系统RTO突破阈值事件

2023年11月,因灾备中心Redis集群未同步启用notify-keyspace-events Ex配置,导致订单超时清理任务失效;同时主中心DNS服务器故障后,部分网点终端仍缓存旧A记录达2小时(TTL设置为7200)。最终通过手动下发ipconfig /flushdns指令+修改本地C:\Windows\System32\drivers\etc\hosts完成紧急恢复,实际RTO达142分钟,远超SLA承诺的5分钟。

自动化切换检查点编排流程图

flowchart TD
    A[触发灾备切换指令] --> B{主中心心跳检测失败?}
    B -->|是| C[验证灾备中心数据库只读状态]
    B -->|否| D[终止切换流程并告警]
    C --> E[执行binlog位点比对]
    E --> F{差异≤100条?}
    F -->|是| G[将灾备DB设为可写]
    F -->|否| H[启动增量日志追赶]
    G --> I[更新全局配置中心ZooKeeper开关]
    I --> J[滚动重启API网关实例]
    J --> K[向监控平台推送切换完成事件]

内网专用灾备健康度看板指标定义

  • dr_network_latency_p95:从灾备中心任意节点ping主中心核心交换机的P95延迟(阈值≤8ms);
  • cert_expiration_days:灾备中心所有双向mTLS证书剩余有效期(预警线<45天);
  • etcd_leader_transfers_1h:灾备ETCD集群每小时Leader切换次数(异常值>3次需介入);
  • kafka_dr_topic_lag:灾备Kafka集群消费主中心Topic的滞后消息数(阈值<500条)。

银行级灾备演练红蓝对抗要点

红队模拟攻击:在灾备中心注入伪造的/healthz响应(返回200但DB连接已断),检验蓝队能否识别该“假存活”状态;蓝队须在10分钟内通过tcpdump -i any port 5432 and host <dr-db-ip>确认真实连接状态,并触发kubectl delete pod -n dr-postgres postgres-0强制重建实例。每次演练后更新《灾备配置基线快照》,使用git diff比对前后Ansible Playbook变更。

热爱算法,相信代码可以改变世界。

发表回复

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