Posted in

Go OAuth2.0服务落地全链路(企业级认证中台建设实录)

第一章:Go OAuth2.0服务落地全链路(企业级认证中台建设实录)

在大型分布式系统中,统一身份认证与授权能力是安全治理的基石。我们基于 Go 语言构建了高可用、可扩展的企业级 OAuth2.0 认证中台,支撑 20+ 业务线、日均 800 万次令牌颁发请求,SLA 达到 99.99%。

核心架构设计

采用分层解耦模型:接入层(RESTful API + OpenID Connect 兼容端点)、逻辑层(OAuth2.0 流程编排 + 策略引擎)、存储层(Redis 缓存令牌状态 + PostgreSQL 持久化客户端/用户/授权记录)。所有敏感操作(如 client_secret 生成、refresh_token 签发)均通过 crypto/rand 安全随机数生成器实现。

快速启动示例

使用 go-oauth2-server 社区增强版(已适配 PKCE 与 Device Authorization Grant):

# 1. 初始化数据库(PostgreSQL)
psql -U auth_admin -d auth_center -f ./migrations/001_init.sql

# 2. 启动服务(自动加载环境变量)
export AUTH_DB_URL="postgres://auth_admin:pwd@db:5432/auth_center?sslmode=disable"
export REDIS_ADDR="redis:6379"
go run cmd/server/main.go --port 8080

关键流程保障机制

  • 令牌生命周期控制:access_token 默认 30 分钟(JWT 签发含 expjti),refresh_token 单次有效且绑定设备指纹(user_agent + ip_hash
  • 客户端可信分级:区分 confidential(需 client_secret)与 public(仅限 PKCE)两类客户端,注册时强制校验 redirect_uri 白名单
  • 审计与可观测性:所有 /oauth/token 请求自动写入结构化日志(含 client_id、grant_type、scope、响应码),并上报 Prometheus 指标 oauth_token_issued_total{grant_type,client_type}
组件 技术选型 关键能力
签名算法 ES256(ECDSA) 替代 HS256,避免密钥泄露风险
会话管理 Redis Cluster 支持跨 AZ 部署,TTL 自动续期
动态客户端注册 RFC 7591 兼容接口 支持自动化 CI/CD 流水线集成

安全加固实践

启用 TLS 1.3 强制策略;所有重定向 URI 必须以 https://http://localhost 开头;/authorize 端点默认启用 prompt=consent 并支持自定义同意页模板;定期轮换 JWT 签名密钥(密钥版本号嵌入 JWT header 的 kid 字段)。

第二章:OAuth2.0协议内核与Go语言实现原理

2.1 授权码模式的协议流程解析与go-oauth2/server源码剖析

授权码模式(Authorization Code Flow)是 OAuth 2.0 最安全、最常用的授权方式,适用于有后端服务的 Web 应用。

核心交互阶段

  • 用户重定向至授权端点(/authorize),携带 client_idredirect_uriscoperesponse_type=code
  • 用户同意后,OAuth 服务器返回临时授权码(code)至客户端指定 redirect_uri
  • 客户端用 code + client_secret 向令牌端点(/token)交换 access_token

go-oauth2/server 关键处理逻辑

// server/handler/authorize.go 中的 HandleAuthorize 方法节选
func (h *AuthorizeHandler) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
    // 1. 解析并校验 client_id、redirect_uri、response_type=code
    // 2. 检查用户是否已登录(依赖外部 session 或 auth middleware)
    // 3. 生成短期有效的 authorization code(含绑定 client_id、user_id、redirect_uri)
    // 4. 302 重定向回 redirect_uri?code=xxx&state=yyy
}

该方法不直接颁发 token,仅生成并安全传递 code,确保敏感凭证不暴露在前端 URL 片段中。

授权码生命周期关键参数对比

参数 作用 默认有效期 存储位置
code 一次性交换 token 的凭证 10 分钟 Redis / DB(带 client_id 绑定)
state 防 CSRF,由客户端生成并回传 无过期 客户端内存 + 服务端校验
graph TD
    A[Client: GET /authorize] --> B{User Authenticated?}
    B -->|No| C[Redirect to Login]
    B -->|Yes| D[Generate code + state]
    D --> E[302 Redirect to redirect_uri?code=xxx&state=yyy]

