first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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, "通知发送失败")
|
||||
)
|
||||
@@ -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, "未知错误")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user