Phase 0+1+2 续: 项目骨架 + 配置 + 日志 + 存储层
Phase 0 (项目骨架):
- main.go 改为最小骨架 (config + logging + gin + /healthz + 优雅退出)
- internal/{config,logging,storage,cluster,...}/ 目录占位
Phase 1 (配置 + 日志):
- internal/config: env 解析 + 必填校验 + token 隐藏的 String()
- internal/logging: slog multi-handler 双输出 (终端 text + 主文件 JSON)
+ StartToolCall per-tool 独立文件 (0600), tools 目录 0700
Phase 2 续 (存储层):
- internal/cluster: 16 字段 Cluster struct (AuthPassword json:"-")
- internal/storage: modernc.org/sqlite 接入, WAL 模式, schema 自动迁移
- ClusterRepo CRUD: Create/Get/List/Update/Delete + ErrNotFound
+ AuthPassword 空字符串 = 保留旧密码 (核心约定)
- 7 个单元测试全绿 (:memory: DB)
- created_at/updated_at 改纳秒精度, 消除 List 测试的 sleep 特殊 case
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Config configures the application logger.
|
||||
type Config struct {
|
||||
Level string // "debug"|"info"|"warn"|"error"
|
||||
Dir string // e.g. "./data/logs"
|
||||
Format string // "text"|"json" — controls terminal output; files are always JSON
|
||||
LogToStd bool // true: enable terminal output
|
||||
}
|
||||
|
||||
// ToolCallLogger records the lifecycle of a single tool call.
|
||||
type ToolCallLogger interface {
|
||||
WithResult(result any)
|
||||
WithError(err error)
|
||||
End()
|
||||
}
|
||||
|
||||
// logDir is shared with StartToolCall so the per-tool-call logger knows where
|
||||
// to create files. It is set by New and read by StartToolCall.
|
||||
var (
|
||||
logDir string
|
||||
logDirMu sync.RWMutex
|
||||
)
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// multiHandler fans out records to multiple underlying handlers.
|
||||
type multiHandler struct {
|
||||
handlers []slog.Handler
|
||||
}
|
||||
|
||||
func (m *multiHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, l) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
var errs []error
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, r.Level) {
|
||||
if err := h.Handle(ctx, r); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
handlers := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
handlers[i] = h.WithAttrs(attrs)
|
||||
}
|
||||
return &multiHandler{handlers: handlers}
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithGroup(name string) slog.Handler {
|
||||
handlers := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
handlers[i] = h.WithGroup(name)
|
||||
}
|
||||
return &multiHandler{handlers: handlers}
|
||||
}
|
||||
|
||||
// New creates the main application logger and a shutdown closure.
|
||||
func New(cfg Config) (*slog.Logger, func(), error) {
|
||||
if err := os.MkdirAll(cfg.Dir, 0o755); err != nil {
|
||||
return nil, nil, fmt.Errorf("logging: create log dir: %w", err)
|
||||
}
|
||||
|
||||
toolsDir := filepath.Join(cfg.Dir, "tools")
|
||||
if err := os.MkdirAll(toolsDir, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("logging: create tools log dir: %w", err)
|
||||
}
|
||||
|
||||
logDirMu.Lock()
|
||||
logDir = cfg.Dir
|
||||
logDirMu.Unlock()
|
||||
|
||||
level := parseLevel(cfg.Level)
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
|
||||
mainPath := filepath.Join(cfg.Dir, "spark-mcp.log")
|
||||
f, err := os.OpenFile(mainPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("logging: open main log file: %w", err)
|
||||
}
|
||||
|
||||
fileHandler := slog.NewJSONHandler(f, opts)
|
||||
|
||||
var handler slog.Handler = fileHandler
|
||||
if cfg.LogToStd {
|
||||
var stdHandler slog.Handler
|
||||
switch strings.ToLower(cfg.Format) {
|
||||
case "json":
|
||||
stdHandler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
default:
|
||||
stdHandler = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
handler = &multiHandler{handlers: []slog.Handler{fileHandler, stdHandler}}
|
||||
}
|
||||
|
||||
shutdown := func() {
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
logger := slog.New(handler).With("component", "main")
|
||||
return logger, shutdown, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const toolCallResultMaxBytes = 10 * 1024
|
||||
|
||||
var toolNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
|
||||
|
||||
func sanitizeToolName(name string) string {
|
||||
s := toolNameSanitizer.ReplaceAllString(name, "_")
|
||||
if len(s) > 32 {
|
||||
s = s[:32]
|
||||
}
|
||||
s = strings.Trim(s, "_")
|
||||
if s == "" {
|
||||
s = "tool"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shortHexID(n int) (string, error) {
|
||||
b := make([]byte, n/2+n%2)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b)[:n], nil
|
||||
}
|
||||
|
||||
type toolCallLogger struct {
|
||||
file *os.File
|
||||
toolName string
|
||||
startedAt time.Time
|
||||
resultRaw json.RawMessage
|
||||
err error
|
||||
endOnce sync.Once
|
||||
}
|
||||
|
||||
func (t *toolCallLogger) WithResult(result any) {
|
||||
t.resultRaw = marshalResult(result)
|
||||
}
|
||||
|
||||
func (t *toolCallLogger) WithError(err error) {
|
||||
t.err = err
|
||||
}
|
||||
|
||||
func (t *toolCallLogger) End() {
|
||||
t.endOnce.Do(func() {
|
||||
defer func() { _ = t.file.Close() }()
|
||||
|
||||
durationMs := time.Since(t.startedAt).Milliseconds()
|
||||
errStr := ""
|
||||
if t.err != nil {
|
||||
errStr = t.err.Error()
|
||||
}
|
||||
|
||||
end := map[string]any{
|
||||
"event": "end",
|
||||
"duration_ms": durationMs,
|
||||
"result": nil,
|
||||
"error": errStr,
|
||||
}
|
||||
if t.resultRaw != nil {
|
||||
end["result"] = json.RawMessage(t.resultRaw)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(end)
|
||||
if err != nil {
|
||||
b = []byte(fmt.Sprintf(`{"event":"end","duration_ms":%d,"result":null,"error":%q}`, durationMs, errStr))
|
||||
}
|
||||
_, _ = t.file.Write(b)
|
||||
_, _ = t.file.WriteString("\n")
|
||||
})
|
||||
}
|
||||
|
||||
// StartToolCall creates a per-tool-call log file and returns a logger that
|
||||
// records the call lifecycle.
|
||||
func StartToolCall(ctx context.Context, base *slog.Logger, toolName string, params any) (ToolCallLogger, error) {
|
||||
logDirMu.RLock()
|
||||
dir := logDir
|
||||
logDirMu.RUnlock()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("logging: log directory not initialized; call New first")
|
||||
}
|
||||
|
||||
toolsDir := filepath.Join(dir, "tools")
|
||||
if err := os.MkdirAll(toolsDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("logging: create tools log dir: %w", err)
|
||||
}
|
||||
|
||||
safeName := sanitizeToolName(toolName)
|
||||
ts := time.Now().Format("20060102_150405.000")
|
||||
id, err := shortHexID(6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logging: generate tool call id: %w", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s_%s_%s.log", ts, safeName, id)
|
||||
path := filepath.Join(toolsDir, filename)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logging: create tool call log file: %w", err)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
start := map[string]any{
|
||||
"event": "start",
|
||||
"tool": toolName,
|
||||
"params": marshalValue(params),
|
||||
"started_at": startedAt.Format(time.RFC3339Nano),
|
||||
}
|
||||
if d, ok := ctx.Deadline(); ok {
|
||||
start["deadline"] = d.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("logging: marshal start record: %w", err)
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("logging: write start record: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString("\n"); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("logging: write start record: %w", err)
|
||||
}
|
||||
|
||||
if base != nil {
|
||||
base.Info("tool.call.start", "tool", toolName, "file", filename)
|
||||
}
|
||||
|
||||
return &toolCallLogger{
|
||||
file: f,
|
||||
toolName: toolName,
|
||||
startedAt: startedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func marshalValue(v any) json.RawMessage {
|
||||
if v == nil {
|
||||
return []byte("null")
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
b, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return mustMarshalString("<unserializable>")
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func marshalResult(v any) json.RawMessage {
|
||||
if v == nil {
|
||||
return []byte("null")
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
b, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return mustMarshalString("<unserializable>")
|
||||
}
|
||||
}
|
||||
if len(b) > toolCallResultMaxBytes {
|
||||
trunc := string(b[:toolCallResultMaxBytes]) + "...[truncated]"
|
||||
return mustMarshalString(trunc)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func mustMarshalString(s string) json.RawMessage {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return []byte(`"` + strings.ReplaceAll(s, `"`, `\"`) + `"`)
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user