统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// Package logging supplies readable console output and rotating structured files.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Directory, Level string
|
||||
MaxSizeMB, MaxBackups, MaxAgeDays int
|
||||
}
|
||||
|
||||
func New(o Options) (*zap.Logger, func(), error) {
|
||||
level, err := zapcore.ParseLevel(o.Level)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid LOG_LEVEL")
|
||||
}
|
||||
if o.Directory == "" || o.MaxSizeMB < 1 || o.MaxBackups < 1 || o.MaxAgeDays < 1 {
|
||||
return nil, nil, fmt.Errorf("invalid logging configuration")
|
||||
}
|
||||
if err := os.MkdirAll(o.Directory, 0750); err != nil {
|
||||
return nil, nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(o.Directory, "veyra.log")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return nil, nil, fmt.Errorf("close initial log file: %w", err)
|
||||
}
|
||||
file := &lumberjack.Logger{Filename: path, MaxSize: o.MaxSizeMB, MaxBackups: o.MaxBackups, MaxAge: o.MaxAgeDays, Compress: true, LocalTime: true}
|
||||
encoder := zap.NewProductionEncoderConfig()
|
||||
encoder.TimeKey = "time"
|
||||
encoder.MessageKey = "message"
|
||||
encoder.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
console := encoder
|
||||
console.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
core := zapcore.NewTee(
|
||||
zapcore.NewCore(zapcore.NewConsoleEncoder(console), zapcore.Lock(zapcore.AddSync(os.Stdout)), level),
|
||||
zapcore.NewCore(zapcore.NewJSONEncoder(encoder), zapcore.AddSync(file), level),
|
||||
)
|
||||
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.DPanicLevel))
|
||||
return logger, func() { _ = logger.Sync(); _ = file.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFileOutputAndLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logger, closeLogs, err := New(Options{dir, "warn", 1, 2, 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger.Info("filtered")
|
||||
logger.Warn("request rejected", zap.String("request_id", "test-request"), zap.Int("status", 409))
|
||||
closeLogs()
|
||||
data, err := os.ReadFile(filepath.Join(dir, "veyra.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row struct {
|
||||
Level, Message, RequestID string
|
||||
Status int
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.Unmarshal(fields["level"], &row.Level)
|
||||
_ = json.Unmarshal(fields["message"], &row.Message)
|
||||
_ = json.Unmarshal(fields["request_id"], &row.RequestID)
|
||||
_ = json.Unmarshal(fields["status"], &row.Status)
|
||||
if row.Level != "warn" || row.Message != "request rejected" || row.RequestID != "test-request" || row.Status != 409 || strings.Contains(string(data), "filtered") {
|
||||
t.Fatalf("invalid structured log: %s", data)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "veyra.log"))
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("log file must be private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stdout := os.Stdout
|
||||
discard, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer discard.Close()
|
||||
os.Stdout = discard
|
||||
logger, closeLogs, err := New(Options{dir, "info", 1, 2, 1})
|
||||
os.Stdout = stdout
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeLogs()
|
||||
for i := 0; i < 3; i++ {
|
||||
logger.Info("rotation", zap.String("payload", strings.Repeat("x", 400000)))
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "veyra-") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("exceeding size must rotate the log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotOpenLogs(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := New(Options{path, "info", 1, 1, 1}); err == nil {
|
||||
t.Fatal("unwritable log directory must fail startup")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user