2.2 Token生命周期管理:JWT签发、校验与Revocation的Go实践

JWT签发:安全生成与结构化载荷

使用github.com/golang-jwt/jwt/v5签发带自定义声明的Token:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "sub": "user_123",
    "exp": time.Now().Add(24 * time.Hour).Unix(),
    "jti": uuid.New().String(), // 唯一令牌ID,用于后续吊销追踪
})
signedToken, err := token.SignedString([]byte("secret-key"))

sub标识主体;exp强制过期控制;jti是Revocation机制的关键索引。密钥必须通过环境变量或KMS注入,禁止硬编码。

校验与吊销双轨机制

阶段 检查项 是否依赖存储
签名验证 HMAC/RS256完整性
时间有效性 nbf/exp时间窗口
吊销状态 jti是否存在于Redis黑名单

Revocation流程(基于Redis)

graph TD
    A[客户端请求] --> B{Token有效?}
    B -->|否| C[拒绝访问]
    B -->|是| D{jti in Redis blacklist?}
    D -->|是| C
    D -->|否| E[允许访问]

2.3 PKCE增强机制在移动端场景下的Go客户端与服务端协同实现

移动端OAuth 2.1强制要求PKCE(RFC 7636),以防范授权码拦截攻击。Go生态中,golang.org/x/oauth2原生支持PKCE,但需客户端与服务端严格协同。

客户端:生成并传递Code Verifier/Challenge

// 生成高熵code_verifier(43字节base64url)
verifier := base64.RawURLEncoding.EncodeToString(
    make([]byte, 32), // 实际应使用crypto/rand.Read
)
challenge := codeverifier.SHA256Challenge(verifier) // S256 method

config := oauth2.Config{
    Endpoint: oauth2.Endpoint{
        AuthURL:  "https://auth.example.com/oauth/authorize",
        TokenURL: "https://auth.example.com/oauth/token",
    },
    CodeChallenge:  challenge,
    CodeChallengeMethod: "S256",
}

code_verifier为客户端本地生成的密钥材料(不可传输),code_challenge是其SHA-256哈希后base64url编码值;服务端在token交换时校验二者一致性。

服务端校验流程(简略示意)

graph TD
    A[收到Authorization Request] --> B{含code_challenge?}
    B -->|Yes| C[存储challenge+method+state]
    B -->|No| D[拒绝请求]
    E[Token Exchange] --> F[用code_verifier重算challenge]
    F --> G[比对存储值]

关键参数对照表

参数 客户端职责 服务端验证动作
code_verifier 本地生成、仅内存持有、绝不外传 不接收,仅用于后续token请求校验
code_challenge 计算并携带于授权请求 存储并绑定authorization code
code_challenge_method 指定S256(推荐)或plain 必须支持S256,拒绝不安全method

服务端在/token端点必须复现挑战计算,并严格绑定code与challenge生命周期。

2.4 动态Client Registration协议标准与Go服务端可扩展注册中心设计

动态客户端注册(Dynamic Client Registration, DCR)是OAuth 2.1及OIDC核心扩展协议,允许客户端在运行时向授权服务器安全声明自身元数据并获取唯一client_id,无需预配置。

核心协议流程

graph TD
    A[Client POST /register] --> B[AuthZ Server 验证JWT/MTLS]
    B --> C[生成 client_id + client_secret]
    C --> D[返回含 issuer、jwks_uri 的JSON响应]

Go注册中心关键设计维度

  • 插件化验证器:支持JWT签名验签、mTLS双向认证、IP白名单等策略链
  • 元数据存储抽象ClientStore接口支持内存、Redis、PostgreSQL多后端
  • 生命周期钩子OnRegistered() / OnRevoked() 便于审计与同步

示例:注册请求处理逻辑

func (r *RegistrationHandler) Register(w http.ResponseWriter, req *http.Request) {
    var regReq ClientRegistrationRequest
    json.NewDecoder(req.Body).Decode(&regReq) // 包含 client_name, redirect_uris, token_endpoint_auth_method 等
    // ✅ 必须校验 redirect_uris 是否符合白名单正则
    // ✅ 必须拒绝未声明 token_endpoint_auth_method 的请求(默认不安全)
}

