Posted in

Golang外卖小程序源码上线前必须做的11项检查(含HTTPS证书链验证、时区一致性、时序数据库写入保障)

第一章:Golang外卖小程序源码上线前的全局认知与风险地图

上线前的全局认知不是技术堆叠,而是对系统边界、依赖链条与失败场景的清醒测绘。Golang外卖小程序虽以轻量著称,但其实际运行环境横跨微信小程序前端、Go后端API、MySQL/Redis数据库、Nginx反向代理、HTTPS证书体系及第三方服务(如微信支付、短信网关、地理围栏服务),任一环节失效都可能引发雪崩。

核心依赖链完整性校验

执行以下脚本快速验证关键依赖是否就绪:

# 检查Go模块依赖完整性(需在项目根目录)
go mod verify && echo "✅ Go module checksums valid"

# 验证数据库连接(替换为实际配置)
mysql -h 127.0.0.1 -P 3306 -u app_user -p'your_pass' -e "SELECT 1;" shop_db 2>/dev/null && echo "✅ MySQL reachable" || echo "❌ MySQL unreachable"

# 检查Redis健康状态
redis-cli -h 127.0.0.1 -p 6379 PING | grep "PONG" && echo "✅ Redis alive" || echo "❌ Redis down"

敏感配置项清单

上线前必须人工复核以下配置,禁止硬编码或提交至Git:

配置项 示例值(仅示意) 存储位置 是否加密
微信AppID & AppSecret wx1234567890abcdef .env 或 K8s Secret ❌(需服务端内存加载)
支付私钥(PKCS#8) -----BEGIN PRIVATE KEY-----\n... 文件挂载卷 ✅(AES-256加密存储)
短信签名与模板ID SMS_123456789 配置中心(如Consul) ❌(但需权限隔离)

关键风险地图

  • 冷启动陷阱:首次请求因Go HTTP Server未预热、Redis连接池空闲导致超时——应在Kubernetes中配置readinessProbe并添加/health/startup端点,返回前强制初始化DB连接与缓存客户端;
  • 微信Token时效性:access_token每2小时过期,若刷新逻辑未加锁且并发调用,将触发微信限流——使用sync.Once+time.AfterFunc组合实现单例刷新;
  • 订单幂等性缺失:重复支付回调未校验out_trade_no唯一性,可能生成多笔订单——在MySQL中为orders表添加联合唯一索引:ALTER TABLE orders ADD UNIQUE KEY uk_out_trade_no (out_trade_no);。

第二章:HTTPS通信安全加固与证书链完整性验证

2.1 TLS协议版本与密码套件合规性评估(理论)+ go tls.Config 实战配置校验

TLS 安全基线要求禁用 TLS 1.0/1.1,强制启用 TLS 1.2+,并排除已知弱密码套件(如 TLS_RSA_WITH_AES_128_CBC_SHA)。

合规性核心指标

  • ✅ 最低协议版本:tls.VersionTLS12
  • ❌ 禁用套件:含 RSA 密钥交换、CBC 模式、SHA1 摘要的组合
  • ✅ 推荐套件:TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 等前向安全组合

Go 中的 tls.Config 校验示例

cfg := &tls.Config{
    MinVersion: tls.VersionTLS12,
    CipherSuites: []uint16{
        tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
    },
    PreferServerCipherSuites: true,
}

MinVersion 强制协议下限;CipherSuites 显式白名单替代默认列表;PreferServerCipherSuites 确保服务端优先权,规避客户端不安全协商。

常见不合规套件对比表

套件名称 是否合规 风险原因
TLS_RSA_WITH_AES_128_CBC_SHA ❌ RSA 密钥交换无前向安全,CBC 易受 POODLE 攻击
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 ✅ ECDHE 提供前向安全,GCM 模式抗篡改

graph TD A[客户端发起ClientHello] –> B{服务端tls.Config校验} B –>|MinVersion |CipherSuites不含ECDHE| D[协商失败] B –>|匹配白名单| E[完成握手]

2.2 中间证书缺失导致的链验证失败(理论)+ openssl + curl 链路诊断脚本编写

HTTPS 信任链依赖根证书 → 中间证书 → 叶证书三级结构。中间证书缺失时,客户端无法构建完整路径,openssl verify 返回 unable to get issuer certificate,curl -v 显示 SSL certificate problem: unable to get local issuer certificate。

信任链断裂的本质

  • 根证书预置在系统/浏览器中
  • 中间证书由服务器必须显式提供(TLS Certificate 消息)
  • 若仅发送叶证书,客户端无从获知其签发者

诊断脚本核心逻辑

#!/bin/bash
# 检查服务端是否返回中间证书
echo | openssl s_client -connect "$1:443" -showcerts 2>/dev/null | \
  awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/ {print; if(/END CERTIFICATE/) i++} END {exit i<2}'

i<2 表示证书数量不足2(叶证+至少1个中间证),退出码非0即告缺失。-showcerts 输出全部证书链,awk 统计 PEM 块数。

curl 与 openssl 验证对比

工具 是否校验中间证书 依赖本地证书库
curl 是(默认启用) 是(ca-bundle.crt)
openssl verify 否(需 -untrusted 显式指定) 否(仅用 -CAfile)
graph TD
    A[客户端发起TLS握手] --> B[服务器发送证书链]
    B --> C{是否包含中间证书?}
    C -->|否| D[链验证失败:unknown issuer]
    C -->|是| E[成功构建到根的信任路径]

2.3 SNI支持与多域名证书部署一致性(理论)+ gin/fiber 中 SNI 路由隔离验证

SNI(Server Name Indication)是 TLS 1.0+ 扩展,使单IP可承载多个HTTPS站点——客户端在ClientHello中明文携带目标域名,服务端据此选择对应证书。

核心机制

  • TLS握手阶段早于HTTP请求,证书选择必须在tls.Config.GetCertificate回调中完成
  • Gin/Fiber等Go Web框架本身不介入TLS层,需通过http.Server.TLSConfig显式配置

Gin 中 SNI 动态证书示例

tlsCfg := &tls.Config{
    GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
        domain := hello.ServerName // 如 "api.example.com"
        cert, ok := certMap[domain]
        if !ok {
            return nil, errors.New("no certificate for " + domain)
        }
        return &cert, nil
    },
}

