重构日志与可观测性体系
新增单行文本编码器与结构化 GORM 日志,统一错误记录与请求日志策略,收紧日志文件权限并修复按天切分与压缩,支付回调参数脱敏,生产强制阿里云短信,RequestID 校验防注入,日志文案中文化。
This commit is contained in:
@@ -52,8 +52,8 @@ npm run dev
|
|||||||
|
|
||||||
## 开发态短信与实名
|
## 开发态短信与实名
|
||||||
|
|
||||||
- 后端日志默认同时输出到控制台和 `backend/logs/app-YYYY-MM-DD.log`,按北京时间自动按天切分;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
|
- 后端日志使用单行可读文本。开发环境默认只输出控制台,生产示例只输出 `backend/logs/app-YYYY-MM-DD.log`,避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别。
|
||||||
- 短信验证码默认使用 mock 适配器,验证码会打印在后端日志中;生产环境可通过 `SMS_PROVIDER=aliyun` 接入阿里云短信。
|
- 短信验证码默认使用 mock 适配器;需要查看调试验证码时将 `LOG_LEVEL` 临时设为 `debug`。生产环境强制使用 `SMS_PROVIDER=aliyun`。
|
||||||
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。
|
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。
|
||||||
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
||||||
- 实名状态可通过 `GET /api/realname/status` 查询。
|
- 实名状态可通过 `GET /api/realname/status` 查询。
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_DIR=logs
|
LOG_DIR=logs
|
||||||
LOG_ENABLE_CONSOLE=true
|
LOG_ENABLE_CONSOLE=true
|
||||||
LOG_ENABLE_FILE=true
|
LOG_ENABLE_FILE=false
|
||||||
|
LOG_RETAIN_DAYS=14
|
||||||
|
|
||||||
# MinIO 容器初始化变量,同时供后端对象存储使用。
|
# MinIO 容器初始化变量,同时供后端对象存储使用。
|
||||||
MINIO_ROOT_USER=minioadmin
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ RATE_LIMIT_REQUESTS_PER_MINUTE=300
|
|||||||
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_DIR=/app/logs
|
LOG_DIR=/app/logs
|
||||||
LOG_ENABLE_CONSOLE=true
|
LOG_ENABLE_CONSOLE=false
|
||||||
LOG_ENABLE_FILE=true
|
LOG_ENABLE_FILE=true
|
||||||
|
LOG_RETAIN_DAYS=14
|
||||||
|
|
||||||
# MinIO 容器初始化变量,同时供后端对象存储使用;使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
|
# MinIO 容器初始化变量,同时供后端对象存储使用;使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
|
||||||
MINIO_ROOT_USER=change-minio-user
|
MINIO_ROOT_USER=change-minio-user
|
||||||
|
|||||||
+12
-12
@@ -53,14 +53,14 @@ func main() {
|
|||||||
_ = logger.Sync()
|
_ = logger.Sync()
|
||||||
}()
|
}()
|
||||||
if err := cfg.ValidateProductionSecurity(); err != nil {
|
if err := cfg.ValidateProductionSecurity(); err != nil {
|
||||||
logger.Fatal("production security config invalid", zap.Error(err))
|
logger.Fatal("生产安全配置校验失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
logAuthRuntimeIdentity(logger, cfg)
|
logAuthRuntimeIdentity(logger, cfg)
|
||||||
|
|
||||||
var deps router.Dependencies
|
var deps router.Dependencies
|
||||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level)
|
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
|
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
deps.DB = db
|
deps.DB = db
|
||||||
if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{
|
if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{
|
||||||
@@ -69,7 +69,7 @@ func main() {
|
|||||||
Password: cfg.BootstrapAdminPassword,
|
Password: cfg.BootstrapAdminPassword,
|
||||||
Nickname: cfg.BootstrapAdminNickname,
|
Nickname: cfg.BootstrapAdminNickname,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.Fatal("admin bootstrap failed", zap.Error(err))
|
logger.Fatal("初始管理员创建失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
|
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
|
||||||
@@ -78,7 +78,7 @@ func main() {
|
|||||||
DB: cfg.RedisDB,
|
DB: cfg.RedisDB,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("redis unavailable; redis-backed APIs will return 503", zap.Error(err))
|
logger.Warn("Redis 不可用,相关接口将返回 503", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
deps.Redis = redisClient
|
deps.Redis = redisClient
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -103,9 +103,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("api server starting", zap.String("addr", cfg.AppAddr))
|
logger.Info("API 服务启动", zap.String("addr", cfg.AppAddr))
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal("api server failed", zap.Error(err))
|
logger.Fatal("API 服务异常退出", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -117,9 +117,9 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
logger.Fatal("api server shutdown failed", zap.Error(err))
|
logger.Fatal("API 服务关闭失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
logger.Info("api server stopped")
|
logger.Info("API 服务已停止")
|
||||||
}
|
}
|
||||||
|
|
||||||
// logAuthRuntimeIdentity 记录可用于排查多实例或部署配置漂移的非敏感身份信息。
|
// logAuthRuntimeIdentity 记录可用于排查多实例或部署配置漂移的非敏感身份信息。
|
||||||
@@ -129,7 +129,7 @@ func logAuthRuntimeIdentity(logger *zap.Logger, cfg config.Config) {
|
|||||||
instanceID = "unknown"
|
instanceID = "unknown"
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256([]byte(cfg.JWTSecret))
|
sum := sha256.Sum256([]byte(cfg.JWTSecret))
|
||||||
logger.Info("auth runtime identity",
|
logger.Debug("认证运行实例",
|
||||||
zap.String("instance_id", instanceID),
|
zap.String("instance_id", instanceID),
|
||||||
zap.String("app_env", cfg.AppEnv),
|
zap.String("app_env", cfg.AppEnv),
|
||||||
zap.String("jwt_secret_fingerprint", hex.EncodeToString(sum[:])[:12]),
|
zap.String("jwt_secret_fingerprint", hex.EncodeToString(sum[:])[:12]),
|
||||||
@@ -146,10 +146,10 @@ func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *z
|
|||||||
}
|
}
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
if config.IsProductionEnv(cfg.AppEnv) {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
logger.Fatal("支付配置加密密钥未设置或无效")
|
||||||
}
|
}
|
||||||
encryptor = &paymentconfig.MockEncryptor{}
|
encryptor = &paymentconfig.MockEncryptor{}
|
||||||
logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using MockEncryptor for jobs")
|
logger.Debug("定时任务使用模拟支付配置加密器")
|
||||||
}
|
}
|
||||||
return paymentconfig.NewRepository(db, encryptor)
|
return paymentconfig.NewRepository(db, encryptor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func Load() Config {
|
|||||||
Level: getEnv("LOG_LEVEL", "info"),
|
Level: getEnv("LOG_LEVEL", "info"),
|
||||||
Dir: getEnv("LOG_DIR", "logs"),
|
Dir: getEnv("LOG_DIR", "logs"),
|
||||||
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
|
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
|
||||||
EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
|
EnableFile: getEnvBool("LOG_ENABLE_FILE", false),
|
||||||
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
|
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
|
||||||
},
|
},
|
||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
@@ -123,6 +123,13 @@ func (c Config) ValidateProductionSecurity() error {
|
|||||||
if !IsProductionEnv(c.AppEnv) {
|
if !IsProductionEnv(c.AppEnv) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(c.SMS.Provider), "aliyun") {
|
||||||
|
return errors.New("SMS_PROVIDER must be aliyun in production")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.SMS.AliyunAccessKeyID) == "" || strings.TrimSpace(c.SMS.AliyunAccessKeySecret) == "" ||
|
||||||
|
strings.TrimSpace(c.SMS.AliyunSignName) == "" || strings.TrimSpace(c.SMS.AliyunLoginTemplateCode) == "" {
|
||||||
|
return errors.New("aliyun SMS credentials, sign name, and template code are required in production")
|
||||||
|
}
|
||||||
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
|
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
|
||||||
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
|
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
appLogging "hfb_sys/backend/internal/logging"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
gormLogger "gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type structuredGormLogger struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
level gormLogger.LogLevel
|
||||||
|
slowThreshold time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
||||||
|
level := gormLogger.Warn
|
||||||
|
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
|
||||||
|
level = gormLogger.Info
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.L()
|
||||||
|
}
|
||||||
|
return &structuredGormLogger{
|
||||||
|
logger: logger.With(zap.String("module", "database")),
|
||||||
|
level: level,
|
||||||
|
slowThreshold: 500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface {
|
||||||
|
cloned := *l
|
||||||
|
cloned.level = level
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) Info(ctx context.Context, message string, args ...any) {
|
||||||
|
if l.level >= gormLogger.Info {
|
||||||
|
l.withContext(ctx).Debug("数据库信息", zap.String("detail", fmt.Sprintf(message, args...)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) Warn(ctx context.Context, message string, args ...any) {
|
||||||
|
if l.level >= gormLogger.Warn {
|
||||||
|
l.withContext(ctx).Warn("数据库警告", zap.String("detail", fmt.Sprintf(message, args...)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) Error(ctx context.Context, message string, args ...any) {
|
||||||
|
if l.level >= gormLogger.Error {
|
||||||
|
l.withContext(ctx).Error("数据库错误", zap.String("detail", fmt.Sprintf(message, args...)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), err error) {
|
||||||
|
if l.level == gormLogger.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := time.Since(begin)
|
||||||
|
switch {
|
||||||
|
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormLogger.Error:
|
||||||
|
sql, rows := query()
|
||||||
|
l.withContext(ctx).Error("数据库查询失败", queryFields(sql, rows, elapsed, err)...)
|
||||||
|
case elapsed >= l.slowThreshold && l.level >= gormLogger.Warn:
|
||||||
|
sql, rows := query()
|
||||||
|
l.withContext(ctx).Warn("数据库慢查询", queryFields(sql, rows, elapsed, nil)...)
|
||||||
|
case l.level == gormLogger.Info:
|
||||||
|
sql, rows := query()
|
||||||
|
l.withContext(ctx).Debug("数据库查询", queryFields(sql, rows, elapsed, nil)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamsFilter 让 GORM 保留 SQL 占位符,避免查询参数进入日志。
|
||||||
|
func (l *structuredGormLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) {
|
||||||
|
return sql, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *structuredGormLogger) withContext(ctx context.Context) *zap.Logger {
|
||||||
|
fields := make([]zap.Field, 0, 2)
|
||||||
|
if requestID := appLogging.RequestIDFromContext(ctx); requestID != "" {
|
||||||
|
fields = append(fields, zap.String("request_id", requestID))
|
||||||
|
}
|
||||||
|
if adminID := appLogging.AdminIDFromContext(ctx); adminID != 0 {
|
||||||
|
fields = append(fields, zap.Uint64("admin_id", adminID))
|
||||||
|
}
|
||||||
|
return l.logger.With(fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryFields(sql string, rows int64, elapsed time.Duration, err error) []zap.Field {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Float64("duration_ms", float64(elapsed.Microseconds())/1000),
|
||||||
|
zap.Int64("rows", rows),
|
||||||
|
zap.String("sql", sql),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fields = append(fields, zap.Error(err))
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGormLoggerDoesNotLogQueryParameters(t *testing.T) {
|
||||||
|
core, observed := observer.New(zap.DebugLevel)
|
||||||
|
logger := zap.New(core)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: newGormLogger("debug", logger),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type secretRecord struct {
|
||||||
|
ID uint64
|
||||||
|
Phone string
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&secretRecord{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const secretPhone = "13812345678"
|
||||||
|
if err := db.Create(&secretRecord{Phone: secretPhone}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range observed.All() {
|
||||||
|
for _, value := range entry.ContextMap() {
|
||||||
|
if strings.Contains(valueString(value), secretPhone) {
|
||||||
|
t.Fatalf("query parameter leaked into log: %v", entry.ContextMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueString(value any) string {
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func OpenMySQL(dsn string, logLevel string) (*gorm.DB, error) {
|
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: newGormLogger(logLevel),
|
Logger: newGormLogger(logLevel, appLogger),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -31,19 +28,3 @@ func OpenMySQL(dsn string, logLevel string) (*gorm.DB, error) {
|
|||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGormLogger(logLevel string) logger.Interface {
|
|
||||||
level := logger.Warn
|
|
||||||
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
|
|
||||||
level = logger.Info
|
|
||||||
}
|
|
||||||
return logger.New(
|
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
|
||||||
logger.Config{
|
|
||||||
SlowThreshold: 500 * time.Millisecond,
|
|
||||||
LogLevel: level,
|
|
||||||
IgnoreRecordNotFoundError: true,
|
|
||||||
Colorful: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code s
|
|||||||
|
|
||||||
resp, err := p.client.SendSmsWithContext(ctx, req, &dara.RuntimeOptions{})
|
resp, err := p.client.SendSmsWithContext(ctx, req, &dara.RuntimeOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if p.log != nil {
|
|
||||||
p.log.Warn("aliyun sms send failed", zap.String("phone", maskPhone(phone)), zap.Error(err))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("aliyun sms send failed: %w", err)
|
return fmt.Errorf("aliyun sms send failed: %w", err)
|
||||||
}
|
}
|
||||||
if resp == nil || resp.Body == nil {
|
if resp == nil || resp.Body == nil {
|
||||||
@@ -82,15 +79,6 @@ func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code s
|
|||||||
resultCode := dara.StringValue(resp.Body.Code)
|
resultCode := dara.StringValue(resp.Body.Code)
|
||||||
if resultCode != "OK" {
|
if resultCode != "OK" {
|
||||||
resultMessage := dara.StringValue(resp.Body.Message)
|
resultMessage := dara.StringValue(resp.Body.Message)
|
||||||
if p.log != nil {
|
|
||||||
p.log.Warn(
|
|
||||||
"aliyun sms rejected",
|
|
||||||
zap.String("phone", maskPhone(phone)),
|
|
||||||
zap.String("request_id", dara.StringValue(resp.Body.RequestId)),
|
|
||||||
zap.String("code", resultCode),
|
|
||||||
zap.String("message", resultMessage),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
providerErr := &ProviderError{Code: resultCode, Message: resultMessage}
|
providerErr := &ProviderError{Code: resultCode, Message: resultMessage}
|
||||||
if resultCode == "isv.BUSINESS_LIMIT_CONTROL" {
|
if resultCode == "isv.BUSINESS_LIMIT_CONTROL" {
|
||||||
providerErr.Err = ErrProviderRateLimited
|
providerErr.Err = ErrProviderRateLimited
|
||||||
@@ -99,11 +87,10 @@ func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p.log != nil {
|
if p.log != nil {
|
||||||
p.log.Info(
|
p.log.Debug(
|
||||||
"aliyun sms sent",
|
"短信发送成功",
|
||||||
zap.String("phone", maskPhone(phone)),
|
zap.String("phone", maskPhone(phone)),
|
||||||
zap.String("request_id", dara.StringValue(resp.Body.RequestId)),
|
zap.String("provider_request_id", dara.StringValue(resp.Body.RequestId)),
|
||||||
zap.String("biz_id", dara.StringValue(resp.Body.BizId)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (p *MockProvider) SendLoginCode(ctx context.Context, phone string, code str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if p.log != nil {
|
if p.log != nil {
|
||||||
p.log.Info("mock sms code generated", zap.String("phone", phone), zap.String("code", code))
|
p.log.Debug("调试短信验证码", zap.String("phone", maskPhone(phone)), zap.String("code", code))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
|||||||
}
|
}
|
||||||
ok, err := j.redis.SetNX(ctx, orderTimeoutLockKey, j.instanceID, j.lockTTL()).Result()
|
ok, err := j.redis.SetNX(ctx, orderTimeoutLockKey, j.instanceID, j.lockTTL()).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("order timeout job acquire lock failed, run without lock", zap.Error(err))
|
j.logger.Warn("订单超时任务获取锁失败,将以单实例模式执行", zap.Error(err))
|
||||||
return func() {}, true
|
return func() {}, true
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -101,7 +101,7 @@ func (j *Job) releaseLock() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
||||||
if err := script.Run(relCtx, j.redis, []string{orderTimeoutLockKey}, j.instanceID).Err(); err != nil {
|
if err := script.Run(relCtx, j.redis, []string{orderTimeoutLockKey}, j.instanceID).Err(); err != nil {
|
||||||
j.logger.Warn("order timeout job release lock failed", zap.Error(err))
|
j.logger.Warn("订单超时任务释放锁失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func (j *Job) loop(ctx context.Context) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
j.logger.Info("order timeout job stopped")
|
j.logger.Debug("订单超时任务已停止")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
j.run(ctx)
|
j.run(ctx)
|
||||||
@@ -148,7 +148,7 @@ func (j *Job) run(ctx context.Context) {
|
|||||||
|
|
||||||
cfg, err := j.loadThresholds(ctx)
|
cfg, err := j.loadThresholds(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("order timeout job config load failed", zap.Error(err))
|
j.logger.Warn("订单超时任务加载配置失败", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -164,13 +164,13 @@ func (j *Job) run(ctx context.Context) {
|
|||||||
for _, handler := range handlers {
|
for _, handler := range handlers {
|
||||||
count, err := handler(ctx, now, cfg)
|
count, err := handler(ctx, now, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("order timeout handler failed", zap.Error(err))
|
j.logger.Warn("订单超时处理失败", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total += count
|
total += count
|
||||||
}
|
}
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
j.logger.Info("order timeout job processed orders", zap.Int("count", total))
|
j.logger.Info("订单超时处理完成", zap.Int("count", total))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
|
|||||||
}
|
}
|
||||||
ok, err := j.redis.SetNX(ctx, refundRetryLockKey, j.instanceID, j.lockTTL()).Result()
|
ok, err := j.redis.SetNX(ctx, refundRetryLockKey, j.instanceID, j.lockTTL()).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("refund retry job acquire lock failed, run without lock", zap.Error(err))
|
j.logger.Warn("退款重试任务获取锁失败,将以单实例模式执行", zap.Error(err))
|
||||||
return func() {}, true
|
return func() {}, true
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -69,7 +69,7 @@ func (j *Job) releaseLock() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
|
||||||
if err := script.Run(relCtx, j.redis, []string{refundRetryLockKey}, j.instanceID).Err(); err != nil {
|
if err := script.Run(relCtx, j.redis, []string{refundRetryLockKey}, j.instanceID).Err(); err != nil {
|
||||||
j.logger.Warn("refund retry job release lock failed", zap.Error(err))
|
j.logger.Warn("退款重试任务释放锁失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ func (j *Job) loop(ctx context.Context) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
j.logger.Info("refund retry job stopped")
|
j.logger.Debug("退款重试任务已停止")
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
j.run(ctx)
|
j.run(ctx)
|
||||||
@@ -117,14 +117,14 @@ func (j *Job) run(ctx context.Context) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
processed, err := j.syncRefundPayments(ctx, now)
|
processed, err := j.syncRefundPayments(ctx, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("refund retry job sync payments failed", zap.Error(err))
|
j.logger.Warn("退款重试任务同步失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
missing, rebuilt, err := j.warnMissingRefundOrders(ctx, now)
|
missing, rebuilt, err := j.warnMissingRefundOrders(ctx, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("refund retry job scan missing refund orders failed", zap.Error(err))
|
j.logger.Warn("退款重试任务扫描缺失退款单失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
if processed > 0 || missing > 0 {
|
if processed > 0 || missing > 0 {
|
||||||
j.logger.Info("refund retry job finished",
|
j.logger.Info("退款重试任务处理完成",
|
||||||
zap.Int("processed", processed),
|
zap.Int("processed", processed),
|
||||||
zap.Int("missing_refund_orders", missing),
|
zap.Int("missing_refund_orders", missing),
|
||||||
zap.Int("rebuilt_arbitration_refunds", rebuilt),
|
zap.Int("rebuilt_arbitration_refunds", rebuilt),
|
||||||
@@ -147,13 +147,13 @@ func (j *Job) syncRefundPayments(ctx context.Context, now time.Time) (int, error
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if _, err := j.payments.SyncRefundStatusByPaymentID(ctx, row.ID); err != nil {
|
if _, err := j.payments.SyncRefundStatusByPaymentID(ctx, row.ID); err != nil {
|
||||||
if markErr := j.markRetryFailed(ctx, row, now); markErr != nil {
|
if markErr := j.markRetryFailed(ctx, row, now); markErr != nil {
|
||||||
j.logger.Warn("refund retry mark failure failed",
|
j.logger.Warn("退款重试失败状态保存失败",
|
||||||
zap.Uint64("payment_id", row.ID),
|
zap.Uint64("payment_id", row.ID),
|
||||||
zap.Uint64("order_id", row.OrderID),
|
zap.Uint64("order_id", row.OrderID),
|
||||||
zap.Error(markErr),
|
zap.Error(markErr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
j.logger.Warn("refund retry sync failed",
|
j.logger.Warn("退款状态同步失败",
|
||||||
zap.Uint64("payment_id", row.ID),
|
zap.Uint64("payment_id", row.ID),
|
||||||
zap.Uint64("order_id", row.OrderID),
|
zap.Uint64("order_id", row.OrderID),
|
||||||
zap.String("biz_type", row.BizType),
|
zap.String("biz_type", row.BizType),
|
||||||
@@ -164,7 +164,7 @@ func (j *Job) syncRefundPayments(ctx context.Context, now time.Time) (int, error
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := j.resetRetry(ctx, row.ID); err != nil {
|
if err := j.resetRetry(ctx, row.ID); err != nil {
|
||||||
j.logger.Warn("refund retry reset counter failed",
|
j.logger.Warn("退款重试计数重置失败",
|
||||||
zap.Uint64("payment_id", row.ID),
|
zap.Uint64("payment_id", row.ID),
|
||||||
zap.Uint64("order_id", row.OrderID),
|
zap.Uint64("order_id", row.OrderID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -222,7 +222,7 @@ func (j *Job) warnMaxRetryRefunds(ctx context.Context, now time.Time) {
|
|||||||
Limit(50).
|
Limit(50).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Warn("refund retry max count scan failed", zap.Error(err))
|
j.logger.Warn("退款最大重试次数扫描失败", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
@@ -235,7 +235,7 @@ func (j *Job) warnMaxRetryRefunds(ctx context.Context, now time.Time) {
|
|||||||
ids = append(ids, row.ID)
|
ids = append(ids, row.ID)
|
||||||
orderIDs = append(orderIDs, row.OrderID)
|
orderIDs = append(orderIDs, row.OrderID)
|
||||||
}
|
}
|
||||||
j.logger.Warn("refund retry reached max count, need manual check",
|
j.logger.Warn("退款已达最大重试次数,需要人工处理",
|
||||||
zap.Int("count", len(rows)),
|
zap.Int("count", len(rows)),
|
||||||
zap.Uint64s("payment_ids", ids),
|
zap.Uint64s("payment_ids", ids),
|
||||||
zap.Uint64s("order_ids", orderIDs),
|
zap.Uint64s("order_ids", orderIDs),
|
||||||
@@ -243,7 +243,7 @@ func (j *Job) warnMaxRetryRefunds(ctx context.Context, now time.Time) {
|
|||||||
if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}).
|
if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}).
|
||||||
Where("id IN ?", ids).
|
Where("id IN ?", ids).
|
||||||
Update("last_retry_at", now).Error; err != nil {
|
Update("last_retry_at", now).Error; err != nil {
|
||||||
j.logger.Warn("refund retry bump last_retry_at failed", zap.Error(err))
|
j.logger.Warn("退款重试时间更新失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int,
|
|||||||
rebuildFailOrderIDs = append(rebuildFailOrderIDs, row.ID)
|
rebuildFailOrderIDs = append(rebuildFailOrderIDs, row.ID)
|
||||||
} else {
|
} else {
|
||||||
rebuilt++
|
rebuilt++
|
||||||
j.logger.Info("auto rebuild arbitration refund submitted",
|
j.logger.Info("仲裁退款单已自动补建",
|
||||||
zap.Uint64("order_id", row.ID),
|
zap.Uint64("order_id", row.ID),
|
||||||
zap.Int64("refund_amount_cent", row.RefundAmountCent),
|
zap.Int64("refund_amount_cent", row.RefundAmountCent),
|
||||||
)
|
)
|
||||||
@@ -295,13 +295,13 @@ func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int,
|
|||||||
}
|
}
|
||||||
// 汇总告警 + Redis 24h 去重,避免每轮扫描刷屏
|
// 汇总告警 + Redis 24h 去重,避免每轮扫描刷屏
|
||||||
if len(missingOrderIDs) > 0 && j.shouldWarn(ctx, "missing_payment", 0) {
|
if len(missingOrderIDs) > 0 && j.shouldWarn(ctx, "missing_payment", 0) {
|
||||||
j.logger.Warn("refund order missing payment record, need manual check",
|
j.logger.Warn("退款订单缺少支付记录,需要人工处理",
|
||||||
zap.Int("count", len(missingOrderIDs)),
|
zap.Int("count", len(missingOrderIDs)),
|
||||||
zap.Uint64s("order_ids", missingOrderIDs),
|
zap.Uint64s("order_ids", missingOrderIDs),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(rebuildFailOrderIDs) > 0 && j.shouldWarn(ctx, "rebuild_fail", 0) {
|
if len(rebuildFailOrderIDs) > 0 && j.shouldWarn(ctx, "rebuild_fail", 0) {
|
||||||
j.logger.Warn("auto rebuild arbitration refund failed, need manual check",
|
j.logger.Warn("仲裁退款单自动补建失败,需要人工处理",
|
||||||
zap.Int("count", len(rebuildFailOrderIDs)),
|
zap.Int("count", len(rebuildFailOrderIDs)),
|
||||||
zap.Uint64s("order_ids", rebuildFailOrderIDs),
|
zap.Uint64s("order_ids", rebuildFailOrderIDs),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package logging
|
package logging
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
type requestIDContextKey struct{}
|
type requestIDContextKey struct{}
|
||||||
type adminIDContextKey struct{}
|
type adminIDContextKey struct{}
|
||||||
@@ -41,3 +45,19 @@ func AdminIDFromContext(ctx context.Context) uint64 {
|
|||||||
adminID, _ := ctx.Value(adminIDContextKey{}).(uint64)
|
adminID, _ := ctx.Value(adminIDContextKey{}).(uint64)
|
||||||
return adminID
|
return adminID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FromContext 返回自动携带请求和管理员上下文的日志器。
|
||||||
|
func FromContext(ctx context.Context) *zap.Logger {
|
||||||
|
logger := zap.L()
|
||||||
|
fields := make([]zap.Field, 0, 2)
|
||||||
|
if requestID := RequestIDFromContext(ctx); requestID != "" {
|
||||||
|
fields = append(fields, zap.String("request_id", requestID))
|
||||||
|
}
|
||||||
|
if adminID := AdminIDFromContext(ctx); adminID != 0 {
|
||||||
|
fields = append(fields, zap.Uint64("admin_id", adminID))
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
return logger.With(fields...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package logging
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,6 +22,12 @@ type dailyWriter struct {
|
|||||||
file *os.File
|
file *os.File
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *dailyWriter) Open() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.rotateIfNeeded(time.Now().In(w.location))
|
||||||
|
}
|
||||||
|
|
||||||
func newDailyWriter(dir string, prefix string, location *time.Location, retainDays int) *dailyWriter {
|
func newDailyWriter(dir string, prefix string, location *time.Location, retainDays int) *dailyWriter {
|
||||||
if retainDays < 0 {
|
if retainDays < 0 {
|
||||||
retainDays = 0
|
retainDays = 0
|
||||||
@@ -59,51 +66,79 @@ func (w *dailyWriter) rotateIfNeeded(now time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(w.dir, 0o755); err != nil {
|
if err := os.MkdirAll(w.dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(w.dir, 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
prevDay := w.day
|
|
||||||
if w.file != nil {
|
if w.file != nil {
|
||||||
_ = w.file.Close()
|
if err := w.file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
w.file = nil
|
w.file = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
path := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
||||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := file.Chmod(0o600); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
w.file = file
|
w.file = file
|
||||||
w.day = day
|
w.day = day
|
||||||
|
|
||||||
// 异步压缩昨日日志并清理过期文件,避免阻塞业务写路径
|
// 异步补压缩全部历史日志并清理过期文件,避免漏掉停机期间的日期。
|
||||||
if prevDay != "" && prevDay != day {
|
go w.maintain(now)
|
||||||
go w.maintain(prevDay, now)
|
|
||||||
} else {
|
|
||||||
// 进程启动或首写:尝试压缩昨天 + 清理过期
|
|
||||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
|
||||||
go w.maintain(yesterday, now)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *dailyWriter) maintain(prevDay string, now time.Time) {
|
func (w *dailyWriter) maintain(now time.Time) {
|
||||||
if prevDay != "" {
|
if err := w.compressHistorical(now); err != nil {
|
||||||
_ = w.compressDay(prevDay)
|
writeMaintenanceError(err)
|
||||||
}
|
}
|
||||||
if w.retainDays > 0 {
|
if w.retainDays > 0 {
|
||||||
_ = w.purgeOlderThan(now)
|
if err := w.purgeOlderThan(now); err != nil {
|
||||||
|
writeMaintenanceError(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *dailyWriter) compressHistorical(now time.Time) error {
|
||||||
|
entries, err := os.ReadDir(w.dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
today := now.In(w.location).Format("2006-01-02")
|
||||||
|
for _, entry := range entries {
|
||||||
|
day, ok := w.dayFromFilename(entry.Name(), ".log")
|
||||||
|
if !ok || day >= today {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := w.compressDay(day); err != nil {
|
||||||
|
return fmt.Errorf("压缩 %s 日志失败: %w", day, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *dailyWriter) compressDay(day string) error {
|
func (w *dailyWriter) compressDay(day string) error {
|
||||||
srcPath := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
srcPath := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
|
||||||
dstPath := srcPath + ".gz"
|
dstPath := srcPath + ".gz"
|
||||||
if _, err := os.Stat(dstPath); err == nil {
|
if _, err := os.Stat(dstPath); err == nil {
|
||||||
// 已压缩则删除明文(若仍存在)
|
if err := validateGzip(dstPath); err == nil {
|
||||||
_ = os.Remove(srcPath)
|
if err := os.Remove(srcPath); err != nil && !os.IsNotExist(err) {
|
||||||
return nil
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Remove(dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
src, err := os.Open(srcPath)
|
src, err := os.Open(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,32 +149,58 @@ func (w *dailyWriter) compressDay(day string) error {
|
|||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
|
||||||
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
dst, err := os.CreateTemp(w.dir, "."+filepath.Base(dstPath)+".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tmpPath := dst.Name()
|
||||||
|
defer func() { _ = os.Remove(tmpPath) }()
|
||||||
|
if err := dst.Chmod(0o600); err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
gz := gzip.NewWriter(dst)
|
gz := gzip.NewWriter(dst)
|
||||||
if _, err := io.Copy(gz, src); err != nil {
|
if _, err := io.Copy(gz, src); err != nil {
|
||||||
_ = gz.Close()
|
_ = gz.Close()
|
||||||
_ = dst.Close()
|
_ = dst.Close()
|
||||||
_ = os.Remove(dstPath)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := gz.Close(); err != nil {
|
if err := gz.Close(); err != nil {
|
||||||
_ = dst.Close()
|
_ = dst.Close()
|
||||||
_ = os.Remove(dstPath)
|
return err
|
||||||
|
}
|
||||||
|
if err := dst.Sync(); err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := dst.Close(); err != nil {
|
if err := dst.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(tmpPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return os.Remove(srcPath)
|
return os.Remove(srcPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateGzip(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
reader, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(io.Discard, reader)
|
||||||
|
closeErr := reader.Close()
|
||||||
|
return errors.Join(copyErr, closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
||||||
// 按「日历天」比较:保留最近 retainDays 天(含当天),更早的 .log / .log.gz 删除。
|
// 按「日历天」比较:保留最近 retainDays 天(含当天),更早的 .log / .log.gz 删除。
|
||||||
today := time.Date(now.In(w.location).Year(), now.In(w.location).Month(), now.In(w.location).Day(), 0, 0, 0, 0, w.location)
|
today := time.Date(now.In(w.location).Year(), now.In(w.location).Month(), now.In(w.location).Day(), 0, 0, 0, 0, w.location)
|
||||||
cutoff := today.AddDate(0, 0, -w.retainDays) // day < cutoff 才删除;retainDays=2 且今天 12 号 → 删除 10 号之前
|
cutoff := today.AddDate(0, 0, -(w.retainDays - 1))
|
||||||
entries, err := os.ReadDir(w.dir)
|
entries, err := os.ReadDir(w.dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -150,10 +211,10 @@ func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
if !strings.HasPrefix(name, prefix) {
|
if !strings.HasPrefix(name, prefix) ||
|
||||||
|
(!strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".log.gz")) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// app-2026-07-11.log 或 app-2026-07-11.log.gz
|
|
||||||
rest := strings.TrimPrefix(name, prefix)
|
rest := strings.TrimPrefix(name, prefix)
|
||||||
rest = strings.TrimSuffix(rest, ".gz")
|
rest = strings.TrimSuffix(rest, ".gz")
|
||||||
rest = strings.TrimSuffix(rest, ".log")
|
rest = strings.TrimSuffix(rest, ".log")
|
||||||
@@ -162,8 +223,26 @@ func (w *dailyWriter) purgeOlderThan(now time.Time) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if day.Before(cutoff) {
|
if day.Before(cutoff) {
|
||||||
_ = os.Remove(filepath.Join(w.dir, name))
|
if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *dailyWriter) dayFromFilename(name string, suffix string) (string, bool) {
|
||||||
|
prefix := w.prefix + "-"
|
||||||
|
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
day := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix)
|
||||||
|
if _, err := time.ParseInLocation("2006-01-02", day, w.location); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return day, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMaintenanceError(err error) {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "%s | ERROR | 日志维护失败 | error=%q\n", time.Now().Format("2006-01-02 15:04:05.000"), err.Error())
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestCompressDayAndPurge(t *testing.T) {
|
|||||||
t.Fatalf("gzip missing: %v", err)
|
t.Fatalf("gzip missing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以 7-12 为「现在」,保留 2 天 → cutoff=7-10,删除 7-10 之前(仅 7-01)
|
// 以 7-12 为「现在」,保留 2 天(7-11、7-12),删除 7-11 之前。
|
||||||
now := time.Date(2026, 7, 12, 12, 0, 0, 0, loc)
|
now := time.Date(2026, 7, 12, 12, 0, 0, 0, loc)
|
||||||
if err := w.purgeOlderThan(now); err != nil {
|
if err := w.purgeOlderThan(now); err != nil {
|
||||||
t.Fatalf("purgeOlderThan: %v", err)
|
t.Fatalf("purgeOlderThan: %v", err)
|
||||||
@@ -40,15 +40,38 @@ func TestCompressDayAndPurge(t *testing.T) {
|
|||||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-01.log")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-01.log")); !os.IsNotExist(err) {
|
||||||
t.Fatal("expired log should be purged")
|
t.Fatal("expired log should be purged")
|
||||||
}
|
}
|
||||||
// 7-10 / 7-11 仍应存在(cutoff 当天起保留)
|
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); !os.IsNotExist(err) {
|
||||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); err != nil {
|
t.Fatalf("expired gzip should be purged, err=%v", err)
|
||||||
t.Fatalf("recent gzip should remain: %v", err)
|
|
||||||
}
|
}
|
||||||
|
// 7-11 仍应存在(cutoff 当天起保留)
|
||||||
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-11.log")); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-11.log")); err != nil {
|
||||||
t.Fatalf("recent plain should remain: %v", err)
|
t.Fatalf("recent plain should remain: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompressDayReplacesCorruptedGzip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
w := newDailyWriter(dir, "app", time.UTC, 0)
|
||||||
|
sourcePath := filepath.Join(dir, "app-2026-07-11.log")
|
||||||
|
gzipPath := sourcePath + ".gz"
|
||||||
|
if err := os.WriteFile(sourcePath, []byte("important log\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(gzipPath, []byte("broken"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.compressDay("2026-07-11"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateGzip(gzipPath); err != nil {
|
||||||
|
t.Fatalf("gzip should be valid: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("source should be removed after successful compression, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompressDayIdempotent(t *testing.T) {
|
func TestCompressDayIdempotent(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
loc := time.UTC
|
loc := time.UTC
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package logging
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,47 +19,41 @@ func New(cfg config.LogConfig) (*zap.Logger, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
level := zap.NewAtomicLevelAt(parseLevel(cfg.Level))
|
level := zap.NewAtomicLevelAt(parseLevel(cfg.Level))
|
||||||
encoderConfig := zap.NewProductionEncoderConfig()
|
|
||||||
encoderConfig.TimeKey = "time"
|
|
||||||
encoderConfig.LevelKey = "level"
|
|
||||||
encoderConfig.MessageKey = "message"
|
|
||||||
encoderConfig.CallerKey = "caller"
|
|
||||||
encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
|
|
||||||
enc.AppendString(t.In(location).Format("2006-01-02 15:04:05.000 -0700"))
|
|
||||||
}
|
|
||||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
|
||||||
encoderConfig.EncodeDuration = zapcore.StringDurationEncoder
|
|
||||||
encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
|
|
||||||
|
|
||||||
cores := make([]zapcore.Core, 0, 2)
|
cores := make([]zapcore.Core, 0, 2)
|
||||||
if cfg.EnableConsole {
|
if cfg.EnableConsole {
|
||||||
cores = append(cores, zapcore.NewCore(
|
cores = append(cores, zapcore.NewCore(
|
||||||
zapcore.NewConsoleEncoder(encoderConfig),
|
newTextEncoder(location),
|
||||||
zapcore.Lock(os.Stdout),
|
zapcore.Lock(os.Stdout),
|
||||||
level,
|
level,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if cfg.EnableFile {
|
if cfg.EnableFile {
|
||||||
writer := newDailyWriter(cfg.Dir, "app", location, cfg.RetainDays)
|
writer := newDailyWriter(cfg.Dir, "app", location, cfg.RetainDays)
|
||||||
|
if err := writer.Open(); err != nil {
|
||||||
|
return nil, fmt.Errorf("初始化日志文件失败: %w", err)
|
||||||
|
}
|
||||||
cores = append(cores, zapcore.NewCore(
|
cores = append(cores, zapcore.NewCore(
|
||||||
zapcore.NewJSONEncoder(encoderConfig),
|
newTextEncoder(location),
|
||||||
writer,
|
writer,
|
||||||
level,
|
level,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if len(cores) == 0 {
|
if len(cores) == 0 {
|
||||||
cores = append(cores, zapcore.NewCore(
|
cores = append(cores, zapcore.NewCore(
|
||||||
zapcore.NewConsoleEncoder(encoderConfig),
|
newTextEncoder(location),
|
||||||
zapcore.Lock(os.Stdout),
|
zapcore.Lock(os.Stdout),
|
||||||
level,
|
level,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
return zap.New(
|
logger := zap.New(
|
||||||
zapcore.NewTee(cores...),
|
zapcore.NewTee(cores...),
|
||||||
zap.AddCaller(),
|
zap.AddCaller(),
|
||||||
zap.AddStacktrace(zapcore.PanicLevel),
|
zap.AddStacktrace(zapcore.PanicLevel),
|
||||||
), nil
|
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
|
||||||
|
)
|
||||||
|
zap.ReplaceGlobals(logger)
|
||||||
|
return logger, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLevel(value string) zapcore.Level {
|
func parseLevel(value string) zapcore.Level {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap/buffer"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var textBufferPool = buffer.NewPool()
|
||||||
|
|
||||||
|
// textEncoder 把结构化字段输出为便于直接阅读和 grep 的单行文本。
|
||||||
|
type textEncoder struct {
|
||||||
|
*zapcore.MapObjectEncoder
|
||||||
|
location *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTextEncoder(location *time.Location) zapcore.Encoder {
|
||||||
|
return &textEncoder{
|
||||||
|
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
|
||||||
|
location: location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *textEncoder) Clone() zapcore.Encoder {
|
||||||
|
cloned := &textEncoder{
|
||||||
|
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
|
||||||
|
location: e.location,
|
||||||
|
}
|
||||||
|
for key, value := range e.Fields {
|
||||||
|
cloned.Fields[key] = value
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *textEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
|
||||||
|
ctx := e.Clone().(*textEncoder)
|
||||||
|
for i := range fields {
|
||||||
|
fields[i].AddTo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := textBufferPool.Get()
|
||||||
|
buf.AppendString(entry.Time.In(e.location).Format("2006-01-02 15:04:05.000"))
|
||||||
|
buf.AppendString(" | ")
|
||||||
|
buf.AppendString(fmt.Sprintf("%-5s", strings.ToUpper(entry.Level.String())))
|
||||||
|
buf.AppendString(" | ")
|
||||||
|
buf.AppendString(cleanText(entry.Message))
|
||||||
|
|
||||||
|
if entry.Level >= zapcore.WarnLevel && entry.Caller.Defined {
|
||||||
|
buf.AppendString(" | caller=")
|
||||||
|
buf.AppendString(entry.Caller.TrimmedPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := orderedKeys(ctx.Fields)
|
||||||
|
for _, key := range keys {
|
||||||
|
value := ctx.Fields[key]
|
||||||
|
if isEmptyValue(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.AppendString(" | ")
|
||||||
|
buf.AppendString(key)
|
||||||
|
buf.AppendByte('=')
|
||||||
|
buf.AppendString(formatValue(value))
|
||||||
|
}
|
||||||
|
if entry.Stack != "" {
|
||||||
|
buf.AppendString(" | stack=")
|
||||||
|
buf.AppendString(strconv.Quote(entry.Stack))
|
||||||
|
}
|
||||||
|
buf.AppendByte('\n')
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedKeys(fields map[string]any) []string {
|
||||||
|
priority := []string{
|
||||||
|
"module", "request_id", "method", "route", "path", "status", "code",
|
||||||
|
"duration_ms", "rows", "user_id", "admin_id", "client_ip", "error",
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
seen := make(map[string]bool, len(fields))
|
||||||
|
for _, key := range priority {
|
||||||
|
if _, ok := fields[key]; ok {
|
||||||
|
keys = append(keys, key)
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest := make([]string, 0, len(fields)-len(keys))
|
||||||
|
for key := range fields {
|
||||||
|
if !seen[key] {
|
||||||
|
rest = append(rest, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(rest)
|
||||||
|
return append(keys, rest...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatValue(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if typed != "" && !strings.ContainsAny(typed, " \t\r\n|=\"") {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
return strconv.Quote(typed)
|
||||||
|
case []byte:
|
||||||
|
return strconv.Quote(string(typed))
|
||||||
|
case time.Time:
|
||||||
|
return typed.Format(time.RFC3339)
|
||||||
|
case time.Duration:
|
||||||
|
return typed.String()
|
||||||
|
default:
|
||||||
|
return cleanText(fmt.Sprint(typed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanText(value string) string {
|
||||||
|
return strings.NewReplacer("\r", `\r`, "\n", `\n`, "\t", `\t`).Replace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyValue(value any) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
text, ok := value.(string)
|
||||||
|
return ok && strings.TrimSpace(text) == ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTextEncoderProducesReadableSingleLine(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
core := zapcore.NewCore(newTextEncoder(time.UTC), zapcore.AddSync(&output), zapcore.DebugLevel)
|
||||||
|
logger := zap.New(core)
|
||||||
|
logger.Info("服务启动",
|
||||||
|
zap.String("request_id", "req-1"),
|
||||||
|
zap.String("empty", ""),
|
||||||
|
zap.String("detail", "line1\nline2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
got := output.String()
|
||||||
|
if strings.ContainsAny(got, "{}") {
|
||||||
|
t.Fatalf("output should not use JSON object syntax: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "INFO | 服务启动 | request_id=req-1") {
|
||||||
|
t.Fatalf("unexpected text output: %s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "empty=") {
|
||||||
|
t.Fatalf("empty fields should be omitted: %s", got)
|
||||||
|
}
|
||||||
|
if strings.Count(got, "\n") != 1 || !strings.Contains(got, `detail="line1\nline2"`) {
|
||||||
|
t.Fatalf("output should remain one physical line: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,22 +11,15 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Recovery(logger *zap.Logger) gin.HandlerFunc {
|
const contextPanicStack = "panic_stack"
|
||||||
|
|
||||||
|
func Recovery(_ *zap.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if recovered := recover(); recovered != nil {
|
if recovered := recover(); recovered != nil {
|
||||||
err := fmt.Errorf("panic: %v", recovered)
|
err := fmt.Errorf("panic: %v", recovered)
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
logger.Error("http panic recovered",
|
c.Set(contextPanicStack, string(debug.Stack()))
|
||||||
zap.String("request_id", GetRequestID(c)),
|
|
||||||
zap.String("method", c.Request.Method),
|
|
||||||
zap.String("path", c.Request.URL.Path),
|
|
||||||
zap.String("route", c.FullPath()),
|
|
||||||
zap.String("client_ip", c.ClientIP()),
|
|
||||||
zap.String("user_agent", c.GetHeader("User-Agent")),
|
|
||||||
zap.String("panic", fmt.Sprint(recovered)),
|
|
||||||
zap.ByteString("stack", debug.Stack()),
|
|
||||||
)
|
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/logging"
|
"hfb_sys/backend/internal/logging"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var requestIDFallbackCounter atomic.Uint64
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RequestIDHeader = "X-Request-ID"
|
RequestIDHeader = "X-Request-ID"
|
||||||
ContextRequestID = "request_id"
|
ContextRequestID = "request_id"
|
||||||
@@ -17,7 +23,7 @@ const (
|
|||||||
func RequestID() gin.HandlerFunc {
|
func RequestID() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
requestID := c.GetHeader(RequestIDHeader)
|
requestID := c.GetHeader(RequestIDHeader)
|
||||||
if requestID == "" {
|
if !validRequestID(requestID) {
|
||||||
requestID = newRequestID()
|
requestID = newRequestID()
|
||||||
}
|
}
|
||||||
c.Set(ContextRequestID, requestID)
|
c.Set(ContextRequestID, requestID)
|
||||||
@@ -42,7 +48,19 @@ func GetRequestID(c *gin.Context) string {
|
|||||||
func newRequestID() string {
|
func newRequestID() string {
|
||||||
buf := make([]byte, 16)
|
buf := make([]byte, 16)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return ""
|
return fmt.Sprintf("%x-%x", time.Now().UnixNano(), requestIDFallbackCounter.Add(1))
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(buf)
|
return hex.EncodeToString(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validRequestID(value string) bool {
|
||||||
|
if value == "" || len(value) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.IndexFunc(value, func(char rune) bool {
|
||||||
|
return !((char >= 'a' && char <= 'z') ||
|
||||||
|
(char >= 'A' && char <= 'Z') ||
|
||||||
|
(char >= '0' && char <= '9') ||
|
||||||
|
char == '-' || char == '_' || char == '.')
|
||||||
|
}) == -1
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidRequestID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "标准 ID", value: "req-20260729_ab.cd", want: true},
|
||||||
|
{name: "空值", value: "", want: false},
|
||||||
|
{name: "包含空格", value: "bad id", want: false},
|
||||||
|
{name: "包含换行", value: "bad\nid", want: false},
|
||||||
|
{name: "超长", value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := validRequestID(tt.value); got != tt.want {
|
||||||
|
t.Fatalf("validRequestID() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 成功且延迟低于该阈值的热路径请求不再写访问日志(错误/慢请求仍全量记录)。
|
// 正常请求不记录访问日志;只有慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
|
||||||
const slowRequestThresholdMs = 200
|
const slowRequestThresholdMs = 500
|
||||||
|
|
||||||
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -20,8 +22,9 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
route := c.FullPath()
|
route := c.FullPath()
|
||||||
|
authFailure := meaningfulAuthFailure(c)
|
||||||
|
|
||||||
if shouldSkipHTTPLog(path, route, status, latencyMs) {
|
if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +34,9 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
zap.String("path", path),
|
zap.String("path", path),
|
||||||
zap.String("route", route),
|
zap.String("route", route),
|
||||||
zap.Int("status", status),
|
zap.Int("status", status),
|
||||||
zap.Float64("latency_ms", latencyMs),
|
zap.String("code", response.CodeFromContext(c)),
|
||||||
|
zap.Float64("duration_ms", latencyMs),
|
||||||
zap.String("client_ip", c.ClientIP()),
|
zap.String("client_ip", c.ClientIP()),
|
||||||
zap.Int("response_size", c.Writer.Size()),
|
|
||||||
}
|
|
||||||
// 成功响应省略 UA/referer,错误与 4xx/5xx 保留完整现场
|
|
||||||
if status >= 400 {
|
|
||||||
fields = append(fields,
|
|
||||||
zap.String("user_agent", c.GetHeader("User-Agent")),
|
|
||||||
zap.String("referer", c.GetHeader("Referer")),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if userID, ok := c.Get(ContextUserID); ok {
|
if userID, ok := c.Get(ContextUserID); ok {
|
||||||
fields = append(fields, zap.Any("user_id", userID))
|
fields = append(fields, zap.Any("user_id", userID))
|
||||||
@@ -64,47 +60,44 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
fields = append(fields, zap.Any("auth_current_token_version", currentVersion))
|
fields = append(fields, zap.Any("auth_current_token_version", currentVersion))
|
||||||
}
|
}
|
||||||
if len(c.Errors) > 0 {
|
if len(c.Errors) > 0 {
|
||||||
fields = append(fields, zap.String("errors", strings.TrimSpace(c.Errors.String())))
|
fields = append(fields,
|
||||||
|
zap.String("error", c.Errors.Last().Err.Error()),
|
||||||
|
zap.Int("error_count", len(c.Errors)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if stack, ok := c.Get(contextPanicStack); ok {
|
||||||
|
fields = append(fields, zap.Any("stack", stack))
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case status >= 500:
|
case status >= 500:
|
||||||
logger.Error("http request", fields...)
|
logger.Error("HTTP 请求失败", fields...)
|
||||||
case status >= 400:
|
case status == http.StatusTooManyRequests:
|
||||||
logger.Warn("http request", fields...)
|
logger.Warn("HTTP 请求被限流", fields...)
|
||||||
default:
|
case authFailure:
|
||||||
logger.Info("http request", fields...)
|
logger.Warn("后台认证失败", fields...)
|
||||||
|
case latencyMs >= slowRequestThresholdMs:
|
||||||
|
logger.Warn("HTTP 慢请求", fields...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldSkipHTTPLog 判断是否跳过写入:仅跳过「成功 + 非慢请求」的高频热路径。
|
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
|
||||||
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
|
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
|
||||||
if status >= 400 {
|
if status >= 500 || status == http.StatusTooManyRequests {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if latencyMs >= slowRequestThresholdMs {
|
if latencyMs >= slowRequestThresholdMs {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return isHotPath(path, route)
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHotPath(path, route string) bool {
|
func meaningfulAuthFailure(c *gin.Context) bool {
|
||||||
if strings.HasSuffix(path, "/unread-count") {
|
value, ok := c.Get(ContextAuthFailureReason)
|
||||||
return true
|
if !ok {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if path == "/api/wallet/balance" {
|
reason, _ := value.(string)
|
||||||
return true
|
return reason != "" && reason != "missing"
|
||||||
}
|
|
||||||
if path == "/api/mobile-home-config" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, "/cover") || route == "/api/listings/:id/cover" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if path == "/api/files/object" || path == "/api/admin/files/object" ||
|
|
||||||
route == "/api/files/object" || route == "/api/admin/files/object" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestLoggerRecordsServerErrorCause(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
core, observed := observer.New(zap.DebugLevel)
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(RequestID(), RequestLogger(zap.New(core)))
|
||||||
|
engine.GET("/failed", func(c *gin.Context) {
|
||||||
|
response.RecordError(c, errors.New("database unavailable"))
|
||||||
|
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
|
||||||
|
})
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/failed", nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(responseRecorder, request)
|
||||||
|
|
||||||
|
entries := observed.FilterMessage("HTTP 请求失败").All()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("server error logs = %d, want 1", len(entries))
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["error"] != "database unavailable" || fields["code"] != "internal_error" {
|
||||||
|
t.Fatalf("unexpected fields: %v", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggerSkipsOrdinaryNotFound(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
core, observed := observer.New(zap.DebugLevel)
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(RequestID(), RequestLogger(zap.New(core)))
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/missing", nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(responseRecorder, request)
|
||||||
|
if observed.Len() != 0 {
|
||||||
|
t.Fatalf("ordinary 404 should not produce logs: %v", observed.All())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,14 @@ func TestShouldSkipHTTPLog(t *testing.T) {
|
|||||||
latencyMs float64
|
latencyMs float64
|
||||||
wantSkip bool
|
wantSkip bool
|
||||||
}{
|
}{
|
||||||
{name: "轮询未读成功跳过", path: "/api/chats/unread-count", status: 200, latencyMs: 1, wantSkip: true},
|
{name: "普通成功请求跳过", path: "/api/orders", status: 200, latencyMs: 20, wantSkip: true},
|
||||||
{name: "钱包余额成功跳过", path: "/api/wallet/balance", status: 200, latencyMs: 2, wantSkip: true},
|
{name: "创建成功请求跳过", path: "/api/orders", status: 201, latencyMs: 30, wantSkip: true},
|
||||||
{name: "封面成功跳过", path: "/api/listings/12/cover", route: "/api/listings/:id/cover", status: 200, latencyMs: 3, wantSkip: true},
|
{name: "普通 400 跳过", path: "/api/orders", status: 400, latencyMs: 2, wantSkip: true},
|
||||||
{name: "文件对象成功跳过", path: "/api/files/object", status: 200, latencyMs: 5, wantSkip: true},
|
{name: "普通 401 跳过", path: "/api/orders", status: 401, latencyMs: 2, wantSkip: true},
|
||||||
{name: "首页配置成功跳过", path: "/api/mobile-home-config", status: 200, latencyMs: 4, wantSkip: true},
|
{name: "普通 404 跳过", path: "/unknown", status: 404, latencyMs: 2, wantSkip: true},
|
||||||
{name: "轮询 401 不跳过", path: "/api/chats/unread-count", status: 401, latencyMs: 1, wantSkip: false},
|
|
||||||
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
|
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
|
||||||
{name: "轮询慢请求不跳过", path: "/api/chats/unread-count", status: 200, latencyMs: 250, wantSkip: false},
|
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
|
||||||
{name: "普通业务成功不跳过", path: "/api/orders", status: 200, latencyMs: 20, wantSkip: false},
|
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
|
||||||
{name: "下单成功不跳过", path: "/api/orders", status: 201, latencyMs: 30, wantSkip: false},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func parseQuery(c *gin.Context) (Query, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeAuditError(c *gin.Context, err error) {
|
func writeAuditError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func isHTTPSRequest(c *gin.Context) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeAdminAuthError(c *gin.Context, err error) {
|
func writeAdminAuthError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func (h *Handler) Summary(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeDashboardError(c *gin.Context, err error) {
|
func writeDashboardError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeFinanceError(c *gin.Context, err error) {
|
func writeFinanceError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeNotificationError(c *gin.Context, err error) {
|
func writeNotificationError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writePushError(c *gin.Context, err error) {
|
func writePushError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func auditMeta(c *gin.Context) AuditMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeAdminUserError(c *gin.Context, err error) {
|
func writeAdminUserError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := h.service.List(c.Request.Context(), query)
|
result, err := h.service.List(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取公告列表失败")
|
response.InternalServerError(c, "获取公告列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := h.service.AdminList(c.Request.Context(), query)
|
result, err := h.service.AdminList(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取公告列表失败")
|
response.InternalServerError(c, "获取公告列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
|
|
||||||
announcement, err := h.service.Create(c.Request.Context(), req, adminID)
|
announcement, err := h.service.Create(c.Request.Context(), req, adminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "创建公告失败")
|
response.InternalServerError(c, "创建公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,7 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
announcement, err := h.service.Update(c.Request.Context(), id, req)
|
announcement, err := h.service.Update(c.Request.Context(), id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "更新公告失败")
|
response.InternalServerError(c, "更新公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,6 +154,7 @@ func (h *Handler) Publish(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.Publish(c.Request.Context(), id); err != nil {
|
if err := h.service.Publish(c.Request.Context(), id); err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "发布公告失败")
|
response.InternalServerError(c, "发布公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,6 +171,7 @@ func (h *Handler) Archive(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.Archive(c.Request.Context(), id); err != nil {
|
if err := h.service.Archive(c.Request.Context(), id); err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "归档公告失败")
|
response.InternalServerError(c, "归档公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -182,6 +188,7 @@ func (h *Handler) Unarchive(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.Unarchive(c.Request.Context(), id); err != nil {
|
if err := h.service.Unarchive(c.Request.Context(), id); err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "取消归档公告失败")
|
response.InternalServerError(c, "取消归档公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -198,6 +205,7 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "删除公告失败")
|
response.InternalServerError(c, "删除公告失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ func (h *Handler) ResetPassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeAuthError(c *gin.Context, err error) {
|
func writeAuthError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库或 Redis 未连接")
|
response.ServiceUnavailable(c, "数据库或 Redis 未连接")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/captcha"
|
"hfb_sys/backend/internal/captcha"
|
||||||
smsprovider "hfb_sys/backend/internal/integrations/sms"
|
smsprovider "hfb_sys/backend/internal/integrations/sms"
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -120,7 +121,15 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
|
|||||||
}
|
}
|
||||||
if err := s.sms.SendLoginCode(ctx, phone, code); err != nil {
|
if err := s.sms.SendLoginCode(ctx, phone, code); err != nil {
|
||||||
if s.log != nil {
|
if s.log != nil {
|
||||||
s.log.Warn("sms login code send failed", zap.String("phone", PublicPhone(phone)), zap.Error(err))
|
fields := []zap.Field{
|
||||||
|
zap.String("module", "auth"),
|
||||||
|
zap.String("phone", PublicPhone(phone)),
|
||||||
|
zap.Error(err),
|
||||||
|
}
|
||||||
|
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
|
||||||
|
fields = append(fields, zap.String("request_id", requestID))
|
||||||
|
}
|
||||||
|
s.log.Warn("短信验证码发送失败", fields...)
|
||||||
}
|
}
|
||||||
if smsprovider.IsProviderRateLimited(err) {
|
if smsprovider.IsProviderRateLimited(err) {
|
||||||
pipe := s.redis.TxPipeline()
|
pipe := s.redis.TxPipeline()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func writeChatError(c *gin.Context, err error) {
|
func writeChatError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "聊天服务暂时不可用")
|
response.ServiceUnavailable(c, "聊天服务暂时不可用")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +21,8 @@ func (h *Handler) CreateQrCodeHandler(c *gin.Context) {
|
|||||||
adminID := c.GetUint64("admin_id")
|
adminID := c.GetUint64("admin_id")
|
||||||
qrcode, err := h.service.repo.CreateQrCode(c.Request.Context(), adminID, req)
|
qrcode, err := h.service.repo.CreateQrCode(c.Request.Context(), adminID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +40,8 @@ func (h *Handler) BatchCreateQrCodeHandler(c *gin.Context) {
|
|||||||
adminID := c.GetUint64("admin_id")
|
adminID := c.GetUint64("admin_id")
|
||||||
qrcodes, err := h.service.repo.BatchCreateQrCode(c.Request.Context(), adminID, req)
|
qrcodes, err := h.service.repo.BatchCreateQrCode(c.Request.Context(), adminID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,8 @@ func (h *Handler) BatchDeleteQrCodeHandler(c *gin.Context) {
|
|||||||
|
|
||||||
deleted, err := h.service.repo.BatchDeleteQrCodes(c.Request.Context(), req.IDs)
|
deleted, err := h.service.repo.BatchDeleteQrCodes(c.Request.Context(), req.IDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +76,8 @@ func (h *Handler) ListQrCodesHandler(c *gin.Context) {
|
|||||||
|
|
||||||
qrcodes, total, err := h.service.repo.ListQrCodes(c.Request.Context(), req)
|
qrcodes, total, err := h.service.repo.ListQrCodes(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +95,8 @@ func (h *Handler) ListQrCodesHandler(c *gin.Context) {
|
|||||||
func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
|
func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
|
||||||
stats, err := h.service.repo.GetQrCodeStats(c.Request.Context())
|
stats, err := h.service.repo.GetQrCodeStats(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +131,8 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +175,8 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +197,8 @@ func (h *Handler) GetOCRJobResultHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +228,8 @@ func (h *Handler) UpdateQrCodeHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "已发放的二维码不能改回待用"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "已发放的二维码不能改回待用"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +249,8 @@ func (h *Handler) DeleteQrCodeHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
response.RecordError(c, err)
|
||||||
|
response.InternalServerError(c, "聊天二维码服务暂时不可用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/listingstatus"
|
"hfb_sys/backend/internal/listingstatus"
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/adminnotification"
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
"hfb_sys/backend/pkg/money"
|
"hfb_sys/backend/pkg/money"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -453,7 +454,13 @@ func (r *Repository) startRefundBestEffort(ctx context.Context, action *refundAc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := r.refundStarter.StartRefund(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
|
if _, err := r.refundStarter.StartRefund(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
|
||||||
log.Printf("[dispute] start refund failed order_id=%d biz_type=%s amount_cent=%d err=%v", action.OrderID, action.BizType, action.RefundAmountCent, err)
|
logging.FromContext(ctx).Error("争议退款提交失败",
|
||||||
|
zap.String("module", "dispute"),
|
||||||
|
zap.Uint64("order_id", action.OrderID),
|
||||||
|
zap.String("biz_type", action.BizType),
|
||||||
|
zap.Int64("amount_cent", action.RefundAmountCent),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ func parsePagination(c *gin.Context) (int, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeDisputeError(c *gin.Context, err error) {
|
func writeDisputeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeFileError(c *gin.Context, err error) {
|
func writeFileError(c *gin.Context, err error) {
|
||||||
_ = c.Error(err)
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "文件存储未连接")
|
response.ServiceUnavailable(c, "文件存储未连接")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func writeListingError(c *gin.Context, err error) {
|
func writeListingError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -116,6 +116,7 @@ func externalUploadIPAllowed(clientIP string, allowed []string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeExternalUploadAuthError(c *gin.Context, err error) {
|
func writeExternalUploadAuthError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch err {
|
switch err {
|
||||||
case errExternalUploadForbiddenIP:
|
case errExternalUploadForbiddenIP:
|
||||||
response.Error(c, http.StatusForbidden, "forbidden", "来源 IP 不允许访问")
|
response.Error(c, http.StatusForbidden, "forbidden", "来源 IP 不允许访问")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func NewHandler(service *Service) *Handler {
|
|||||||
func (h *Handler) ListCategories(c *gin.Context) {
|
func (h *Handler) ListCategories(c *gin.Context) {
|
||||||
items, err := h.service.ListPublicCategories(c.Request.Context())
|
items, err := h.service.ListPublicCategories(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取分类失败")
|
response.InternalServerError(c, "获取分类失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ func (h *Handler) ListProducts(c *gin.Context) {
|
|||||||
query.Page, query.PageSize = parsePagination(c)
|
query.Page, query.PageSize = parsePagination(c)
|
||||||
result, err := h.service.ListPublicProducts(c.Request.Context(), query)
|
result, err := h.service.ListPublicProducts(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取商品列表失败")
|
response.InternalServerError(c, "获取商品列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,7 @@ func (h *Handler) ListMyOrders(c *gin.Context) {
|
|||||||
query.Page, query.PageSize = parsePagination(c)
|
query.Page, query.PageSize = parsePagination(c)
|
||||||
result, err := h.service.ListOrdersForUser(c.Request.Context(), userID, query)
|
result, err := h.service.ListOrdersForUser(c.Request.Context(), userID, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取订单列表失败")
|
response.InternalServerError(c, "获取订单列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,7 @@ func (h *Handler) CancelMyOrder(c *gin.Context) {
|
|||||||
func (h *Handler) AdminListCategories(c *gin.Context) {
|
func (h *Handler) AdminListCategories(c *gin.Context) {
|
||||||
items, err := h.service.AdminListCategories(c.Request.Context())
|
items, err := h.service.AdminListCategories(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取分类失败")
|
response.InternalServerError(c, "获取分类失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,7 @@ func (h *Handler) AdminListProducts(c *gin.Context) {
|
|||||||
query.Page, query.PageSize = parsePagination(c)
|
query.Page, query.PageSize = parsePagination(c)
|
||||||
result, err := h.service.AdminListProducts(c.Request.Context(), query)
|
result, err := h.service.AdminListProducts(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取商品列表失败")
|
response.InternalServerError(c, "获取商品列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -273,6 +278,7 @@ func (h *Handler) AdminListOrders(c *gin.Context) {
|
|||||||
query.Page, query.PageSize = parsePagination(c)
|
query.Page, query.PageSize = parsePagination(c)
|
||||||
result, err := h.service.AdminListOrders(c.Request.Context(), query)
|
result, err := h.service.AdminListOrders(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取订单列表失败")
|
response.InternalServerError(c, "获取订单列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -340,6 +346,7 @@ func (h *Handler) AdminCancelOrder(c *gin.Context) {
|
|||||||
func (h *Handler) AdminGetConfig(c *gin.Context) {
|
func (h *Handler) AdminGetConfig(c *gin.Context) {
|
||||||
item, err := h.service.GetConfig(c.Request.Context())
|
item, err := h.service.GetConfig(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "获取配置失败")
|
response.InternalServerError(c, "获取配置失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -354,6 +361,7 @@ func (h *Handler) AdminUpdateConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
item, err := h.service.UpdateConfig(c.Request.Context(), req)
|
item, err := h.service.UpdateConfig(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.InternalServerError(c, "更新配置失败")
|
response.InternalServerError(c, "更新配置失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,6 +369,7 @@ func (h *Handler) AdminUpdateConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrProductNotFound), errors.Is(err, ErrOrderNotFound):
|
case errors.Is(err, ErrProductNotFound), errors.Is(err, ErrOrderNotFound):
|
||||||
response.NotFound(c, err.Error())
|
response.NotFound(c, err.Error())
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeNotificationError(c *gin.Context, err error) {
|
func writeNotificationError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func writeOrderError(c *gin.Context, err error) {
|
func writeOrderError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package order
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) {
|
func (r *Repository) prepareRefund(order *model.RentalOrder, amountCent int64, bizType string, remark string) (*refundAction, error) {
|
||||||
@@ -30,6 +32,12 @@ func (r *Repository) startRefundBestEffort(ctx context.Context, action *refundAc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := r.refundStarter.StartRefund(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
|
if _, err := r.refundStarter.StartRefund(ctx, action.OrderID, action.RefundAmountCent, action.BizType, action.Remark); err != nil {
|
||||||
log.Printf("[order] start refund failed order_id=%d biz_type=%s amount_cent=%d err=%v", action.OrderID, action.BizType, action.RefundAmountCent, err)
|
logging.FromContext(ctx).Error("订单退款提交失败",
|
||||||
|
zap.String("module", "order"),
|
||||||
|
zap.Uint64("order_id", action.OrderID),
|
||||||
|
zap.String("biz_type", action.BizType),
|
||||||
|
zap.Int64("amount_cent", action.RefundAmountCent),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,14 +186,13 @@ func (h *Handler) LeshuaNotify(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
rawPayload := string(body)
|
rawPayload := string(body)
|
||||||
contentType := c.GetHeader("Content-Type")
|
contentType := c.GetHeader("Content-Type")
|
||||||
h.log().Info("payment notify received", notifyLogFields(c.Request.Context(), "leshua", params, contentType, len(body))...)
|
|
||||||
result, err := h.service.HandleLeshuaNotify(c.Request.Context(), params, rawPayload, contentType)
|
result, err := h.service.HandleLeshuaNotify(c.Request.Context(), params, rawPayload, contentType)
|
||||||
if err != nil || result == nil || !result.OK {
|
if err != nil || result == nil || !result.OK {
|
||||||
h.log().Warn("payment notify failed", notifyLogFields(c.Request.Context(), "leshua", params, contentType, len(body), zap.Error(err))...)
|
h.log().Warn("支付回调处理失败", notifyLogFields(c.Request.Context(), "leshua", params, contentType, len(body), zap.Error(err))...)
|
||||||
c.String(http.StatusOK, "FAIL")
|
c.String(http.StatusOK, "FAIL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log().Info("payment notify processed", notifyLogFields(c.Request.Context(), "leshua", params, contentType, len(body))...)
|
h.log().Info("支付回调处理完成", notifyLogFields(c.Request.Context(), "leshua", params, contentType, len(body))...)
|
||||||
c.String(http.StatusOK, result.Message)
|
c.String(http.StatusOK, result.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,14 +210,13 @@ func (h *Handler) LakalaNotify(c *gin.Context) {
|
|||||||
rawPayload := string(body)
|
rawPayload := string(body)
|
||||||
contentType := c.GetHeader("Content-Type")
|
contentType := c.GetHeader("Content-Type")
|
||||||
authorization := c.GetHeader("Authorization")
|
authorization := c.GetHeader("Authorization")
|
||||||
h.log().Info("payment notify received", notifyLogFields(c.Request.Context(), "lakala", params, contentType, len(body))...)
|
|
||||||
result, err := h.service.HandleNotify(c.Request.Context(), "lakala", params, rawPayload, contentType, authorization)
|
result, err := h.service.HandleNotify(c.Request.Context(), "lakala", params, rawPayload, contentType, authorization)
|
||||||
if err != nil || result == nil || !result.OK {
|
if err != nil || result == nil || !result.OK {
|
||||||
h.log().Warn("payment notify failed", notifyLogFields(c.Request.Context(), "lakala", params, contentType, len(body), zap.Error(err))...)
|
h.log().Warn("支付回调处理失败", notifyLogFields(c.Request.Context(), "lakala", params, contentType, len(body), zap.Error(err))...)
|
||||||
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "失败"})
|
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log().Info("payment notify processed", notifyLogFields(c.Request.Context(), "lakala", params, contentType, len(body))...)
|
h.log().Info("支付回调处理完成", notifyLogFields(c.Request.Context(), "lakala", params, contentType, len(body))...)
|
||||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "执行成功"})
|
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "执行成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,14 +234,13 @@ func (h *Handler) ShunchengNotify(c *gin.Context) {
|
|||||||
rawPayload := string(body)
|
rawPayload := string(body)
|
||||||
contentType := c.GetHeader("Content-Type")
|
contentType := c.GetHeader("Content-Type")
|
||||||
authorization := c.GetHeader("Authorization")
|
authorization := c.GetHeader("Authorization")
|
||||||
h.log().Info("payment notify received", notifyLogFields(c.Request.Context(), "shuncheng", params, contentType, len(body))...)
|
|
||||||
result, err := h.service.HandleNotify(c.Request.Context(), "shuncheng", params, rawPayload, contentType, authorization)
|
result, err := h.service.HandleNotify(c.Request.Context(), "shuncheng", params, rawPayload, contentType, authorization)
|
||||||
if err != nil || result == nil || !result.OK {
|
if err != nil || result == nil || !result.OK {
|
||||||
h.log().Warn("payment notify failed", notifyLogFields(c.Request.Context(), "shuncheng", params, contentType, len(body), zap.Error(err))...)
|
h.log().Warn("支付回调处理失败", notifyLogFields(c.Request.Context(), "shuncheng", params, contentType, len(body), zap.Error(err))...)
|
||||||
c.String(http.StatusOK, "FAIL")
|
c.String(http.StatusOK, "FAIL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log().Info("payment notify processed", notifyLogFields(c.Request.Context(), "shuncheng", params, contentType, len(body))...)
|
h.log().Info("支付回调处理完成", notifyLogFields(c.Request.Context(), "shuncheng", params, contentType, len(body))...)
|
||||||
c.String(http.StatusOK, result.Message)
|
c.String(http.StatusOK, result.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +292,7 @@ func parseAdminPaymentQuery(c *gin.Context) (AdminPaymentQuery, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writePaymentError(c *gin.Context, err error) {
|
func writePaymentError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin
|
|||||||
return nil, ErrPaymentUnavailable
|
return nil, ErrPaymentUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
r.log().Info("mohong payment start", paymentLogFields(ctx, appendFields(
|
|
||||||
paymentOrderFields(payment),
|
|
||||||
runtimeConfigFields(runtimeConfig),
|
|
||||||
)...,
|
|
||||||
)...)
|
|
||||||
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
||||||
ThirdOrderID: payment.ThirdOrderID,
|
ThirdOrderID: payment.ThirdOrderID,
|
||||||
AmountCent: payment.AmountCent,
|
AmountCent: payment.AmountCent,
|
||||||
@@ -78,7 +73,7 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||||
r.log().Warn("mohong payment request failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("魔哄支付下单失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{zap.Error(err)},
|
[]zap.Field{zap.Error(err)},
|
||||||
@@ -107,6 +102,11 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
||||||
|
r.log().Info("魔哄支付下单完成", paymentLogFields(ctx, appendFields(
|
||||||
|
paymentOrderFields(latest),
|
||||||
|
runtimeConfigFields(runtimeConfig),
|
||||||
|
)...,
|
||||||
|
)...)
|
||||||
dto := toDTO(*latest)
|
dto := toDTO(*latest)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package payment
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
@@ -33,7 +37,7 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
|
|||||||
|
|
||||||
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
||||||
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil {
|
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil {
|
||||||
r.log().Warn("payment notify diagnostic save failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
@@ -116,20 +120,18 @@ func (r *Repository) verifyNotify(ctx context.Context, payment *model.PaymentOrd
|
|||||||
}
|
}
|
||||||
verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization)
|
verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization)
|
||||||
if err != nil || !verify.OK {
|
if err != nil || !verify.OK {
|
||||||
r.log().Warn("payment notify verify failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付回调验签失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
zap.String("sign_got", verify.Got),
|
zap.Bool("signature_present", verify.Got != ""),
|
||||||
zap.String("sign_expected", firstNonEmpty(verify.Expected["notify_key"], verify.Expected["notify_cert"], verify.Expected["error"])),
|
|
||||||
zap.Strings("param_keys", verify.ParamKeys),
|
zap.Strings("param_keys", verify.ParamKeys),
|
||||||
zap.String("sign_base_string", firstNonEmpty(verify.BaseString["notify_key"], verify.BaseString["notify_cert"])),
|
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
},
|
},
|
||||||
)...,
|
)...,
|
||||||
)...)
|
)...)
|
||||||
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil {
|
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil {
|
||||||
r.log().Warn("payment notify diagnostic save failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
@@ -141,12 +143,6 @@ func (r *Repository) verifyNotify(ctx context.Context, payment *model.PaymentOrd
|
|||||||
}
|
}
|
||||||
return verify, ErrPaymentVerifyFailed
|
return verify, ErrPaymentVerifyFailed
|
||||||
}
|
}
|
||||||
r.log().Info("payment notify verified", paymentLogFields(ctx, appendFields(
|
|
||||||
paymentOrderFields(payment),
|
|
||||||
runtimeConfigFields(runtimeConfig),
|
|
||||||
[]zap.Field{zap.String("matched_key", verify.MatchedKey)},
|
|
||||||
)...,
|
|
||||||
)...)
|
|
||||||
return verify, nil
|
return verify, nil
|
||||||
}
|
}
|
||||||
func (r *Repository) recordNotifyDiagnostic(ctx context.Context, paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error {
|
func (r *Repository) recordNotifyDiagnostic(ctx context.Context, paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error {
|
||||||
@@ -159,13 +155,41 @@ func (r *Repository) recordNotifyDiagnostic(ctx context.Context, paymentID uint6
|
|||||||
Update("raw_response", jsonMap(raw)).Error
|
Update("raw_response", jsonMap(raw)).Error
|
||||||
}
|
}
|
||||||
func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string {
|
func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string {
|
||||||
raw := withRawSource(params, channelSourceNotify)
|
raw := withRawSource(redactNotifyParams(params), channelSourceNotify)
|
||||||
raw["_notify_diagnostic_status"] = status
|
raw["_notify_diagnostic_status"] = status
|
||||||
raw["_raw_payload"] = rawPayload
|
|
||||||
raw["_raw_content_type"] = contentType
|
raw["_raw_content_type"] = contentType
|
||||||
raw["_sign_got"] = verify.Got
|
raw["_raw_payload_size"] = strconv.Itoa(len(rawPayload))
|
||||||
|
raw["_raw_payload_sha256"] = shortDigest(rawPayload)
|
||||||
|
raw["_signature_present"] = strconv.FormatBool(verify.Got != "")
|
||||||
raw["_sign_matched_key"] = verify.MatchedKey
|
raw["_sign_matched_key"] = verify.MatchedKey
|
||||||
raw["_sign_expected"] = jsonString(verify.Expected)
|
raw["_sign_param_keys"] = strings.Join(verify.ParamKeys, ",")
|
||||||
raw["_sign_base_strings"] = jsonString(verify.BaseString)
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redactNotifyParams(params map[string]string) map[string]string {
|
||||||
|
allowed := map[string]bool{
|
||||||
|
"service": true, "merchant_id": true, "third_order_id": true,
|
||||||
|
"provider_order_id": true, "leshua_order_id": true, "schc_order_id": true,
|
||||||
|
"merchant_refund_id": true, "provider_refund_id": true,
|
||||||
|
"leshua_refund_id": true, "schc_refund_id": true,
|
||||||
|
"status": true, "amount": true, "refund_amount": true,
|
||||||
|
"pay_time": true, "refund_time": true,
|
||||||
|
"code": true, "resp_code": true, "result_code": true, "error_code": true,
|
||||||
|
}
|
||||||
|
redacted := make(map[string]string, len(allowed))
|
||||||
|
for key, value := range params {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
if allowed[lowerKey] {
|
||||||
|
redacted[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortDigest(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
return nil, ErrPaymentUnavailable
|
return nil, ErrPaymentUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
r.log().Info("payment start", paymentLogFields(ctx, appendFields(
|
|
||||||
paymentOrderFields(payment),
|
|
||||||
runtimeConfigFields(runtimeConfig),
|
|
||||||
)...,
|
|
||||||
)...)
|
|
||||||
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
||||||
ThirdOrderID: payment.ThirdOrderID,
|
ThirdOrderID: payment.ThirdOrderID,
|
||||||
AmountCent: payment.AmountCent,
|
AmountCent: payment.AmountCent,
|
||||||
@@ -78,7 +73,7 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||||
r.log().Warn("payment request failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付下单请求失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{zap.Error(err)},
|
[]zap.Field{zap.Error(err)},
|
||||||
@@ -88,7 +83,7 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
}
|
}
|
||||||
if !resp.OK {
|
if !resp.OK {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage)
|
_ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage)
|
||||||
r.log().Warn("payment rejected", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付下单被渠道拒绝", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
@@ -117,7 +112,7 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
||||||
r.log().Info("payment result", paymentLogFields(ctx, appendFields(
|
r.log().Info("支付下单完成", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(latest),
|
paymentOrderFields(latest),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
)...,
|
)...,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
if existing {
|
if existing {
|
||||||
latest, syncErr := r.syncRefundPayment(ctx, refundOrder, refundOrder.Status != "refunded")
|
latest, syncErr := r.syncRefundPayment(ctx, refundOrder, refundOrder.Status != "refunded")
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
r.log().Warn("payment existing refund sync failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("已有退款单同步失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
[]zap.Field{zap.String("biz_type", bizType), zap.Error(syncErr)},
|
[]zap.Field{zap.String("biz_type", bizType), zap.Error(syncErr)},
|
||||||
)...,
|
)...,
|
||||||
@@ -65,19 +65,9 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r.log().Info("payment refund start", paymentLogFields(ctx, appendFields(
|
|
||||||
paymentOrderFields(refundOrder),
|
|
||||||
runtimeConfigFields(runtimeConfig),
|
|
||||||
[]zap.Field{
|
|
||||||
zap.String("merchant_refund_id", refundOrder.ThirdOrderID),
|
|
||||||
zap.String("origin_third_order_id", originalPayment.ThirdOrderID),
|
|
||||||
zap.String("origin_provider_order_id", refundOriginProviderOrderID(originalPayment)),
|
|
||||||
},
|
|
||||||
)...,
|
|
||||||
)...)
|
|
||||||
r.recordConfigUsage(ctx, runtimeConfig, refundOrder)
|
r.recordConfigUsage(ctx, runtimeConfig, refundOrder)
|
||||||
if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil {
|
if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil {
|
||||||
r.log().Warn("payment mark order refunding failed", paymentLogFields(ctx,
|
r.log().Warn("订单退款状态更新失败", paymentLogFields(ctx,
|
||||||
zap.Uint64("order_id", orderID),
|
zap.Uint64("order_id", orderID),
|
||||||
zap.Int64("refund_amount_cent", refundAmountCent),
|
zap.Int64("refund_amount_cent", refundAmountCent),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -86,7 +76,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
|
|
||||||
if runtimeConfig.Channel == nil {
|
if runtimeConfig.Channel == nil {
|
||||||
if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}, nil); err != nil {
|
if err := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}, nil); err != nil {
|
||||||
r.log().Warn("payment mark refund failed status failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款失败状态保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
[]zap.Field{zap.Error(err)},
|
[]zap.Field{zap.Error(err)},
|
||||||
)...,
|
)...,
|
||||||
@@ -109,13 +99,13 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
rawRequest = resp.RawRequest
|
rawRequest = resp.RawRequest
|
||||||
}
|
}
|
||||||
if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}, rawRequest); markErr != nil {
|
if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}, rawRequest); markErr != nil {
|
||||||
r.log().Warn("payment mark refund failed status failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款失败状态保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
[]zap.Field{zap.Error(markErr)},
|
[]zap.Field{zap.Error(markErr)},
|
||||||
)...,
|
)...,
|
||||||
)...)
|
)...)
|
||||||
}
|
}
|
||||||
r.log().Warn("payment refund request failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款渠道请求失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{zap.Error(err)},
|
[]zap.Field{zap.Error(err)},
|
||||||
@@ -125,13 +115,13 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
}
|
}
|
||||||
if !resp.OK {
|
if !resp.OK {
|
||||||
if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw, resp.RawRequest); markErr != nil {
|
if markErr := r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw, resp.RawRequest); markErr != nil {
|
||||||
r.log().Warn("payment mark refund rejected status failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款拒绝状态保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
[]zap.Field{zap.Error(markErr)},
|
[]zap.Field{zap.Error(markErr)},
|
||||||
)...,
|
)...,
|
||||||
)...)
|
)...)
|
||||||
}
|
}
|
||||||
r.log().Warn("payment refund rejected", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款被渠道拒绝", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
@@ -153,7 +143,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.log().Info("payment refund result", paymentLogFields(ctx, appendFields(
|
r.log().Info("退款提交完成", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(refundOrder),
|
paymentOrderFields(refundOrder),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
[]zap.Field{
|
[]zap.Field{
|
||||||
@@ -308,7 +298,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if resp != nil && resp.RawRequest != nil {
|
if resp != nil && resp.RawRequest != nil {
|
||||||
if updateErr := r.updateRefundRawRequest(ctx, payment.ID, resp.RawRequest); updateErr != nil {
|
if updateErr := r.updateRefundRawRequest(ctx, payment.ID, resp.RawRequest); updateErr != nil {
|
||||||
r.log().Warn("payment refund query raw request update failed", paymentLogFields(ctx, appendFields(
|
r.log().Warn("退款查询诊断保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
[]zap.Field{zap.Error(updateErr)},
|
[]zap.Field{zap.Error(updateErr)},
|
||||||
)...,
|
)...,
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func (r *Repository) recordConfigUsage(ctx context.Context, runtimeConfig *runti
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.configRepo.RecordUsage(ctx, runtimeConfig.ID, payment.ID, runtimeConfig.Provider, runtimeConfig.MerchantID, payment.AmountCent, payment.BizType); err != nil {
|
if err := r.configRepo.RecordUsage(ctx, runtimeConfig.ID, payment.ID, runtimeConfig.Provider, runtimeConfig.MerchantID, payment.AmountCent, payment.BizType); err != nil {
|
||||||
r.log().Warn("payment config usage record failed", paymentLogFields(ctx,
|
r.log().Warn("支付配置使用记录保存失败", paymentLogFields(ctx,
|
||||||
zap.Uint64("payment_config_id", runtimeConfig.ID),
|
zap.Uint64("payment_config_id", runtimeConfig.ID),
|
||||||
zap.Uint64("payment_id", payment.ID),
|
zap.Uint64("payment_id", payment.ID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch err {
|
switch err {
|
||||||
case ErrAccountNotFound:
|
case ErrAccountNotFound:
|
||||||
response.NotFound(c, "收款账号不存在")
|
response.NotFound(c, "收款账号不存在")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := h.service.List(c.Request.Context(), query)
|
resp, err := h.service.List(c.Request.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "查询失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "查询失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,6 +79,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "查询失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "查询失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,16 +101,19 @@ func (h *Handler) ExportBackup(c *gin.Context) {
|
|||||||
|
|
||||||
backup, err := h.service.ExportBackup(c.Request.Context(), adminID, auditMeta(c))
|
backup, err := h.service.ExportBackup(c.Request.Context(), adminID, auditMeta(c))
|
||||||
if err == ErrDecryptionFailed {
|
if err == ErrDecryptionFailed {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "decrypt_failed", "密钥解密失败")
|
response.Error(c, http.StatusInternalServerError, "decrypt_failed", "密钥解密失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "导出失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "导出失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(backup, "", " ")
|
data, err := json.MarshalIndent(backup, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "导出失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "导出失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,10 +153,12 @@ func (h *Handler) ImportBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == ErrEncryptionFailed {
|
if err == ErrEncryptionFailed {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "encrypt_failed", "密钥加密失败")
|
response.Error(c, http.StatusInternalServerError, "encrypt_failed", "密钥加密失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "导入失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "导入失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -187,6 +194,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "创建失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "创建失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -230,6 +238,7 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "更新失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "更新失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -266,6 +275,7 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
response.RecordError(c, err)
|
||||||
response.Error(c, http.StatusInternalServerError, "internal_error", "删除失败")
|
response.Error(c, http.StatusInternalServerError, "internal_error", "删除失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ func (h *Handler) ListForSeller(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writePickupError(c *gin.Context, err error) {
|
func writePickupError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeRealnameError(c *gin.Context, err error) {
|
func writeRealnameError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -61,13 +62,17 @@ func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo st
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.log != nil {
|
if s.log != nil {
|
||||||
s.log.Warn(
|
fields := []zap.Field{
|
||||||
"realname provider verify failed",
|
zap.String("module", "realname"),
|
||||||
zap.String("provider", s.provider.Name()),
|
zap.String("provider", s.provider.Name()),
|
||||||
zap.Uint64("user_id", userID),
|
zap.Uint64("user_id", userID),
|
||||||
zap.String("masked_id_no", maskIDNo(idNo)),
|
zap.String("masked_id_no", maskIDNo(idNo)),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
}
|
||||||
|
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
|
||||||
|
fields = append(fields, zap.String("request_id", requestID))
|
||||||
|
}
|
||||||
|
s.log.Warn("实名认证服务调用失败", fields...)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrInvalidGroup):
|
case errors.Is(err, ErrInvalidGroup):
|
||||||
response.BadRequest(c, "客服分组信息不正确")
|
response.BadRequest(c, "客服分组信息不正确")
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeConfigError(c *gin.Context, err error) {
|
func writeConfigError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeWalletError(c *gin.Context, err error) {
|
func writeWalletError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(c *gin.Context, err error) {
|
func writeError(c *gin.Context, err error) {
|
||||||
|
response.RecordError(c, err)
|
||||||
switch err {
|
switch err {
|
||||||
case ErrWithdrawalNotFound:
|
case ErrWithdrawalNotFound:
|
||||||
response.NotFound(c, "提现申请不存在")
|
response.NotFound(c, "提现申请不存在")
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
encryptor, err := crypto.NewFieldEncryptor(primary, legacy)
|
encryptor, err := crypto.NewFieldEncryptor(primary, legacy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if config.IsProductionEnv(cfg.AppEnv) {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("FIELD_ENCRYPTION_KEY invalid", zap.Error(err))
|
logger.Fatal("业务字段加密密钥无效", zap.Error(err))
|
||||||
}
|
}
|
||||||
logger.Warn("FIELD_ENCRYPTION_KEY invalid, fallback to legacy-only", zap.Error(err))
|
logger.Warn("业务字段加密密钥无效,已回退旧密钥", zap.Error(err))
|
||||||
// primary 非法时退回 legacy 单密钥(若有),否则 MockEncryptor
|
// primary 非法时退回 legacy 单密钥(若有),否则 MockEncryptor
|
||||||
if legacy != "" {
|
if legacy != "" {
|
||||||
if e, eErr := crypto.NewFieldEncryptor(legacy); eErr == nil {
|
if e, eErr := crypto.NewFieldEncryptor(legacy); eErr == nil {
|
||||||
@@ -100,15 +100,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
encryptor, err := crypto.NewFieldEncryptor(legacy)
|
encryptor, err := crypto.NewFieldEncryptor(legacy)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fieldEncryptor = encryptor
|
fieldEncryptor = encryptor
|
||||||
logger.Warn("FIELD_ENCRYPTION_KEY not set, using legacy-only encryptor for dev compatibility")
|
logger.Debug("开发环境使用旧业务字段加密密钥")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fieldEncryptor == nil {
|
if fieldEncryptor == nil {
|
||||||
if config.IsProductionEnv(cfg.AppEnv) {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("FIELD_ENCRYPTION_KEY not set")
|
logger.Fatal("业务字段加密密钥未设置")
|
||||||
}
|
}
|
||||||
fieldEncryptor = &crypto.MockEncryptor{}
|
fieldEncryptor = &crypto.MockEncryptor{}
|
||||||
logger.Warn("FIELD_ENCRYPTION_KEY and legacy both unset, using MockEncryptor")
|
logger.Debug("开发环境使用模拟业务字段加密器")
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
||||||
@@ -245,10 +245,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
if config.IsProductionEnv(cfg.AppEnv) {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
logger.Fatal("支付配置加密密钥未设置或无效")
|
||||||
}
|
}
|
||||||
encryptor = &paymentconfig.MockEncryptor{}
|
encryptor = &paymentconfig.MockEncryptor{}
|
||||||
logger.Warn("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid, using MockEncryptor")
|
logger.Debug("开发环境使用模拟支付配置加密器")
|
||||||
}
|
}
|
||||||
paymentConfigRepo = paymentconfig.NewRepository(deps.DB, encryptor)
|
paymentConfigRepo = paymentconfig.NewRepository(deps.DB, encryptor)
|
||||||
paymentConfigService = paymentconfig.NewService(paymentConfigRepo)
|
paymentConfigService = paymentconfig.NewService(paymentConfigRepo)
|
||||||
@@ -331,7 +331,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
var err error
|
var err error
|
||||||
fileStorage, err = filemodule.NewStorage(cfg.Storage)
|
fileStorage, err = filemodule.NewStorage(cfg.Storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("file storage bucket is not ready; file APIs will retry on request", zap.Error(err))
|
logger.Warn("文件存储桶尚未就绪,接口请求时将重试", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileService := filemodule.NewService(fileStorage)
|
fileService := filemodule.NewService(fileStorage)
|
||||||
@@ -766,7 +766,7 @@ func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provid
|
|||||||
LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode,
|
LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("aliyun sms provider unavailable; sms send will fail", zap.Error(err))
|
logger.Warn("阿里云短信服务初始化失败", zap.Error(err))
|
||||||
return smsintegration.NewUnavailableProvider(err)
|
return smsintegration.NewUnavailableProvider(err)
|
||||||
}
|
}
|
||||||
return provider
|
return provider
|
||||||
@@ -784,7 +784,7 @@ func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *
|
|||||||
Encryptor: encryptor,
|
Encryptor: encryptor,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("cloud market realname provider unavailable; realname verify will fail", zap.Error(err))
|
logger.Warn("实名认证服务初始化失败", zap.Error(err))
|
||||||
return realname.NewUnavailableProvider(err)
|
return realname.NewUnavailableProvider(err)
|
||||||
}
|
}
|
||||||
return provider
|
return provider
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const contextResponseCode = "response_code"
|
||||||
|
|
||||||
type Body struct {
|
type Body struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -29,12 +33,39 @@ func Created(c *gin.Context, data any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Error(c *gin.Context, status int, code, message string) {
|
func Error(c *gin.Context, status int, code, message string) {
|
||||||
|
if status >= http.StatusInternalServerError && len(c.Errors) == 0 {
|
||||||
|
RecordError(c, fmt.Errorf("未记录原始错误: %s", code))
|
||||||
|
}
|
||||||
|
c.Set(contextResponseCode, code)
|
||||||
c.JSON(status, Body{
|
c.JSON(status, Body{
|
||||||
Code: code,
|
Code: code,
|
||||||
Message: message,
|
Message: message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordError 保存原始错误,供统一请求日志记录服务端故障原因。
|
||||||
|
func RecordError(c *gin.Context, err error) {
|
||||||
|
if c == nil || err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range c.Errors {
|
||||||
|
if errors.Is(item.Err, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = c.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeFromContext 返回已经写入响应的稳定业务错误码。
|
||||||
|
func CodeFromContext(c *gin.Context) string {
|
||||||
|
value, ok := c.Get(contextResponseCode)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code, _ := value.(string)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
func BadRequest(c *gin.Context, message string) {
|
func BadRequest(c *gin.Context, message string) {
|
||||||
Error(c, http.StatusBadRequest, "bad_request", message)
|
Error(c, http.StatusBadRequest, "bad_request", message)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user