该逻辑强制执行OIDC规范第3.2.2节对redirect_uris的严格匹配与token_endpoint_auth_method显式声明要求,避免开放重定向与凭据泄露风险。

2.5 RFC8693 Token Exchange规范在微服务跨域授权中的Go适配实现

RFC8693 定义了安全、可审计的令牌交换机制,适用于微服务间跨信任域(如 api.example.comanalytics.internal)的细粒度授权委托。

核心交换流程

// ExchangeRequest 构建符合 RFC8693 的 token exchange 请求
req := &oauth2.TokenExchangeRequest{
    SubjectToken:     userJWT,                    // 原始用户访问令牌(sub=alice@ext)
    SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
    ActorToken:       svcCertJWT,                 // 可选:调用方服务证书 JWT(证明自身身份)
    ActorTokenType:   "urn:ietf:params:oauth:token-type:jwt",
    Resource:         "https://analytics.internal/v1/reports", // 目标资源标识
    RequestedTokenType: "urn:ietf:params:oauth:token-type:jwt",
}

该结构严格对齐 RFC8693 §2.1 字段语义;Resource 字段驱动策略引擎裁剪权限范围,ActorToken 启用双向身份链验证。

信任域映射策略

源域(subject_token_iss) 目标域(aud) 权限模板
https://auth.ext analytics.internal read:report,scope:dept-a
https://idp.corp payments.internal write:txn,scope:region-us

授权决策流程

graph TD
    A[收到 Exchange Request] --> B{验证 subject_token 签名与有效期}
    B --> C[解析 claims 并匹配 Resource 策略]
    C --> D[注入 actor 身份上下文]
    D --> E[签发受限 scope 的新 JWT]

第三章:高可用认证中台架构设计

3.1 基于Go-Kit+gRPC的分层服务架构与领域边界划分

在微服务演进中,Go-Kit 提供面向切面的中间件能力,gRPC 则保障跨语言、高性能的远程调用。二者结合可清晰分离传输层、业务逻辑层与领域模型层。

分层职责映射

  • Transport 层:gRPC Server/Client,处理协议编解码与连接管理
  • Endpoint 层:Go-Kit Endpoint,将 RPC 方法映射为 endpoint.Endpoint 函数
  • Service 层:纯接口定义 + 实现,完全脱离传输细节,承载核心领域契约

领域边界示例(用户服务)

// user/service.go
type UserService interface {
    CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error)
}

此接口即领域契约:不依赖 protobuf 生成类型,也不引入 HTTP/gRPC 概念;CreateUserRequest 是领域输入模型(非 pb.CreateUserRequest),由 Endpoint 层完成 DTO → Domain 的转换。

架构通信流(mermaid)

graph TD
    A[gRPC Client] -->|protobuf| B[gRPC Server]
    B --> C[GRPC Transport]
    C --> D[Endpoint Layer]
    D --> E[Service Layer]
    E --> F[Domain Model]
层级 技术载体 关键约束
Transport gRPC Server 仅处理序列化、拦截、超时
Endpoint Go-Kit Endpoint 无业务逻辑,专注编解码与适配
Service interface+struct 100% 领域语义,零框架依赖

3.2 多租户隔离模型:Schema级与Context级权限路由的Go运行时实现

多租户系统需在共享基础设施中保障数据与行为隔离。Go 语言通过 context.Context 与运行时类型系统,天然支持租户上下文透传与动态 Schema 路由。

Schema级隔离:动态DB连接池路由

func (r *TenantRouter) GetDB(tenantID string) (*sql.DB, error) {
    db, ok := r.cache.Load(tenantID)
    if ok {
        return db.(*sql.DB), nil
    }
    // 基于tenantID拼接schema名(如 "tenant_abc"),复用连接池配置
    cfg := r.baseConfig.Copy()
    cfg.Schema = fmt.Sprintf("tenant_%s", tenantID)
    db, err := sql.Open("pgx", cfg.String())
    r.cache.Store(tenantID, db)
    return db, err
}