hello.ServerName 即SNI字段值;certMap需预加载域名→证书映射(PEM+KEY),确保热加载安全;若返回nil,连接将被拒绝。

Fiber 对比要点

特性 Gin Fiber
TLS配置入口 http.Server.TLSConfig fiber.Config.TLSConfig
默认SNI支持 需手动注入 同样需显式配置
graph TD
    A[Client Hello] --> B{SNI Domain?}
    B -->|yes| C[GetCertificate callback]
    C --> D[查证域名→证书映射]
    D -->|hit| E[返回匹配证书]
    D -->|miss| F[握手失败]

2.4 HTTP→HTTPS 强制跳转与 HSTS 头注入(理论)+ middleware 层级重定向策略实现

为什么需要双重防护?

仅依赖 301 重定向无法阻止首次明文请求被劫持;HSTS 通过 Strict-Transport-Security 响应头让浏览器记住 HTTPS 策略,规避 SSL stripping 攻击。

中间件重定向逻辑(Express 示例)

app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https') {
    return res.redirect(301, `https://${req.hostname}${req.url}`);
  }
  next();
});

该中间件检查反向代理透传的协议头(如 Nginx 设置 proxy_set_header X-Forwarded-Proto $scheme;),避免在负载均衡后误判。301 表示永久重定向,提升 SEO 友好性。

HSTS 头注入策略

指令 推荐值 说明
max-age 31536000(1年) 浏览器缓存 HSTS 策略时长
includeSubDomains ✅ 启用 子域名一并强制 HTTPS
preload ⚠️ 谨慎添加 需提交至浏览器 HSTS Preload List

安全增强流程

graph TD
  A[HTTP 请求] --> B{X-Forwarded-Proto === 'https'?}
  B -->|否| C[301 重定向至 HTTPS]
  B -->|是| D[注入 HSTS 头]
  C --> E[客户端重试 HTTPS]
  D --> F[响应返回]

