chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package logging
import (
"context"
"go.uber.org/zap"
)
type requestIDContextKey struct{}
type adminIDContextKey struct{}
// WithRequestID 把请求 ID 写入标准 context,供非 HTTP 层日志关联请求链路。
func WithRequestID(ctx context.Context, requestID string) context.Context {
if ctx == nil || requestID == "" {
return ctx
}
return context.WithValue(ctx, requestIDContextKey{}, requestID)
}
// RequestIDFromContext 从标准 context 读取请求 ID。
func RequestIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
value, ok := ctx.Value(requestIDContextKey{}).(string)
if !ok {
return ""
}
return value
}
// WithAdminID 把已完成鉴权的管理员 ID 写入标准 context,供仓储审计和日志关联。
func WithAdminID(ctx context.Context, adminID uint64) context.Context {
if ctx == nil || adminID == 0 {
return ctx
}
return context.WithValue(ctx, adminIDContextKey{}, adminID)
}
// AdminIDFromContext 从标准 context 读取已完成鉴权的管理员 ID。
func AdminIDFromContext(ctx context.Context) uint64 {
if ctx == nil {
return 0
}
adminID, _ := ctx.Value(adminIDContextKey{}).(uint64)
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...)
}
+248
View File
@@ -0,0 +1,248 @@
package logging
import (
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type dailyWriter struct {
mu sync.Mutex
dir string
prefix string
location *time.Location
retainDays int
day string
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 {
if retainDays < 0 {
retainDays = 0
}
return &dailyWriter{
dir: dir,
prefix: prefix,
location: location,
retainDays: retainDays,
}
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotateIfNeeded(time.Now().In(w.location)); err != nil {
return 0, err
}
return w.file.Write(p)
}
func (w *dailyWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
return w.file.Sync()
}
func (w *dailyWriter) rotateIfNeeded(now time.Time) error {
day := now.Format("2006-01-02")
if w.file != nil && w.day == day {
return nil
}
if err := os.MkdirAll(w.dir, 0o700); err != nil {
return err
}
if err := os.Chmod(w.dir, 0o700); err != nil {
return err
}
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
w.file = nil
}
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, 0o600)
if err != nil {
return err
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return err
}
w.file = file
w.day = day
// 异步补压缩全部历史日志并清理过期文件,避免漏掉停机期间的日期。
go w.maintain(now)
return nil
}
func (w *dailyWriter) maintain(now time.Time) {
if err := w.compressHistorical(now); err != nil {
writeMaintenanceError(err)
}
if w.retainDays > 0 {
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 {
srcPath := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
dstPath := srcPath + ".gz"
if _, err := os.Stat(dstPath); err == nil {
if err := validateGzip(dstPath); err == nil {
if err := os.Remove(srcPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.Remove(dstPath); err != nil {
return err
}
}
src, err := os.Open(srcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer src.Close()
dst, err := os.CreateTemp(w.dir, "."+filepath.Base(dstPath)+".tmp-*")
if err != nil {
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)
if _, err := io.Copy(gz, src); err != nil {
_ = gz.Close()
_ = dst.Close()
return err
}
if err := gz.Close(); err != nil {
_ = dst.Close()
return err
}
if err := dst.Sync(); err != nil {
_ = dst.Close()
return err
}
if err := dst.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return err
}
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 {
// 按「日历天」比较:保留最近 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)
cutoff := today.AddDate(0, 0, -(w.retainDays - 1))
entries, err := os.ReadDir(w.dir)
if err != nil {
return err
}
prefix := w.prefix + "-"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, prefix) ||
(!strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".log.gz")) {
continue
}
rest := strings.TrimPrefix(name, prefix)
rest = strings.TrimSuffix(rest, ".gz")
rest = strings.TrimSuffix(rest, ".log")
day, err := time.ParseInLocation("2006-01-02", rest, w.location)
if err != nil {
continue
}
if day.Before(cutoff) {
if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
}
}
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())
}
@@ -0,0 +1,89 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCompressDayAndPurge(t *testing.T) {
dir := t.TempDir()
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
w := newDailyWriter(dir, "app", loc, 2)
// 准备三天明文日志
days := []string{"2026-07-01", "2026-07-10", "2026-07-11"}
for _, day := range days {
path := filepath.Join(dir, "app-"+day+".log")
if err := os.WriteFile(path, []byte(`{"msg":"hello `+day+`"}`+"\n"), 0o644); err != nil {
t.Fatalf("write log: %v", err)
}
}
// 压缩 7-10
if err := w.compressDay("2026-07-10"); err != nil {
t.Fatalf("compressDay: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log")); !os.IsNotExist(err) {
t.Fatalf("plain log should be removed after gzip, err=%v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); err != nil {
t.Fatalf("gzip missing: %v", err)
}
// 以 7-12 为「现在」,保留 2 天(7-11、7-12),删除 7-11 之前。
now := time.Date(2026, 7, 12, 12, 0, 0, 0, loc)
if err := w.purgeOlderThan(now); err != nil {
t.Fatalf("purgeOlderThan: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-01.log")); !os.IsNotExist(err) {
t.Fatal("expired log should be purged")
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); !os.IsNotExist(err) {
t.Fatalf("expired gzip should be purged, err=%v", err)
}
// 7-11 仍应存在(cutoff 当天起保留)
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-11.log")); err != nil {
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) {
dir := t.TempDir()
loc := time.UTC
w := newDailyWriter(dir, "app", loc, 0)
path := filepath.Join(dir, "app-2026-07-11.log")
if err := os.WriteFile(path, []byte("line\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := w.compressDay("2026-07-11"); err != nil {
t.Fatal(err)
}
if err := w.compressDay("2026-07-11"); err != nil {
t.Fatalf("second compress should be no-op: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package logging
import (
"fmt"
"os"
"strings"
"time"
"hfb_sys/backend/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg config.LogConfig) (*zap.Logger, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("Asia/Shanghai", 8*60*60)
}
level := zap.NewAtomicLevelAt(parseLevel(cfg.Level))
cores := make([]zapcore.Core, 0, 2)
if cfg.EnableConsole {
cores = append(cores, zapcore.NewCore(
newTextEncoder(location),
zapcore.Lock(os.Stdout),
level,
))
}
if cfg.EnableFile {
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(
newTextEncoder(location),
writer,
level,
))
}
if len(cores) == 0 {
cores = append(cores, zapcore.NewCore(
newTextEncoder(location),
zapcore.Lock(os.Stdout),
level,
))
}
logger := zap.New(
zapcore.NewTee(cores...),
zap.AddCaller(),
zap.AddStacktrace(zapcore.PanicLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
zap.ReplaceGlobals(logger)
return logger, nil
}
func parseLevel(value string) zapcore.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return zapcore.DebugLevel
case "warn", "warning":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
+128
View File
@@ -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)
}
}