逻辑分析:TenantRouter 利用 sync.Map 缓存租户专属 *sql.DB 实例;cfg.Schema 控制 PostgreSQL 的 search_path,避免跨租户表访问。参数 tenantID 必须经白名单校验,防止 SQL 注入或 schema 名污染。

Context级权限路由:中间件注入租户元数据

func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := r.Header.Get("X-Tenant-ID")
        ctx := context.WithValue(r.Context(), TenantKey, tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
隔离维度 实现机制 安全边界 性能开销
Schema级 DB层schema隔离 强(SQL引擎级) 中(连接池 per tenant)
Context级 Go runtime value传递 中(依赖代码守约) 极低
graph TD
    A[HTTP Request] --> B{X-Tenant-ID header}
    B -->|valid| C[Inject into context]
    C --> D[Handler: ctx.Value(TenantKey)]
    D --> E[Router: Select DB/schema]
    E --> F[Query Execution]

3.3 分布式Session一致性:Redis Cluster + Lua原子操作的Go SDK封装

在高并发微服务架构中,传统单点Session已无法满足可用性与扩展性要求。Redis Cluster 提供分片容错能力,但客户端直连易引发读写不一致——尤其在 SET + EXPIRE 非原子场景下。

Lua保障原子性

const sessionSetScript = `
if redis.call("EXISTS", KEYS[1]) == 1 then
  redis.call("PEXPIRE", KEYS[1], ARGV[2])
  return redis.call("HSET", KEYS[1], "data", ARGV[1])
else
  redis.call("HSET", KEYS[1], "data", ARGV[1])
  redis.call("PEXPIRE", KEYS[1], ARGV[2])
  return 1
end`

逻辑分析:脚本通过 KEYS[1](session ID)判断是否存在,统一执行哈希写入与毫秒级过期设置,避免竞态;ARGV[1] 为序列化 session 数据,ARGV[2] 为 TTL(单位 ms),确保会话生命周期精准可控。

SDK核心能力

  • 自动路由到正确 slot(基于 CRC16 key hash)
  • 失败自动重试 + 槽迁移透明感知
  • Session ID 与 Redis key 映射加盐防哈希倾斜
特性 实现方式
原子写入 Lua 脚本封装 HSET + PEXPIRE
读取一致性 EVALSHA 缓存优化 + pipeline 批量获取
过期续租 HEXISTS + PEXPIRE 条件更新

第四章:企业级工程化落地关键实践

4.1 Prometheus+OpenTelemetry双栈可观测性:OAuth2.0指标埋点与Trace透传

在 OAuth2.0 授权流程中,需同时采集服务级指标(如 token 发放延迟、失败率)与端到端调用链(如 /authorizetoken introspectuser info)。

数据同步机制

Prometheus 抓取 OpenTelemetry Collector 暴露的 /metrics(OTLP-HTTP 转 Prometheus 格式),而 Trace 通过 OTLP/gRPC 直连后端(Jaeger/Tempo)。

埋点关键代码

# OAuth2.0 Resource Server 中注入 trace 和 metrics
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from prometheus_client import Counter, Histogram

oauth2_failures = Counter(
    "oauth2_token_validation_errors_total",
    "Total number of failed token validations",
    ["error_type", "client_id"]  # 维度化区分错误来源
)
token_latency = Histogram(
    "oauth2_token_validation_seconds",
    "Token validation latency in seconds",
    buckets=[0.01, 0.05, 0.1, 0.5, 1.0]
)

# 在 validate_token() 中:
with token_latency.time():
    try:
        payload = jwt.decode(token, key, algorithms=["RS256"])
        return payload
    except InvalidSignatureError:
        oauth2_failures.labels(error_type="invalid_sig", client_id=client_id).inc()
        raise

该埋点实现将认证失败按 error_typeclient_id 双维度聚合,支持租户级 SLA 分析;Histogram 的 bucket 设置覆盖典型 JWT 验证耗时区间(密钥解析、签名验算、时间窗口校验)。

Trace 透传路径

graph TD
    A[Client] -->|Authorization: Bearer xxx| B[API Gateway]
    B -->|propagate traceparent| C[Auth Service]
    C -->|OTLP/gRPC| D[OTel Collector]
    D --> E[Tempo]
    D --> F[Prometheus]
组件 协议 用途
Auth Service OTLP/gRPC 上报 Span(含 http.status_code, oauth2.grant_type
Collector Prometheus Exporter http_server_duration_seconds 映射为 oauth2_token_validate_seconds_sum
Gateway W3C TraceContext 透传 traceparent,确保跨边界链路不中断

4.2 基于Kubernetes Operator的OAuth2.0服务声明式部署与灰度发布

Operator 将 OAuth2.0 服务(如 Hydra 或 Keycloak)的生命周期管理抽象为自定义资源(CR),实现 GitOps 风格的声明式交付。

核心能力演进

  • ✅ 自动化证书轮换(基于 cert-manager 集成)
  • ✅ 多版本并行运行(通过 spec.versionspec.strategy=Canary 控制)
  • ✅ 流量染色路由(注入 x-oauth-version: v2 header 触发灰度分流)

Canary 发布配置示例

apiVersion: auth.example.com/v1
kind: OAuth2Service
metadata:
  name: idp-prod
spec:
  version: "v2.3.0"
  strategy:
    type: Canary
    canary:
      steps:
      - setWeight: 5    # 初始5%流量
      - pause: 300      # 暂停5分钟(秒)
      - setWeight: 50

该 CR 触发 Operator 启动双 Deployment(idp-prod-v2.3.0-canaryidp-prod-stable),并通过 Istio VirtualService 动态调整目标权重。pause 字段由 Operator 转换为 Job 等待,确保人工审批或指标验证窗口。

灰度决策依据

指标 阈值 来源
5xx 错误率 Prometheus
/oauth/token P95 OpenTelemetry
graph TD
  A[CR 创建] --> B{版本变更?}
  B -->|是| C[启动 Canary Deployment]
  B -->|否| D[滚动更新 Stable]
  C --> E[注入 Istio 路由规则]
  E --> F[按 step 执行权重递增]

4.3 安全合规加固:FIPS 140-2兼容密码套件与国密SM2/SM4的Go原生集成

Go 1.22+ 原生支持 crypto/tls 的 FIPS 模式切换(需启用 GODEBUG=fips140=1),并可通过 golang.org/x/crypto/sm2golang.org/x/crypto/sm4 实现国密算法集成。

FIPS 模式 TLS 配置示例

import "crypto/tls"

config := &tls.Config{
    MinVersion: tls.VersionTLS12,
    CurvePreferences: []tls.CurveID{tls.CurveP256},
    CipherSuites: []uint16{
        tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // FIPS 140-2 approved
    },
}

启用后,Go 运行时自动禁用非FIPS算法(如 RC4、MD5、SHA1 签名);CipherSuites 显式限定 NIST SP 800-131A 合规套件,避免协商降级。

国密双模支持路径

  • ✅ SM2 公钥加密与签名(符合 GM/T 0003-2012)
  • ✅ SM4 分组加密(ECB/CBC/CTR,GM/T 0002-2012)
  • ⚠️ 注意:sm2.GenerateKey() 默认使用 P-256 曲线参数,需校验 sm2.Params().Name == "sm2p256v1"
算法 标准依据 Go 模块路径
SM2 GM/T 0003-2012 golang.org/x/crypto/sm2
SM4 GM/T 0002-2012 golang.org/x/crypto/sm4
graph TD
    A[应用层] --> B[TLS握手]
    B --> C{FIPS模式?}
    C -->|是| D[仅加载NIST/FIPS白名单套件]
    C -->|否| E[允许SM2/SM4协商扩展]
    D & E --> F[SM2密钥交换 + SM4-GCM加密]

4.4 认证即代码(Authentication-as-Code):YAML策略引擎与Go动态策略加载器

传统硬编码鉴权逻辑正被声明式策略取代。auth-policy.yaml 定义权限边界,Go运行时通过反射+FSNotify实现热重载。

策略声明示例

# auth-policy.yaml
policies:
- id: "admin-read-write"
  subjects: ["role:admin"]
  resources: ["/api/v1/**"]
  actions: ["GET", "POST", "PUT", "DELETE"]
  effect: "allow"

该YAML片段定义RBAC规则:匹配role:admin主体对/api/v1/下所有资源的全动词授权。effect字段控制默认拒绝语义。

动态加载核心逻辑

func LoadPolicyFromYAML(path string) (*PolicySet, error) {
    data, _ := os.ReadFile(path)
    var cfg PolicyConfig
    yaml.Unmarshal(data, &cfg) // 解析YAML为结构体
    return NewPolicySet(cfg.Policies), nil
}

PolicyConfig为Go结构体,NewPolicySet构建内存索引(如按subject哈希分片),支持毫秒级策略匹配。

组件 职责 热更新支持
YAML解析器 将策略转为Go结构 ✅(WatchFS触发Reload)
策略索引器 构建O(1)匹配索引 ✅(原子指针替换)
策略执行器 Evaluate(ctx, req)实时决策 ✅(无锁读取)
graph TD
    A[YAML文件变更] --> B[FSNotify事件]
    B --> C[解析新策略]
    C --> D[构建新索引]
    D --> E[原子切换policySet指针]

第五章:总结与展望

实战项目复盘:某金融风控平台的模型迭代路径

在2023年Q3上线的实时反欺诈系统中,团队将LightGBM模型替换为融合图神经网络(GNN)与时序注意力机制的Hybrid-FraudNet架构。部署后,对团伙欺诈识别的F1-score从0.82提升至0.91,误报率下降37%。关键突破在于引入动态子图采样策略——每笔交易触发后,系统在50ms内构建以目标用户为中心、半径为3跳的异构关系子图(含账户、设备、IP、地理位置四类节点),并通过PyTorch Geometric实现GPU加速推理。下表对比了三代模型在生产环境A/B测试中的核心指标:

模型版本 平均延迟(ms) 日均拦截欺诈金额(万元) 运维告警频次/日
XGBoost-v1(2021) 86 421 17
LightGBM-v2(2022) 41 689 5
Hybrid-FraudNet(2023) 53 1,246 2

工程化落地的关键瓶颈与解法

模型服务化过程中暴露三大硬性约束:① Kubernetes集群中GPU显存碎片化导致批量推理吞吐波动;② 特征在线计算依赖Flink实时作业,当Kafka Topic积压超200万条时,特征时效性衰减;③ 模型热更新需重启Pod,平均中断达4.2分钟。团队最终采用分层缓存方案:在GPU节点部署Redis Cluster缓存高频子图结构(TTL=30s),Flink侧启用背压感知限流(checkpointInterval=10s + maxParallelism=24),并基于Knative实现无中断模型灰度切换——新模型容器预热完成后,通过Istio VirtualService将1%流量切至新服务,验证通过后自动扩至100%。

flowchart LR
    A[交易请求] --> B{是否命中图缓存?}
    B -->|是| C[加载缓存子图]
    B -->|否| D[调用Neo4j实时查询]
    D --> E[构建子图并写入Redis]
    C & E --> F[Hybrid-FraudNet推理]
    F --> G[返回风险分+可解释热力图]

开源工具链的深度定制实践

原生DVC(Data Version Control)无法满足金融级数据血缘审计要求,团队向其CLI注入自定义钩子:在dvc push前强制校验SHA256哈希与监管备案ID绑定,并将元数据同步至内部区块链存证系统。同时,基于MLflow构建的模型注册中心扩展了“合规性标签”字段,支持按《金融行业人工智能算法安全规范》第5.2条自动打标(如label: “GDPR-anonymized”label: “bias-audit-passed-2023Q4”)。该改造使模型上线审批周期从平均11天压缩至3.5天。

下一代技术栈的验证路线图

当前已在测试环境验证三项前沿能力:使用NVIDIA Triton推理服务器实现多框架模型统一托管(TensorRT/ONNX/PyTorch混合部署);基于eBPF采集GPU kernel级性能数据,定位到cuBLAS矩阵乘法中23%的显存带宽浪费;在Kubernetes中部署KubeRay调度器,使GNN训练任务资源利用率提升至78%(原为41%)。这些组件已封装为Helm Chart,计划于2024年Q2完成全集群滚动升级。

十年码龄,从 C++ 到 Go,经验沉淀,娓娓道来。

发表回复

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