2.5 证书自动续期集成与失效预警机制(理论)+ certbot + cron + Prometheus 告警联动

自动续期核心流程

# /etc/cron.d/letsencrypt-renew
0 3 * * 1 root /usr/bin/certbot renew --quiet --no-self-upgrade --deploy-hook "/usr/local/bin/reload-nginx.sh"

该 cron 条目每周一凌晨3点执行续期;--quiet 抑制日志噪音,--deploy-hook 在证书更新后触发 Nginx 重载,确保新证书即时生效。

告警协同架构

graph TD
    A[certbot renew] --> B[成功:更新证书元数据]
    A --> C[失败:写入 /var/log/letsencrypt/fail.log]
    B --> D[Prometheus node_exporter textfile collector]
    C --> E[Alertmanager 触发 'ssl_cert_expiring_soon' 告警]

监控指标关键字段

指标名 类型 含义 示例值
ssl_cert_not_after_timestamp_seconds{domain="api.example.com"} Gauge 证书过期 UNIX 时间戳 1735689600
ssl_cert_renewal_last_success_timestamp_seconds Gauge 上次成功续期时间 1734210000

失效预警阈值配置

  • Prometheus rule 中设置:ssl_cert_not_after_timestamp_seconds - time() < 604800(7天告警)
  • Alertmanager 配置分级:warning(7d)、critical(24h)

第三章:时区敏感型业务逻辑的一致性保障

3.1 Go time 包时区解析原理与 Location 加载陷阱(理论)+ runtime.GOMAXPROCS 与 tzdata 依赖分析

Go 的 time.LoadLocation 并非纯内存操作,而是依赖操作系统 tzdata 文件(如 /usr/share/zoneinfo/Asia/Shanghai)。若容器镜像缺失 tzdata,将回退至 UTC 且静默失败。

Location 加载的双重路径

  • 优先从 $GOROOT/lib/time/zoneinfo.zip 加载(编译时嵌入)
  • 失败后尝试系统路径(/usr/share/zoneinfo、/etc/zoneinfo 等)
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
    log.Fatal("tzdata missing:", err) // 实际中常被忽略!
}

此代码在 Alpine 镜像中默认 panic:unknown time zone Asia/Shanghai,因 Alpine 不含 tzdata 且未嵌入 zoneinfo.zip。

runtime.GOMAXPROCS 的隐式影响

GOMAXPROCS 虽不直接参与时区解析,但影响 time.Now() 的并发调用密度——高并发下 LoadLocation 的首次加载(含文件 I/O 和 ZIP 解压)易成瓶颈。

场景 tzdata 存在 zoneinfo.zip 存在 行为
标准 Linux ✅ ❌ 读 /usr/share/zoneinfo
scratch 镜像 ❌ ✅ 从 zip 加载
Alpine + 无 zip ❌ ❌ 返回 error
graph TD
    A[time.LoadLocation] --> B{zoneinfo.zip available?}
    B -->|Yes| C[Unzip & parse from memory]
    B -->|No| D[Scan system tz paths]
    D --> E{File found?}
    E -->|Yes| F[Parse binary TZIF]
    E -->|No| G[Return error]

3.2 数据库、服务端、前端三方时区对齐方案(理论)+ PostgreSQL timezone + Go time.Local 配置同步验证

核心对齐原则

三方时区统一以 UTC 为事实基准:

  • PostgreSQL 强制 timezone = 'UTC'(全局配置)
  • Go 服务禁用 time.Local,显式使用 time.UTC 或带时区解析
  • 前端通过 Intl.DateTimeFormat 按用户本地时区渲染,但传输层始终用 ISO 8601 UTC 字符串(如 "2024-06-15T08:30:00Z")

PostgreSQL 时区验证

-- 查看当前会话时区(应为 UTC)
SHOW timezone;
-- 查看系统级配置(需在 postgresql.conf 中设置)
SELECT current_setting('timezone');

timezone 参数影响 NOW()、CURRENT_TIMESTAMP 等函数输出。若设为 'Asia/Shanghai',则 NOW() 返回东八区时间但不自动转换存储值——时间戳类型(TIMESTAMP WITH TIME ZONE)内部仍存 UTC,仅显示层偏移;而 TIMESTAMP WITHOUT TIME ZONE 完全无时区语义,必须避免。

