first commit

This commit is contained in:
2026-09-11 01:47:20 +08:00
commit 303207d3cc
74 changed files with 8348 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
package app
import (
"net/http"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// Response 统一响应包装
type Response struct {
Ctx *gin.Context
}
func NewResponse(c *gin.Context) *Response {
return &Response{Ctx: c}
}
// ToResponse 成功响应
func (r *Response) ToResponse(data interface{}) {
if data == nil {
data = gin.H{}
}
r.Ctx.JSON(http.StatusOK, gin.H{
"code": errcode.Success.Code(),
"msg": errcode.Success.Msg(),
"data": data,
})
}
// ToErrorResponse 错误响应
func (r *Response) ToErrorResponse(err *errcode.Error) {
response := gin.H{
"code": err.Code(),
"msg": err.Msg(),
}
if details := err.Details(); len(details) > 0 {
response["details"] = details
}
r.Ctx.JSON(err.StatusCode(), response)
}
+35
View File
@@ -0,0 +1,35 @@
// Package cos 提供 COS 下载 URL 生成(CDN 鉴权签名)。
package cos
import (
"crypto/md5"
"fmt"
"strconv"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
)
// GetCosUrl 将 COS 相对 key 转为带 CDN 鉴权签名的完整 URL(复刻业务服务 upload.GetCosUrl)。
//
// 签名规则:sign = md5(CdnDownloadSecretKey + "/" + fileName + ts)。
// 保护:fileName 为空、Cos 配置缺失或下载域名为空时,原样返回 fileName,避免未配置时 panic 或生成无效 URL。
func GetCosUrl(fileName string) string {
if fileName == "" {
return ""
}
if global.CosSetting == nil || global.CosSetting.CosDownloadDomain == "" {
return fileName
}
ts := time.Now().Unix()
raw := global.CosSetting.CdnDownloadSecretKey + "/" + fileName + strconv.FormatInt(ts, 10)
sign := fmt.Sprintf("%x", md5.Sum([]byte(raw)))
return fmt.Sprintf("https://%s/%s?sign=%s&t=%d",
global.CosSetting.CosDownloadDomain,
fileName,
sign,
ts,
)
}
+60
View File
@@ -0,0 +1,60 @@
package errcode
// 管理后台业务错误码段位:15000 - 21999(独立进程,与业务服务码值无冲突)
//
// 细分:
// 15000 - 15999 管理员域(登录/账号)
// 16000 - 16999 用户管理域
// 17000 - 17999 商品域
// 18000 - 18999 订单/退款域
// 20000 - 20999 内容审核域(穿搭/衣物/分享)
// 21000 - 21999 通知域
// ===== 管理员域(15000 - 15999) =====
var (
AdminLoginFailed = NewError(15000, "账号或密码错误")
AdminDisabled = NewError(15001, "管理员账号已被禁用")
AdminNotFound = NewError(15002, "管理员不存在")
AdminUsernameExists = NewError(15003, "登录账号已存在")
AdminPasswordInvalid = NewError(15004, "密码格式不正确")
AdminOldPasswordWrong = NewError(15005, "原密码错误")
AdminCannotDeleteSelf = NewError(15006, "不能删除自己")
AdminCannotDisableSelf = NewError(15007, "不能禁用自己")
AdminCannotModifySuper = NewError(15008, "不能操作超级管理员账号")
)
// ===== 用户管理域(16000 - 16999) =====
var (
UserNotFound = NewError(16000, "用户不存在")
UserQuotaNotFound = NewError(16001, "用户额度记录不存在")
UserQuotaInvalid = NewError(16002, "调整后的总配额不能小于已使用量")
UserVIPInvalid = NewError(16003, "VIP 参数不合法")
UserGrantNotFound = NewError(16004, "额度凭证不存在")
)
// ===== 商品域(17000 - 17999) =====
var (
GoodsNotFound = NewError(17000, "商品不存在")
GoodsCodeExists = NewError(17001, "商品编码已存在")
GoodsPriceInvalid = NewError(17002, "商品价格不合法")
)
// ===== 订单/退款域(18000 - 18999) =====
var (
OrderNotFound = NewError(18000, "订单不存在")
RefundNotFound = NewError(18001, "退款单不存在")
RefundStatusInvalid = NewError(18002, "当前退款状态不允许该操作")
)
// ===== 内容审核域(20000 - 20999) =====
var (
OutfitNotFound = NewError(20000, "穿搭不存在")
OutfitAuditInvalid = NewError(20001, "当前状态不允许审核")
ClothesNotFound = NewError(20002, "衣物不存在")
ShareNotFound = NewError(20003, "分享不存在")
)
// ===== 通知域(21000 - 21999) =====
var (
NotificationSendFailed = NewError(21000, "通知发送失败")
)
+43
View File
@@ -0,0 +1,43 @@
package errcode
// 公共错误码段位:10000 - 19999
//
// 细分:
// 10000 - 10999 通用 / 参数错误
// 11000 - 11999 鉴权 / Token
// 12000 - 12999 权限 / 资源
// 19000 - 19999 系统级错误(DB、缓存、RPC)
// 成功
var Success = NewError(0, "成功")
// ===== 通用错误(10000 - 10999) =====
var (
InvalidParams = NewError(10000, "请求参数错误")
NotFound = NewError(10001, "资源不存在")
MethodNotAllow = NewError(10002, "请求方法不允许")
TooManyRequests = NewError(10003, "请求过于频繁")
)
// ===== 鉴权 / Token(11000 - 11999) =====
var (
Unauthorized = NewError(11000, "未授权")
UnauthorizedTokenError = NewError(11001, "Token 鉴权失败")
UnauthorizedTokenTimeout = NewError(11002, "Token 已过期")
UnauthorizedTokenGenerate = NewError(11003, "Token 生成失败")
UnauthorizedTokenMissing = NewError(11004, "Token 缺失")
)
// ===== 权限(12000 - 12999) =====
var (
Forbidden = NewError(12000, "无权限访问")
)
// ===== 系统错误(19000 - 19999) =====
var (
ServerError = NewError(19000, "服务内部错误")
DatabaseError = NewError(19001, "数据库错误")
CacheError = NewError(19002, "缓存错误")
RPCError = NewError(19003, "下游服务调用失败")
UnknownError = NewError(19999, "未知错误")
)
+77
View File
@@ -0,0 +1,77 @@
package errcode
import (
"fmt"
"net/http"
)
// Error 业务错误
type Error struct {
code int
msg string
details []string
}
// codes 用于注册时去重检查
var codes = map[int]string{}
// NewError 注册并创建一个错误码
func NewError(code int, msg string) *Error {
if _, exists := codes[code]; exists {
panic(fmt.Sprintf("error code %d already exists, please change", code))
}
codes[code] = msg
return &Error{code: code, msg: msg}
}
func (e *Error) Error() string {
return fmt.Sprintf("error code: %d, msg: %s", e.code, e.msg)
}
func (e *Error) Code() int { return e.code }
func (e *Error) Msg() string { return e.msg }
func (e *Error) Details() []string { return e.details }
func (e *Error) Msgf(args ...interface{}) string {
return fmt.Sprintf(e.msg, args...)
}
// WithDetails 附加详细信息(返回副本)
func (e *Error) WithDetails(details ...string) *Error {
newError := *e
newError.details = append(newError.details, details...)
return &newError
}
// StatusCode 业务码 → HTTP 状态码
// 仅对少数特殊错误返回非 200,其余统一 200,由前端按业务 code 处理
func (e *Error) StatusCode() int {
switch e.code {
case Success.Code():
return http.StatusOK
// 鉴权类返回 401,便于前端拦截器统一处理跳转登录
case Unauthorized.Code(),
UnauthorizedTokenError.Code(),
UnauthorizedTokenTimeout.Code(),
UnauthorizedTokenMissing.Code():
return http.StatusUnauthorized
// 越权/无权限返回 403,便于网关与审计识别越权事件
case Forbidden.Code():
return http.StatusForbidden
// 限流返回 429,便于网关识别限流事件
case TooManyRequests.Code():
return http.StatusTooManyRequests
// 系统错误返回 500,便于运维监控告警
case ServerError.Code(),
DatabaseError.Code(),
CacheError.Code(),
RPCError.Code(),
UnknownError.Code():
return http.StatusInternalServerError
}
return http.StatusOK
}
+125
View File
@@ -0,0 +1,125 @@
package lzcwyjwt
import (
"errors"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"github.com/golang-jwt/jwt/v5"
)
type TokenType string
const (
TokenTypeAccess TokenType = "access"
TokenTypeRefresh TokenType = "refresh"
)
// Claims 管理员 JWT claims(与业务服务接口风格一致,字段替换为 AdminID)。
type Claims struct {
AdminID int64 `json:"admin_id"`
TokenType TokenType `json:"token_type"`
jwt.RegisteredClaims
}
var (
ErrTokenInvalid = errors.New("token invalid")
ErrTokenExpired = errors.New("token expired")
ErrTokenWrongType = errors.New("token type mismatch")
)
func GenerateTokens(adminID int64) (accessToken, refreshToken string, err error) {
now := time.Now()
accessClaims := Claims{
AdminID: adminID,
TokenType: TokenTypeAccess,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: global.JWTSetting.Issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second)),
NotBefore: jwt.NewNumericDate(now),
},
}
refreshClaims := Claims{
AdminID: adminID,
TokenType: TokenTypeRefresh,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: global.JWTSetting.Issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second)),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(global.JWTSetting.SecretKey))
if err != nil {
return
}
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(global.JWTSetting.SecretKey))
if err != nil {
return
}
return accessToken, refreshToken, nil
}
func ParseAccessToken(tokenString string) (*Claims, error) {
claims, err := parseToken(tokenString)
if err != nil {
return nil, err
}
// 校验 token 类型(防止 refresh token 被当 access token 用)
if claims.TokenType != TokenTypeAccess {
return nil, ErrTokenWrongType
}
return claims, nil
}
func ParseRefreshToken(tokenString string) (*Claims, error) {
claims, err := parseToken(tokenString)
if err != nil {
return nil, err
}
// 校验 token 类型(防止 access token 被当 refresh token 用)
if claims.TokenType != TokenTypeRefresh {
return nil, ErrTokenWrongType
}
return claims, nil
}
// parseToken 解析并校验 token 签名、签发方。
func parseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
// 验证签名算法
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrTokenInvalid
}
return []byte(global.JWTSetting.SecretKey), nil
})
if err != nil {
// 区分过期和其他错误
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrTokenInvalid
}
// 校验签发方,防止跨服务 token 混用。
if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
return nil, ErrTokenInvalid
}
return claims, nil
}
+97
View File
@@ -0,0 +1,97 @@
package setting
// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。
type ServerSettingS struct {
Domain string // 服务域名(用于 CORS 允许来源)
RunMode string
HttpPort string
ReadTimeout int // 秒
WriteTimeout int // 秒
}
type LogSettingS struct {
LogSavePath string
LogFileName string
LogFileExt string
MaxSize int
MaxBackups int
MaxAge int
LogLevel string
}
type MysqlSettingS struct {
UserName string
Password string
Host string
Port int
DBName string
MaxIdleConns int
MaxOpenConns int
ConnMaxLifeTime int
ConnMaxIdleTime int
}
type RedisSettingS struct {
Host string
Port int
Password string
DB int
PoolSize int
MaxIdleConns int
MinIdleConns int
MaxRetryBackoff int
MinRetryBackoff int
MaxRetries int
DialTimeout int
ReadTimeout int
WriteTimeout int
PoolTimeout int
}
type OtelSettingS struct {
Enabled bool
ServiceName string
ServiceVersion string
Environment string
ExporterEndpoint string
Insecure bool
SamplerRatio float64
}
type JWTSettingS struct {
SecretKey string
Issuer string
AccessTokenExpire int // 秒
RefreshTokenExpire int // 秒
}
// AdminSettingS 管理后台专属配置。
type AdminSettingS struct {
InitialUsername string // 初始超级管理员账号(服务启动时幂等创建)
InitialPassword string // 初始超级管理员密码(明文仅存配置,入库为 bcrypt 哈希)
}
// QuotaSettingS 免费额度底值配置(用于管理后台计算并展示「总额度」)。
// 必须与业务服务(zt-closet-backend)的 UserDefaultQuota 免费值保持一致;
// 使用指针类型以区分「未配置」与「显式配置为 0」,未配置或负数回落内置默认 20/3/10/5。
type QuotaSettingS struct {
Cos *int64 // 免费上传额度(永久,不每天重置)
AI *int64 // 免费 AI 推荐额度(每日,每天 0 点重置)
OutfitShare *int64 // 免费社区分享额度(每日)
ClosetShare *int64 // 免费衣柜分享额度(每日)
}
// CosSettingS CDN 下载配置(仅用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL)。
// 管理后台与业务服务共用同一 COS,此处的域名与鉴权密钥须与业务服务 configs/config.yaml 的 Cos 段一致。
type CosSettingS struct {
CosDownloadDomain string // CDN 下载域名
CdnDownloadSecretKey string // CDN 下载鉴权密钥(机密,仅存 config.yaml)
}
func (s *Setting) ReadSection(k string, v any) error {
err := s.vp.UnmarshalKey(k, v)
if err != nil {
return err
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package setting
import (
"github.com/spf13/viper"
)
// Setting 封装 viper 配置读取能力。
type Setting struct {
vp *viper.Viper
}
// NewSetting 初始化配置读取器,仅从 configs/config.yaml 读取配置。
// 所有配置项一律在配置文件中直接填写,不支持环境变量覆盖。
func NewSetting() (*Setting, error) {
vp := viper.New()
vp.AddConfigPath("configs/")
vp.SetConfigName("config")
vp.SetConfigType("yaml")
if err := vp.ReadInConfig(); err != nil {
return nil, err
}
return &Setting{vp: vp}, nil
}
+68
View File
@@ -0,0 +1,68 @@
package tracing
import (
"context"
"errors"
"log/slog"
)
// FanoutHandler 是一个将日志记录扇出到多个 slog.Handler 的组合处理器,
// 用于同时输出本地 JSON 日志与 OTel 日志桥接。
type FanoutHandler struct {
handlers []slog.Handler
}
// NewFanoutHandler 创建扇出处理器(忽略 nil handler)。
func NewFanoutHandler(handlers ...slog.Handler) *FanoutHandler {
filtered := make([]slog.Handler, 0, len(handlers))
for _, h := range handlers {
if h != nil {
filtered = append(filtered, h)
}
}
return &FanoutHandler{handlers: filtered}
}
// Enabled 任一子处理器启用即返回 true。
func (f *FanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range f.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
// Handle 将记录分发给每个启用的子处理器(各自使用副本,避免相互污染)。
func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range f.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
// WithAttrs 为每个子处理器附加属性。
func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
next := make([]slog.Handler, len(f.handlers))
for i, h := range f.handlers {
next[i] = h.WithAttrs(attrs)
}
return &FanoutHandler{handlers: next}
}
// WithGroup 为每个子处理器开启分组。
func (f *FanoutHandler) WithGroup(name string) slog.Handler {
if name == "" {
return f
}
next := make([]slog.Handler, len(f.handlers))
for i, h := range f.handlers {
next[i] = h.WithGroup(name)
}
return &FanoutHandler{handlers: next}
}
+35
View File
@@ -0,0 +1,35 @@
package tracing
import (
"context"
"log/slog"
"go.opentelemetry.io/otel/trace"
)
type OtelContextHandler struct {
Handler slog.Handler
}
func (h OtelContextHandler) Handle(ctx context.Context, r slog.Record) error {
if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
sc := span.SpanContext()
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
func (h OtelContextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.Handler.Enabled(ctx, level)
}
func (h OtelContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return OtelContextHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h OtelContextHandler) WithGroup(name string) slog.Handler {
return OtelContextHandler{Handler: h.Handler.WithGroup(name)}
}
+154
View File
@@ -0,0 +1,154 @@
package tracing
import (
"context"
"errors"
"fmt"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
"go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
logglobal "go.opentelemetry.io/otel/log/global"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
// defaultServiceName 缺省服务名。
const defaultServiceName = "zt-closet-manage-backend"
// noopShutdown 安全的空关闭函数。
func noopShutdown(context.Context) error { return nil }
// NewOTelProvider 初始化 OpenTelemetry 三大信号(Traces/Metrics/Logs)并通过 OTLP gRPC 导出。
//
// 返回:
// - trace.Tracer:全局 Tracer(供 global.Tracer 使用);
// - func(ctx) error:聚合的优雅关闭函数,进程退出时调用以 flush 三个 Provider;
// - error:初始化错误。
//
// 行为约定:
// - cfg.Enabled=false:返回 no-op Tracer 与空 Shutdown,不建立任何导出器(业务层 Tracer.Start 仍安全)。
// - cfg.Insecure=true:OTLP gRPC 使用非 TLS(内网采集器)。
// - 导出器采用惰性连接,采集器不可用时不阻断启动,仅后台重试。
func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error) {
name := defaultServiceName
if cfg != nil && cfg.ServiceName != "" {
name = cfg.ServiceName
}
// 未启用:降级为 no-op。
if cfg == nil || !cfg.Enabled {
return noop.NewTracerProvider().Tracer(name), noopShutdown, nil
}
ctx := context.Background()
res, err := resource.New(ctx, resource.WithAttributes(
attribute.String("service.name", name),
attribute.String("service.version", valueOr(cfg.ServiceVersion, "unknown")),
attribute.String("deployment.environment", valueOr(cfg.Environment, "unknown")),
))
if err != nil {
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel build resource: %w", err)
}
endpoint := cfg.ExporterEndpoint
// ===== Traces =====
traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(endpoint)}
if cfg.Insecure {
traceOpts = append(traceOpts, otlptracegrpc.WithInsecure())
}
traceExp, err := otlptracegrpc.New(ctx, traceOpts...)
if err != nil {
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel trace exporter: %w", err)
}
ratio := cfg.SamplerRatio
if ratio <= 0 {
ratio = 1.0
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
// ===== Metrics =====
metricOpts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(endpoint)}
if cfg.Insecure {
metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure())
}
metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...)
if err != nil {
// 已建 trace,尽力关闭并恢复全局默认 provider,避免留下已关闭的 provider。
_ = tp.Shutdown(ctx)
otel.SetTracerProvider(noop.NewTracerProvider())
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel metric exporter: %w", err)
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
)
otel.SetMeterProvider(mp)
// Go runtime 指标采集(GC、goroutine、内存等)。
if rerr := runtime.Start(runtime.WithMeterProvider(mp)); rerr != nil {
// 非致命,仅记录。
fmt.Printf("otel runtime metrics start failed: %v\n", rerr)
}
// ===== Logs =====
logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(endpoint)}
if cfg.Insecure {
logOpts = append(logOpts, otlploggrpc.WithInsecure())
}
logExp, err := otlploggrpc.New(ctx, logOpts...)
if err != nil {
_ = tp.Shutdown(ctx)
_ = mp.Shutdown(ctx)
otel.SetTracerProvider(noop.NewTracerProvider())
otel.SetMeterProvider(metricnoop.NewMeterProvider())
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel log exporter: %w", err)
}
lp := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)),
)
logglobal.SetLoggerProvider(lp)
// 聚合优雅关闭:依次 flush/关闭三个 Provider。
shutdown := func(ctx context.Context) error {
return errors.Join(
tp.Shutdown(ctx),
mp.Shutdown(ctx),
lp.Shutdown(ctx),
)
}
return tp.Tracer(name), shutdown, nil
}
// valueOr 返回 v,v 为空时返回 def。
func valueOr(v, def string) string {
if v == "" {
return def
}
return v
}