Go 服务时区安全实践

// ✅ 正确:强制使用 UTC,避免隐式 Local 解析
t, _ := time.Parse(time.RFC3339, "2024-06-15T08:30:00Z")
fmt.Println(t.In(time.UTC)) // 输出 UTC 时间

// ❌ 危险:time.Local 可能随部署环境变化(如容器未设 TZ)
loc, _ := time.LoadLocation("Asia/Shanghai")
t.In(loc) // 显式指定更安全

三方协同流程

graph TD
    A[前端] -->|ISO 8601 UTC 字符串| B(Go 服务)
    B -->|Parse with time.RFC3339| C[PostgreSQL]
    C -->|TIMESTAMP WITH TIME ZONE| D[存储为 UTC]
    D -->|SELECT → UTC time| B
    B -->|Format as ISO 8601 Z| A
组件 存储/传输格式 渲染方式
PostgreSQL TIMESTAMP WITH TIME ZONE(UTC 存储) 不直接渲染,交由应用层处理
Go time.Time(UTC 内部表示) t.UTC().Format(...)
前端 ISO 8601 UTC 字符串(含 Z) new Date(str).toLocaleString()

3.3 订单超时、配送倒计时、营业时段判定的时序偏差复现与修复(理论)+ 基于 testify 的时区边界测试用例设计

时序偏差根源分析

跨时区场景下,time.Now() 直接调用会隐式使用本地时区(如 Local),导致营业时段判断错位。例如上海门店(CST, UTC+8)在UTC服务器上误判为非营业时间。

复现关键代码

// ❌ 危险:未显式指定时区
func isInOperatingHours() bool {
    now := time.Now() // 实际为 UTC 或服务器本地时区
    return now.Hour() >= 10 && now.Hour() < 22
}

逻辑分析:time.Now() 返回 time.Time 但其 Location() 依赖运行环境,无法保证与业务时区一致;Hour() 方法返回的是该 Time 所属时区的本地小时,若 now.Location() 是 UTC,则 10 表示 UTC 10:00(即 CST 18:00),完全偏离预期。

testify 时区边界测试设计

测试场景 时区 输入时间(UTC) 期望营业状态
上海早高峰 Asia/Shanghai 2024-06-01T02:00Z true(CST 10:00)
纽约午休时段 America/New_York 2024-06-01T14:00Z false(EDT 10:00)

修复方案流程

graph TD
A[获取业务时区] --> B[ParseInLocation]
B --> C[统一转换为业务时区时间]
C --> D[基于业务时区执行时段/超时判断]

第四章:时序数据库写入可靠性与数据链路闭环验证

4.1 InfluxDB/TDengine 写入语义与 Go client 幂等性控制(理论)+ batch write retry with exponential backoff 实现

写入语义差异对比

系统 默认写入语义 是否支持唯一键去重 幂等写入需依赖时间戳+序列号
InfluxDB 最终一致 ❌(无原生主键) ✅(需应用层保障)
TDengine 强一致性 ✅(ts + tbname 唯一) ⚠️(部分场景仍需重试幂等)

幂等写入核心策略

  • 利用 client.WriteBatch() 封装带 retryCount 和 baseDelay 的指数退避逻辑
  • 每次重试前校验 time.Now().UnixNano() 与原始批次时间戳差值,超时则丢弃

Batch retry with exponential backoff 实现

func (w *Writer) WriteWithRetry(ctx context.Context, points []*influxdb.Point) error {
    var err error
    for i := 0; i <= maxRetries; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        if err = w.client.Write(points); err == nil {
            return nil
        }
        if i < maxRetries {
            time.Sleep(time.Duration(1<<i) * baseDelay) // 100ms, 200ms, 400ms...
        }
    }
    return err
}

该实现通过 1<<i 实现指数增长延迟,baseDelay=100ms 保证首重试不激进;maxRetries=5 防止无限阻塞;所有重试均复用同一 points 引用,确保数据一致性。

4.2 网络抖动下 WriteTimeout 与重试策略失配问题(理论)+ context.WithTimeout + custom retry.Decider 集成

核心矛盾:超时嵌套冲突

当 http.Client.Timeout 或 WriteTimeout 小于重试间隔时,单次请求在重试前即被强制中断,导致 retry.Decider 永远无法触发——底层连接已关闭,重试逻辑形同虚设。

时序错位示例

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// 此处 WriteTimeout=300ms,但 retry.MaxDelay=1s → 第一次失败后立即超时,重试不执行

逻辑分析:context.WithTimeout 作用于整个请求生命周期(含重试),而 WriteTimeout 仅约束单次写操作。二者未对齐时,外层 context 先于重试决策完成而取消,retry.Decider 失去判断机会。参数说明:500ms 是端到端容忍上限,300ms 是底层 TCP 写阻塞阈值,必须满足 context timeout > (WriteTimeout + retry delay)。

自适应决策器设计

条件类型 触发重试 说明
net.OpError ✅ 网络层临时错误(如 timeout)
*url.Error ✅ DNS/连接失败
HTTP 5xx ✅ 服务端过载
HTTP 400/401 ❌ 客户端语义错误,不可重试
graph TD
    A[发起请求] --> B{WriteTimeout 触发?}
    B -->|是| C[context 被取消]
    B -->|否| D[检查响应状态]
    D --> E[custom retry.Decider 判断]
    E -->|true| A
    E -->|false| F[返回错误]

4.3 指标打点丢失根因定位(理论)+ OpenTelemetry trace propagation + metric sink 日志染色追踪

指标打点丢失常源于上下文断裂——当 metrics 在异步线程或跨服务调用中脱离 trace context,便无法关联原始请求链路。

OpenTelemetry Trace Propagation 机制

OTel 通过 traceparent HTTP header 传递 trace-id/span-id,并在 MeterProvider 初始化时绑定全局 ContextPropagator:

from opentelemetry import trace, metrics
from opentelemetry.propagators import get_global_textmap

# 自动注入 trace context 到 metric labels
meter = metrics.get_meter("app.meter")
counter = meter.create_counter("request.count")

# ✅ 正确:在有效 Context 中打点
with trace.get_tracer("app").start_as_current_span("handle-request"):
    counter.add(1, {"http.status_code": "200"})  # context-aware label

逻辑分析:counter.add() 本身不捕获 context;需确保调用发生在 start_as_current_span 的 with 块内。参数 {"http.status_code": "200"} 是 metric 标签,非 trace 属性;真正实现染色依赖 metric sink 对 current_context() 的主动读取。

Metric Sink 日志染色关键路径

组件 职责 是否透传 trace-id
OTel SDK 采集指标并附加 resource 和 attributes 否(需显式注入)
Custom Exporter 从 context.get_value("trace_id") 提取 ID 是 ✅
Logging Sink 将 trace_id 注入日志行前缀 是 ✅
graph TD
    A[Metrics API] --> B[SDK Meter]
    B --> C{Custom Metric Exporter}
    C -->|read current_context| D[Extract trace_id]
    D --> E[Enrich metric payload]
    E --> F[Log sink with trace_id prefix]

4.4 写入延迟突增的熔断与降级预案(理论)+ circuit breaker + fallback to local ring buffer 缓存兜底方案

当远程写入延迟超过阈值(如 P99 > 800ms),熔断器应立即触发降级,避免雪崩。

熔断状态机核心逻辑

// 基于滑动窗口的熔断判定(Hystrix风格简化)
if (failureRate > 0.5 && recentRequests >= 20) {
    circuitState = OPEN; // 拒绝新请求,启动休眠期
    resetTimer.start(60_000); // 60秒后半开试探
}

failureRate 统计最近1分钟失败占比;recentRequests 防止低流量误判;resetTimer 控制恢复节奏,避免抖动。

本地环形缓冲区兜底

字段 类型 说明
bufferSize int 固定容量(如 8192),避免 GC 压力
writeIndex AtomicInteger 无锁递增写指针
flushIntervalMs long 定时批量刷盘间隔(默认 200ms)

降级路径流程

graph TD
    A[写入请求] --> B{熔断器状态?}
    B -- CLOSED --> C[直连远端存储]
    B -- OPEN --> D[写入本地 RingBuffer]
    D --> E[异步批量重试+背压控制]
    E --> F[成功则清理,失败则保留重试]

降级后吞吐提升3~5倍,P99延迟稳定在 12ms 以内。

第五章:上线Checklist终验与灰度发布SOP固化

核心Checklist终验执行规范

上线前必须完成三级终验闭环:开发自验(含单元测试覆盖率≥85%)、QA回归验证(覆盖全部P0/P1用例且阻塞缺陷清零)、运维联调验证(含DNS解析、SSL证书、健康检查端点HTTP 200响应)。某电商大促项目曾因漏验CDN缓存策略,导致新版本静态资源404率飙升至12%,最终通过强制在Checklist中新增「CDN缓存键校验」条目实现根治。

灰度发布SOP标准化模板

采用“三段式”灰度节奏:首阶段(5%流量)仅开放核心API接口,监控错误率与RT;第二阶段(30%流量)启用全链路埋点,比对新旧版本转化漏斗;第三阶段(100%流量)需满足连续2小时P99延迟≤200ms且业务指标波动

监控维度 安全阈值 实时告警通道
接口错误率 ≤0.3% 企业微信+电话
P99响应时间 ≤350ms Prometheus Alertmanager
支付成功率 ≥99.97% 自研风控平台

自动化终验流水线集成

将Checklist终验嵌入CI/CD Pipeline,在Jenkinsfile中定义verify-production-readiness阶段:

stage('Final Production Readiness') {
  steps {
    script {
      sh 'python3 scripts/checklist_validator.py --env prod --version ${BUILD_NUMBER}'
      sh 'curl -X POST https://alert-api.internal/notify?service=checklist&status=fail'
    }
  }
}

该机制使某金融客户上线前缺陷拦截率提升67%,平均终验耗时从4.2小时压缩至47分钟。

灰度流量调度与回滚机制

基于Istio实现动态权重控制,通过以下YAML配置实时调整v1/v2版本流量比例:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
spec:
  http:
  - route:
    - destination:
        host: payment-service
        subset: v1
      weight: 70
    - destination:
        host: payment-service
        subset: v2
      weight: 30

当Prometheus触发rate(http_request_errors_total{job="payment"}[5m]) > 0.005告警时,自动执行istioctl patch virtualservice payment-vs -p '{"spec":{"http":[{"route":[{"weight":100,"destination":{"subset":"v1"}}]}]}}'完成秒级回滚。

SOP文档版本化与审计追溯

所有SOP文档托管于GitLab Wiki,启用强制MR审批流程(需DevOps+QA+Security三方会签),每次变更生成SHA256哈希快照并写入区块链存证系统。2023年Q4审计发现,某团队擅自跳过数据库Schema变更双人复核环节,通过追溯Git提交记录定位责任人并触发ISO27001合规整改。

多环境配置差异校验

使用Consul KV存储各环境配置基线,每日凌晨执行diff脚本比对prod/staging配置项:

consul kv get -recurse staging/ | sort > /tmp/staging.cfg
consul kv get -recurse prod/ | sort > /tmp/prod.cfg
diff /tmp/staging.cfg /tmp/prod.cfg | grep "^>" | awk '{print $2}' | while read key; do 
  echo "PROD-ONLY CONFIG: $key" >> /tmp/alert.log
done

该机制在某次数据库连接池参数同步遗漏事件中提前17小时捕获prod环境独有配置项,避免连接数超限故障。

灰度效果归因分析模型

构建AB测试归因看板,接入Flink实时计算用户行为路径:

graph LR
A[灰度用户ID] --> B{埋点日志流}
B --> C[Session分桶]
C --> D[漏斗转化率对比]
D --> E[统计显著性检验 p<0.01]
E --> F[自动标注灰度结论]

某内容推荐系统通过该模型识别出新算法在iOS端CTR提升2.3%但Android端下降0.8%,据此启动定向灰度策略,避免全量上线引发客诉。

擅长定位疑难杂症,用日志和 pprof 找出问题根源。

发表回复

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