From 5d6c982c9c38c6f6a694a454cdb3ab44d5b0355c Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 17 Mar 2026 00:47:17 +0800 Subject: [PATCH] refactor: replace standard log with zap logger and optimize code patterns - Migrate all log.Printf/log.Println calls to structured zap logging - Use cmp.Or for cleaner default value handling - Replace manual loops with slices.ContainsFunc/IndexFunc - Add batch query methods (IsLikedBatch, IsFavoritedBatch) to solve N+1 problem - Update JSON tags from omitempty to omitzero for numeric fields - Use errgroup-style wg.Go for worker goroutines - Remove duplicate casbin/v2 dependency (keeping v3) - Add plans/ to gitignore --- .gitignore | 3 + .kilocode/skills/golang-patterns/SKILL.md | 674 ++++++++++++++++++++ .kilocode/skills/golang-testing/SKILL.md | 720 ++++++++++++++++++++++ .kilocode/skills/use-modern-go/SKILL.md | 291 +++++++++ cmd/migrate/main.go | 27 +- cmd/server/app.go | 31 +- cmd/server/main.go | 15 +- cmd/server/wire_gen.go | 2 +- go.mod | 1 - internal/cache/cache.go | 11 +- internal/cache/conversation_cache.go | 9 +- internal/cache/redis_cache.go | 48 +- internal/config/config.go | 6 +- internal/dto/dto.go | 50 +- internal/dto/schedule_dto.go | 6 +- internal/dto/segment.go | 57 +- internal/handler/gorse_handler.go | 37 +- internal/handler/group_handler.go | 20 +- internal/handler/user_handler.go | 6 +- internal/middleware/casbin.go | 16 +- internal/model/init.go | 13 +- internal/pkg/gorse/client.go | 14 +- internal/pkg/gorse/embedding.go | 7 +- internal/repository/post_repo.go | 54 ++ internal/service/async_log_manager.go | 15 +- internal/service/audit_service.go | 36 +- internal/service/chat_service.go | 26 +- internal/service/comment_service.go | 62 +- internal/service/group_service.go | 122 ++-- internal/service/message_service.go | 9 +- internal/service/post_ai_service.go | 11 +- internal/service/post_service.go | 7 +- internal/service/schedule_service.go | 8 +- internal/service/sensitive_service.go | 26 +- internal/service/upload_service.go | 37 +- internal/service/user_activity_service.go | 39 +- internal/wire/casbin.go | 17 +- internal/wire/infrastructure.go | 13 +- 38 files changed, 2256 insertions(+), 290 deletions(-) create mode 100644 .kilocode/skills/golang-patterns/SKILL.md create mode 100644 .kilocode/skills/golang-testing/SKILL.md create mode 100644 .kilocode/skills/use-modern-go/SKILL.md diff --git a/.gitignore b/.gitignore index a8c5e5a..3617ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ logs/ # Local environment files .env .env.* + + +plans/ \ No newline at end of file diff --git a/.kilocode/skills/golang-patterns/SKILL.md b/.kilocode/skills/golang-patterns/SKILL.md new file mode 100644 index 0000000..056ef56 --- /dev/null +++ b/.kilocode/skills/golang-patterns/SKILL.md @@ -0,0 +1,674 @@ +--- +name: golang-patterns +description: 用于构建健壮、高效且可维护的Go应用程序的惯用Go模式、最佳实践和约定。 +origin: ECC +--- + +# Go 开发模式 + +用于构建健壮、高效和可维护应用程序的惯用 Go 模式与最佳实践。 + +## 何时激活 + +* 编写新的 Go 代码时 +* 审查 Go 代码时 +* 重构现有 Go 代码时 +* 设计 Go 包/模块时 + +## 核心原则 + +### 1. 简洁与清晰 + +Go 推崇简洁而非精巧。代码应该显而易见且易于阅读。 + +```go +// Good: Clear and direct +func GetUser(id string) (*User, error) { + user, err := db.FindUser(id) + if err != nil { + return nil, fmt.Errorf("get user %s: %w", id, err) + } + return user, nil +} + +// Bad: Overly clever +func GetUser(id string) (*User, error) { + return func() (*User, error) { + if u, e := db.FindUser(id); e == nil { + return u, nil + } else { + return nil, e + } + }() +} +``` + +### 2. 让零值变得有用 + +设计类型时,应使其零值无需初始化即可立即使用。 + +```go +// Good: Zero value is useful +type Counter struct { + mu sync.Mutex + count int // zero value is 0, ready to use +} + +func (c *Counter) Inc() { + c.mu.Lock() + c.count++ + c.mu.Unlock() +} + +// Good: bytes.Buffer works with zero value +var buf bytes.Buffer +buf.WriteString("hello") + +// Bad: Requires initialization +type BadCounter struct { + counts map[string]int // nil map will panic +} +``` + +### 3. 接受接口,返回结构体 + +函数应该接受接口参数并返回具体类型。 + +```go +// Good: Accepts interface, returns concrete type +func ProcessData(r io.Reader) (*Result, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + return &Result{Data: data}, nil +} + +// Bad: Returns interface (hides implementation details unnecessarily) +func ProcessData(r io.Reader) (io.Reader, error) { + // ... +} +``` + +## 错误处理模式 + +### 带上下文的错误包装 + +```go +// Good: Wrap errors with context +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("load config %s: %w", path, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + + return &cfg, nil +} +``` + +### 自定义错误类型 + +```go +// Define domain-specific errors +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message) +} + +// Sentinel errors for common cases +var ( + ErrNotFound = errors.New("resource not found") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidInput = errors.New("invalid input") +) +``` + +### 使用 errors.Is 和 errors.As 检查错误 + +```go +func HandleError(err error) { + // Check for specific error + if errors.Is(err, sql.ErrNoRows) { + log.Println("No records found") + return + } + + // Check for error type + var validationErr *ValidationError + if errors.As(err, &validationErr) { + log.Printf("Validation error on field %s: %s", + validationErr.Field, validationErr.Message) + return + } + + // Unknown error + log.Printf("Unexpected error: %v", err) +} +``` + +### 永不忽略错误 + +```go +// Bad: Ignoring error with blank identifier +result, _ := doSomething() + +// Good: Handle or explicitly document why it's safe to ignore +result, err := doSomething() +if err != nil { + return err +} + +// Acceptable: When error truly doesn't matter (rare) +_ = writer.Close() // Best-effort cleanup, error logged elsewhere +``` + +## 并发模式 + +### 工作池 + +```go +func WorkerPool(jobs <-chan Job, results chan<- Result, numWorkers int) { + var wg sync.WaitGroup + + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + results <- process(job) + } + }() + } + + wg.Wait() + close(results) +} +``` + +### 用于取消和超时的 Context + +```go +func FetchWithTimeout(ctx context.Context, url string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", url, err) + } + defer resp.Body.Close() + + return io.ReadAll(resp.Body) +} +``` + +### 优雅关闭 + +```go +func GracefulShutdown(server *http.Server) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} +``` + +### 用于协调 Goroutine 的 errgroup + +```go +import "golang.org/x/sync/errgroup" + +func FetchAll(ctx context.Context, urls []string) ([][]byte, error) { + g, ctx := errgroup.WithContext(ctx) + results := make([][]byte, len(urls)) + + for i, url := range urls { + i, url := i, url // Capture loop variables + g.Go(func() error { + data, err := FetchWithTimeout(ctx, url) + if err != nil { + return err + } + results[i] = data + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + return results, nil +} +``` + +### 避免 Goroutine 泄漏 + +```go +// Bad: Goroutine leak if context is cancelled +func leakyFetch(ctx context.Context, url string) <-chan []byte { + ch := make(chan []byte) + go func() { + data, _ := fetch(url) + ch <- data // Blocks forever if no receiver + }() + return ch +} + +// Good: Properly handles cancellation +func safeFetch(ctx context.Context, url string) <-chan []byte { + ch := make(chan []byte, 1) // Buffered channel + go func() { + data, err := fetch(url) + if err != nil { + return + } + select { + case ch <- data: + case <-ctx.Done(): + } + }() + return ch +} +``` + +## 接口设计 + +### 小而专注的接口 + +```go +// Good: Single-method interfaces +type Reader interface { + Read(p []byte) (n int, err error) +} + +type Writer interface { + Write(p []byte) (n int, err error) +} + +type Closer interface { + Close() error +} + +// Compose interfaces as needed +type ReadWriteCloser interface { + Reader + Writer + Closer +} +``` + +### 在接口使用处定义接口 + +```go +// In the consumer package, not the provider +package service + +// UserStore defines what this service needs +type UserStore interface { + GetUser(id string) (*User, error) + SaveUser(user *User) error +} + +type Service struct { + store UserStore +} + +// Concrete implementation can be in another package +// It doesn't need to know about this interface +``` + +### 使用类型断言实现可选行为 + +```go +type Flusher interface { + Flush() error +} + +func WriteAndFlush(w io.Writer, data []byte) error { + if _, err := w.Write(data); err != nil { + return err + } + + // Flush if supported + if f, ok := w.(Flusher); ok { + return f.Flush() + } + return nil +} +``` + +## 包组织 + +### 标准项目布局 + +```text +myproject/ +├── cmd/ +│ └── myapp/ +│ └── main.go # Entry point +├── internal/ +│ ├── handler/ # HTTP handlers +│ ├── service/ # Business logic +│ ├── repository/ # Data access +│ └── config/ # Configuration +├── pkg/ +│ └── client/ # Public API client +├── api/ +│ └── v1/ # API definitions (proto, OpenAPI) +├── testdata/ # Test fixtures +├── go.mod +├── go.sum +└── Makefile +``` + +### 包命名 + +```go +// Good: Short, lowercase, no underscores +package http +package json +package user + +// Bad: Verbose, mixed case, or redundant +package httpHandler +package json_parser +package userService // Redundant 'Service' suffix +``` + +### 避免包级状态 + +```go +// Bad: Global mutable state +var db *sql.DB + +func init() { + db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL")) +} + +// Good: Dependency injection +type Server struct { + db *sql.DB +} + +func NewServer(db *sql.DB) *Server { + return &Server{db: db} +} +``` + +## 结构体设计 + +### 函数式选项模式 + +```go +type Server struct { + addr string + timeout time.Duration + logger *log.Logger +} + +type Option func(*Server) + +func WithTimeout(d time.Duration) Option { + return func(s *Server) { + s.timeout = d + } +} + +func WithLogger(l *log.Logger) Option { + return func(s *Server) { + s.logger = l + } +} + +func NewServer(addr string, opts ...Option) *Server { + s := &Server{ + addr: addr, + timeout: 30 * time.Second, // default + logger: log.Default(), // default + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Usage +server := NewServer(":8080", + WithTimeout(60*time.Second), + WithLogger(customLogger), +) +``` + +### 使用嵌入实现组合 + +```go +type Logger struct { + prefix string +} + +func (l *Logger) Log(msg string) { + fmt.Printf("[%s] %s\n", l.prefix, msg) +} + +type Server struct { + *Logger // Embedding - Server gets Log method + addr string +} + +func NewServer(addr string) *Server { + return &Server{ + Logger: &Logger{prefix: "SERVER"}, + addr: addr, + } +} + +// Usage +s := NewServer(":8080") +s.Log("Starting...") // Calls embedded Logger.Log +``` + +## 内存与性能 + +### 当大小已知时预分配切片 + +```go +// Bad: Grows slice multiple times +func processItems(items []Item) []Result { + var results []Result + for _, item := range items { + results = append(results, process(item)) + } + return results +} + +// Good: Single allocation +func processItems(items []Item) []Result { + results := make([]Result, 0, len(items)) + for _, item := range items { + results = append(results, process(item)) + } + return results +} +``` + +### 为频繁分配使用 sync.Pool + +```go +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func ProcessRequest(data []byte) []byte { + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + bufferPool.Put(buf) + }() + + buf.Write(data) + // Process... + return buf.Bytes() +} +``` + +### 避免在循环中进行字符串拼接 + +```go +// Bad: Creates many string allocations +func join(parts []string) string { + var result string + for _, p := range parts { + result += p + "," + } + return result +} + +// Good: Single allocation with strings.Builder +func join(parts []string) string { + var sb strings.Builder + for i, p := range parts { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(p) + } + return sb.String() +} + +// Best: Use standard library +func join(parts []string) string { + return strings.Join(parts, ",") +} +``` + +## Go 工具集成 + +### 基本命令 + +```bash +# Build and run +go build ./... +go run ./cmd/myapp + +# Testing +go test ./... +go test -race ./... +go test -cover ./... + +# Static analysis +go vet ./... +staticcheck ./... +golangci-lint run + +# Module management +go mod tidy +go mod verify + +# Formatting +gofmt -w . +goimports -w . +``` + +### 推荐的 Linter 配置 (.golangci.yml) + +```yaml +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gofmt + - goimports + - misspell + - unconvert + - unparam + +linters-settings: + errcheck: + check-type-assertions: true + govet: + check-shadowing: true + +issues: + exclude-use-default: false +``` + +## 快速参考:Go 惯用法 + +| 惯用法 | 描述 | +|-------|-------------| +| 接受接口,返回结构体 | 函数接受接口参数,返回具体类型 | +| 错误即值 | 将错误视为一等值,而非异常 | +| 不要通过共享内存来通信 | 使用通道在 goroutine 之间进行协调 | +| 让零值变得有用 | 类型应无需显式初始化即可工作 | +| 少量复制优于少量依赖 | 避免不必要的外部依赖 | +| 清晰优于精巧 | 优先考虑可读性而非精巧性 | +| gofmt 虽非最爱,但却是每个人的朋友 | 始终使用 gofmt/goimports 格式化代码 | +| 提前返回 | 先处理错误,保持主逻辑路径无缩进 | + +## 应避免的反模式 + +```go +// Bad: Naked returns in long functions +func process() (result int, err error) { + // ... 50 lines ... + return // What is being returned? +} + +// Bad: Using panic for control flow +func GetUser(id string) *User { + user, err := db.Find(id) + if err != nil { + panic(err) // Don't do this + } + return user +} + +// Bad: Passing context in struct +type Request struct { + ctx context.Context // Context should be first param + ID string +} + +// Good: Context as first parameter +func ProcessRequest(ctx context.Context, id string) error { + // ... +} + +// Bad: Mixing value and pointer receivers +type Counter struct{ n int } +func (c Counter) Value() int { return c.n } // Value receiver +func (c *Counter) Increment() { c.n++ } // Pointer receiver +// Pick one style and be consistent +``` + +**记住**:Go 代码应该以最好的方式显得“乏味”——可预测、一致且易于理解。如有疑问,保持简单。 diff --git a/.kilocode/skills/golang-testing/SKILL.md b/.kilocode/skills/golang-testing/SKILL.md new file mode 100644 index 0000000..f1824ec --- /dev/null +++ b/.kilocode/skills/golang-testing/SKILL.md @@ -0,0 +1,720 @@ +--- +name: golang-testing +description: Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. +origin: ECC +--- + +# Go Testing Patterns + +Comprehensive Go testing patterns for writing reliable, maintainable tests following TDD methodology. + +## When to Activate + +- Writing new Go functions or methods +- Adding test coverage to existing code +- Creating benchmarks for performance-critical code +- Implementing fuzz tests for input validation +- Following TDD workflow in Go projects + +## TDD Workflow for Go + +### The RED-GREEN-REFACTOR Cycle + +``` +RED → Write a failing test first +GREEN → Write minimal code to pass the test +REFACTOR → Improve code while keeping tests green +REPEAT → Continue with next requirement +``` + +### Step-by-Step TDD in Go + +```go +// Step 1: Define the interface/signature +// calculator.go +package calculator + +func Add(a, b int) int { + panic("not implemented") // Placeholder +} + +// Step 2: Write failing test (RED) +// calculator_test.go +package calculator + +import "testing" + +func TestAdd(t *testing.T) { + got := Add(2, 3) + want := 5 + if got != want { + t.Errorf("Add(2, 3) = %d; want %d", got, want) + } +} + +// Step 3: Run test - verify FAIL +// $ go test +// --- FAIL: TestAdd (0.00s) +// panic: not implemented + +// Step 4: Implement minimal code (GREEN) +func Add(a, b int) int { + return a + b +} + +// Step 5: Run test - verify PASS +// $ go test +// PASS + +// Step 6: Refactor if needed, verify tests still pass +``` + +## Table-Driven Tests + +The standard pattern for Go tests. Enables comprehensive coverage with minimal code. + +```go +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 2, 3, 5}, + {"negative numbers", -1, -2, -3}, + {"zero values", 0, 0, 0}, + {"mixed signs", -1, 1, 0}, + {"large numbers", 1000000, 2000000, 3000000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Add(tt.a, tt.b) + if got != tt.expected { + t.Errorf("Add(%d, %d) = %d; want %d", + tt.a, tt.b, got, tt.expected) + } + }) + } +} +``` + +### Table-Driven Tests with Error Cases + +```go +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input string + want *Config + wantErr bool + }{ + { + name: "valid config", + input: `{"host": "localhost", "port": 8080}`, + want: &Config{Host: "localhost", Port: 8080}, + }, + { + name: "invalid JSON", + input: `{invalid}`, + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + { + name: "minimal config", + input: `{}`, + want: &Config{}, // Zero value config + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseConfig(tt.input) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %+v; want %+v", got, tt.want) + } + }) + } +} +``` + +## Subtests and Sub-benchmarks + +### Organizing Related Tests + +```go +func TestUser(t *testing.T) { + // Setup shared by all subtests + db := setupTestDB(t) + + t.Run("Create", func(t *testing.T) { + user := &User{Name: "Alice"} + err := db.CreateUser(user) + if err != nil { + t.Fatalf("CreateUser failed: %v", err) + } + if user.ID == "" { + t.Error("expected user ID to be set") + } + }) + + t.Run("Get", func(t *testing.T) { + user, err := db.GetUser("alice-id") + if err != nil { + t.Fatalf("GetUser failed: %v", err) + } + if user.Name != "Alice" { + t.Errorf("got name %q; want %q", user.Name, "Alice") + } + }) + + t.Run("Update", func(t *testing.T) { + // ... + }) + + t.Run("Delete", func(t *testing.T) { + // ... + }) +} +``` + +### Parallel Subtests + +```go +func TestParallel(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"case1", "input1"}, + {"case2", "input2"}, + {"case3", "input3"}, + } + + for _, tt := range tests { + tt := tt // Capture range variable + t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Run subtests in parallel + result := Process(tt.input) + // assertions... + _ = result + }) + } +} +``` + +## Test Helpers + +### Helper Functions + +```go +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() // Marks this as a helper function + + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + // Cleanup when test finishes + t.Cleanup(func() { + db.Close() + }) + + // Run migrations + if _, err := db.Exec(schema); err != nil { + t.Fatalf("failed to create schema: %v", err) + } + + return db +} + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func assertEqual[T comparable](t *testing.T, got, want T) { + t.Helper() + if got != want { + t.Errorf("got %v; want %v", got, want) + } +} +``` + +### Temporary Files and Directories + +```go +func TestFileProcessing(t *testing.T) { + // Create temp directory - automatically cleaned up + tmpDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tmpDir, "test.txt") + err := os.WriteFile(testFile, []byte("test content"), 0644) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + // Run test + result, err := ProcessFile(testFile) + if err != nil { + t.Fatalf("ProcessFile failed: %v", err) + } + + // Assert... + _ = result +} +``` + +## Golden Files + +Testing against expected output files stored in `testdata/`. + +```go +var update = flag.Bool("update", false, "update golden files") + +func TestRender(t *testing.T) { + tests := []struct { + name string + input Template + }{ + {"simple", Template{Name: "test"}}, + {"complex", Template{Name: "test", Items: []string{"a", "b"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Render(tt.input) + + golden := filepath.Join("testdata", tt.name+".golden") + + if *update { + // Update golden file: go test -update + err := os.WriteFile(golden, got, 0644) + if err != nil { + t.Fatalf("failed to update golden file: %v", err) + } + } + + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("failed to read golden file: %v", err) + } + + if !bytes.Equal(got, want) { + t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want) + } + }) + } +} +``` + +## Mocking with Interfaces + +### Interface-Based Mocking + +```go +// Define interface for dependencies +type UserRepository interface { + GetUser(id string) (*User, error) + SaveUser(user *User) error +} + +// Production implementation +type PostgresUserRepository struct { + db *sql.DB +} + +func (r *PostgresUserRepository) GetUser(id string) (*User, error) { + // Real database query +} + +// Mock implementation for tests +type MockUserRepository struct { + GetUserFunc func(id string) (*User, error) + SaveUserFunc func(user *User) error +} + +func (m *MockUserRepository) GetUser(id string) (*User, error) { + return m.GetUserFunc(id) +} + +func (m *MockUserRepository) SaveUser(user *User) error { + return m.SaveUserFunc(user) +} + +// Test using mock +func TestUserService(t *testing.T) { + mock := &MockUserRepository{ + GetUserFunc: func(id string) (*User, error) { + if id == "123" { + return &User{ID: "123", Name: "Alice"}, nil + } + return nil, ErrNotFound + }, + } + + service := NewUserService(mock) + + user, err := service.GetUserProfile("123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.Name != "Alice" { + t.Errorf("got name %q; want %q", user.Name, "Alice") + } +} +``` + +## Benchmarks + +### Basic Benchmarks + +```go +func BenchmarkProcess(b *testing.B) { + data := generateTestData(1000) + b.ResetTimer() // Don't count setup time + + for i := 0; i < b.N; i++ { + Process(data) + } +} + +// Run: go test -bench=BenchmarkProcess -benchmem +// Output: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op +``` + +### Benchmark with Different Sizes + +```go +func BenchmarkSort(b *testing.B) { + sizes := []int{100, 1000, 10000, 100000} + + for _, size := range sizes { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + data := generateRandomSlice(size) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + // Make a copy to avoid sorting already sorted data + tmp := make([]int, len(data)) + copy(tmp, data) + sort.Ints(tmp) + } + }) + } +} +``` + +### Memory Allocation Benchmarks + +```go +func BenchmarkStringConcat(b *testing.B) { + parts := []string{"hello", "world", "foo", "bar", "baz"} + + b.Run("plus", func(b *testing.B) { + for i := 0; i < b.N; i++ { + var s string + for _, p := range parts { + s += p + } + _ = s + } + }) + + b.Run("builder", func(b *testing.B) { + for i := 0; i < b.N; i++ { + var sb strings.Builder + for _, p := range parts { + sb.WriteString(p) + } + _ = sb.String() + } + }) + + b.Run("join", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = strings.Join(parts, "") + } + }) +} +``` + +## Fuzzing (Go 1.18+) + +### Basic Fuzz Test + +```go +func FuzzParseJSON(f *testing.F) { + // Add seed corpus + f.Add(`{"name": "test"}`) + f.Add(`{"count": 123}`) + f.Add(`[]`) + f.Add(`""`) + + f.Fuzz(func(t *testing.T, input string) { + var result map[string]interface{} + err := json.Unmarshal([]byte(input), &result) + + if err != nil { + // Invalid JSON is expected for random input + return + } + + // If parsing succeeded, re-encoding should work + _, err = json.Marshal(result) + if err != nil { + t.Errorf("Marshal failed after successful Unmarshal: %v", err) + } + }) +} + +// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s +``` + +### Fuzz Test with Multiple Inputs + +```go +func FuzzCompare(f *testing.F) { + f.Add("hello", "world") + f.Add("", "") + f.Add("abc", "abc") + + f.Fuzz(func(t *testing.T, a, b string) { + result := Compare(a, b) + + // Property: Compare(a, a) should always equal 0 + if a == b && result != 0 { + t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result) + } + + // Property: Compare(a, b) and Compare(b, a) should have opposite signs + reverse := Compare(b, a) + if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) { + if result != 0 || reverse != 0 { + t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inconsistent", + a, b, result, b, a, reverse) + } + } + }) +} +``` + +## Test Coverage + +### Running Coverage + +```bash +# Basic coverage +go test -cover ./... + +# Generate coverage profile +go test -coverprofile=coverage.out ./... + +# View coverage in browser +go tool cover -html=coverage.out + +# View coverage by function +go tool cover -func=coverage.out + +# Coverage with race detection +go test -race -coverprofile=coverage.out ./... +``` + +### Coverage Targets + +| Code Type | Target | +|-----------|--------| +| Critical business logic | 100% | +| Public APIs | 90%+ | +| General code | 80%+ | +| Generated code | Exclude | + +### Excluding Generated Code from Coverage + +```go +//go:generate mockgen -source=interface.go -destination=mock_interface.go + +// In coverage profile, exclude with build tags: +// go test -cover -tags=!generate ./... +``` + +## HTTP Handler Testing + +```go +func TestHealthHandler(t *testing.T) { + // Create request + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + // Call handler + HealthHandler(w, req) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK) + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != "OK" { + t.Errorf("got body %q; want %q", body, "OK") + } +} + +func TestAPIHandler(t *testing.T) { + tests := []struct { + name string + method string + path string + body string + wantStatus int + wantBody string + }{ + { + name: "get user", + method: http.MethodGet, + path: "/users/123", + wantStatus: http.StatusOK, + wantBody: `{"id":"123","name":"Alice"}`, + }, + { + name: "not found", + method: http.MethodGet, + path: "/users/999", + wantStatus: http.StatusNotFound, + }, + { + name: "create user", + method: http.MethodPost, + path: "/users", + body: `{"name":"Bob"}`, + wantStatus: http.StatusCreated, + }, + } + + handler := NewAPIHandler() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body io.Reader + if tt.body != "" { + body = strings.NewReader(tt.body) + } + + req := httptest.NewRequest(tt.method, tt.path, body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("got status %d; want %d", w.Code, tt.wantStatus) + } + + if tt.wantBody != "" && w.Body.String() != tt.wantBody { + t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody) + } + }) + } +} +``` + +## Testing Commands + +```bash +# Run all tests +go test ./... + +# Run tests with verbose output +go test -v ./... + +# Run specific test +go test -run TestAdd ./... + +# Run tests matching pattern +go test -run "TestUser/Create" ./... + +# Run tests with race detector +go test -race ./... + +# Run tests with coverage +go test -cover -coverprofile=coverage.out ./... + +# Run short tests only +go test -short ./... + +# Run tests with timeout +go test -timeout 30s ./... + +# Run benchmarks +go test -bench=. -benchmem ./... + +# Run fuzzing +go test -fuzz=FuzzParse -fuzztime=30s ./... + +# Count test runs (for flaky test detection) +go test -count=10 ./... +``` + +## Best Practices + +**DO:** +- Write tests FIRST (TDD) +- Use table-driven tests for comprehensive coverage +- Test behavior, not implementation +- Use `t.Helper()` in helper functions +- Use `t.Parallel()` for independent tests +- Clean up resources with `t.Cleanup()` +- Use meaningful test names that describe the scenario + +**DON'T:** +- Test private functions directly (test through public API) +- Use `time.Sleep()` in tests (use channels or conditions) +- Ignore flaky tests (fix or remove them) +- Mock everything (prefer integration tests when possible) +- Skip error path testing + +## Integration with CI/CD + +```yaml +# GitHub Actions example +test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Run tests + run: go test -race -coverprofile=coverage.out ./... + + - name: Check coverage + run: | + go tool cover -func=coverage.out | grep total | awk '{print $3}' | \ + awk -F'%' '{if ($1 < 80) exit 1}' +``` + +**Remember**: Tests are documentation. They show how your code is meant to be used. Write them clearly and keep them up to date. diff --git a/.kilocode/skills/use-modern-go/SKILL.md b/.kilocode/skills/use-modern-go/SKILL.md new file mode 100644 index 0000000..3755a1a --- /dev/null +++ b/.kilocode/skills/use-modern-go/SKILL.md @@ -0,0 +1,291 @@ +--- +name: use-modern-go +description: Apply modern Go syntax guidelines based on project's Go version. Use when user ask for modern Go code guidelines. +--- + +# Modern Go Guidelines + +## Detected Go Version + +!`grep -rh "^go " --include="go.mod" . 2>/dev/null | cut -d' ' -f2 | sort | uniq -c | sort -nr | head -1 | xargs | cut -d' ' -f2 | grep . || echo unknown` + +## How to Use This Skill + +DO NOT search for go.mod files or try to detect the version yourself. Use ONLY the version shown above. + +**If version detected (not "unknown"):** +- Say: "This project is using Go X.XX, so I’ll stick to modern Go best practices and freely use language features up to and including this version. If you’d prefer a different target version, just let me know." +- Do NOT list features, do NOT ask for confirmation + +**If version is "unknown":** +- Say: "Could not detect Go version in this repository" +- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26] + +**When writing Go code**, use ALL features from this document up to the target version: +- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns +- Never use features from newer Go versions than the target +- Never use outdated patterns when a modern alternative is available + +--- + +## Features by Go Version + +### Go 1.0+ + +- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)` + +### Go 1.8+ + +- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())` + +### Go 1.13+ + +- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors) + +### Go 1.18+ + +- `any`: Use `any` instead of `interface{}` +- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice +- `strings.Cut`: `before, after, found := strings.Cut(s, sep)` + +### Go 1.19+ + +- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))` +- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32` + +```go +var flag atomic.Bool +flag.Store(true) +if flag.Load() { ... } + +var ptr atomic.Pointer[Config] +ptr.Store(cfg) +``` + +### Go 1.20+ + +- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory +- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice +- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }` +- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors +- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)` +- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation + +### Go 1.21+ + +**Built-ins:** +- `min`/`max`: `max(a, b)` instead of if/else comparisons +- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements + +**slices package:** +- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops +- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found) +- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })` +- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })` +- `slices.Sort`: `slices.Sort(items)` for ordered types +- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop +- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop +- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place +- `slices.Clip`: `slices.Clip(s)` removes unused capacity +- `slices.Clone`: `slices.Clone(s)` creates a copy + +**maps package:** +- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration +- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst +- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })` + +**sync package:** +- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper +- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })` + +**context package:** +- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation +- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)` +- `context.WithDeadlineCause`: Similar with deadline instead of duration + +### Go 1.22+ + +**Loops:** +- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++` +- Loop variables are now safe to capture in goroutines (each iteration has its own copy) + +**cmp package:** +- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value + +```go +// Instead of: +name := os.Getenv("NAME") +if name == "" { + name = "default" +} +// Use: +name := cmp.Or(os.Getenv("NAME"), "default") +``` + +**reflect package:** +- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()` + +**net/http:** +- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params +- `r.PathValue("id")` to get path parameters + +### Go 1.23+ + +- `maps.Keys(m)` / `maps.Values(m)` return iterators +- `slices.Collect(iter)` not manual loop to build slice from iterator +- `slices.Sorted(iter)` to collect and sort in one step + +```go +keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) } +sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort +for k := range maps.Keys(m) { process(k) } // iterate directly +``` + +**time package** + +- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do. + +### Go 1.24+ + +- `t.Context()` not `context.WithCancel(context.Background())` in tests. + ALWAYS use t.Context() when a test function needs a context. + +Before: +```go +func TestFoo(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := doSomething(ctx) +} +``` +After: +```go +func TestFoo(t *testing.T) { + ctx := t.Context() + result := doSomething(ctx) +} +``` + +- `omitzero` not `omitempty` in JSON struct tags. + ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps. + +Before: +```go +type Config struct { + Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration! +} +``` +After: +```go +type Config struct { + Timeout time.Duration `json:"timeout,omitzero"` +} +``` + +- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks. + ALWAYS use b.Loop() for the main loop in benchmark functions. + +Before: +```go +func BenchmarkFoo(b *testing.B) { + for i := 0; i < b.N; i++ { + doWork() + } +} +``` +After: +```go +func BenchmarkFoo(b *testing.B) { + for b.Loop() { + doWork() + } +} +``` + +- `strings.SplitSeq` not `strings.Split` when iterating. + ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop. + +Before: +```go +for _, part := range strings.Split(s, ",") { + process(part) +} +``` +After: +```go +for part := range strings.SplitSeq(s, ",") { + process(part) +} +``` +Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`. + +### Go 1.25+ + +- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`. + ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup. + +Before: +```go +var wg sync.WaitGroup +for _, item := range items { + wg.Add(1) + go func() { + defer wg.Done() + process(item) + }() +} +wg.Wait() +``` +After: +```go +var wg sync.WaitGroup +for _, item := range items { + wg.Go(func() { + process(item) + }) +} +wg.Wait() +``` + +### Go 1.26+ + +- `new(val)` not `x := val; &x` — returns pointer to any value. + Go 1.26 extends new() to accept expressions, not just types. + Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T. + DO NOT use `x := val; &x` pattern — always use new(val) directly. + DO NOT use redundant casts like new(int(0)) — just write new(0). + Common use case: struct fields with pointer types. + +Before: +```go +timeout := 30 +debug := true +cfg := Config{ + Timeout: &timeout, + Debug: &debug, +} +``` +After: +```go +cfg := Config{ + Timeout: new(30), // *int + Debug: new(true), // *bool +} +``` + +- `errors.AsType[T](err)` not `errors.As(err, &target)`. + ALWAYS use errors.AsType when checking if error matches a specific type. + +Before: +```go +var pathErr *os.PathError +if errors.As(err, &pathErr) { + handle(pathErr) +} +``` +After: +```go +if pathErr, ok := errors.AsType[*os.PathError](err); ok { + handle(pathErr) +} +``` diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 87a17dc..7fc8334 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -1,10 +1,12 @@ package main import ( + "errors" "flag" "fmt" "log" + "go.uber.org/zap" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -73,17 +75,17 @@ func main() { defer sqlDB.Close() if err := sqlDB.Ping(); err != nil { - log.Fatalf("Failed to ping database: %v", err) + zap.L().Fatal("Failed to ping database", zap.Error(err)) } - log.Println("Connected to database") + zap.L().Info("Connected to database") // 运行迁移 if err := runMigration(db); err != nil { - log.Fatalf("Migration failed: %v", err) + zap.L().Fatal("Migration failed", zap.Error(err)) } - log.Println("Migration completed successfully") + zap.L().Info("Migration completed successfully") } func runMigration(db *gorm.DB) error { @@ -105,26 +107,31 @@ func runMigration(db *gorm.DB) error { } if hasPreviewURL && hasPreviewURLLarge { - log.Println("Columns already exist, skipping migration") + zap.L().Info("Columns already exist, skipping migration") return nil } // 添加字段 + var errs []error if !hasPreviewURL { - log.Println("Adding preview_url column...") + zap.L().Info("Adding preview_url column...") if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url TEXT").Error; err != nil { - return fmt.Errorf("failed to add preview_url column: %w", err) + errs = append(errs, fmt.Errorf("failed to add preview_url column: %w", err)) } } if !hasPreviewURLLarge { - log.Println("Adding preview_url_large column...") + zap.L().Info("Adding preview_url_large column...") if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url_large TEXT").Error; err != nil { - return fmt.Errorf("failed to add preview_url_large column: %w", err) + errs = append(errs, fmt.Errorf("failed to add preview_url_large column: %w", err)) } } - log.Println("Migration completed successfully") + if len(errs) > 0 { + return errors.Join(errs...) + } + + zap.L().Info("Migration completed successfully") return nil } diff --git a/cmd/server/app.go b/cmd/server/app.go index 213bcd5..e61c248 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log" "net/http" "carrot_bbs/internal/config" @@ -11,6 +10,7 @@ import ( "carrot_bbs/internal/router" "carrot_bbs/internal/service" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -22,6 +22,7 @@ type App struct { PushService service.PushService GRPCServer *runner.Server Server *http.Server + Logger *zap.Logger } // NewApp 创建应用程序 @@ -31,6 +32,7 @@ func NewApp( r *router.Router, pushService service.PushService, grpcServer *runner.Server, + logger *zap.Logger, ) *App { return &App{ Config: cfg, @@ -38,6 +40,7 @@ func NewApp( Router: r, PushService: pushService, GRPCServer: grpcServer, + Logger: logger, } } @@ -45,7 +48,7 @@ func NewApp( func (a *App) Start(ctx context.Context) error { // 1. 启动 gRPC 服务器(在 HTTP 服务器之前) if a.GRPCServer != nil { - log.Println("Starting gRPC server...") + a.Logger.Info("Starting gRPC server") if err := a.GRPCServer.Start(); err != nil { return fmt.Errorf("failed to start gRPC server: %w", err) } @@ -56,7 +59,9 @@ func (a *App) Start(ctx context.Context) error { // 3. 创建 HTTP 服务器 addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port) - log.Printf("Starting HTTP server at %s", addr) + a.Logger.Info("Starting HTTP server", + zap.String("address", addr), + ) a.Server = &http.Server{ Addr: addr, @@ -66,7 +71,9 @@ func (a *App) Start(ctx context.Context) error { // 4. 启动 HTTP 服务器 go func() { if err := a.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %s\n", err) + a.Logger.Fatal("HTTP server listen error", + zap.Error(err), + ) } }() @@ -75,33 +82,35 @@ func (a *App) Start(ctx context.Context) error { // Stop 停止应用程序 func (a *App) Stop(ctx context.Context) error { - log.Println("Shutting down server...") + a.Logger.Info("Shutting down server") // 1. 停止 HTTP 服务器 if a.Server != nil { - log.Println("Stopping HTTP server...") + a.Logger.Info("Stopping HTTP server") if err := a.Server.Shutdown(ctx); err != nil { - log.Printf("HTTP server forced to shutdown: %v", err) + a.Logger.Error("HTTP server forced to shutdown", + zap.Error(err), + ) } } // 2. 停止 gRPC 服务器 if a.GRPCServer != nil { - log.Println("Stopping gRPC server...") + a.Logger.Info("Stopping gRPC server") a.GRPCServer.Stop() } // 3. 停止推送 Worker - log.Println("Stopping push worker...") + a.Logger.Info("Stopping push worker") a.PushService.StopPushWorker() // 4. 关闭数据库连接 - log.Println("Closing database connection...") + a.Logger.Info("Closing database connection") sqlDB, err := a.DB.DB() if err == nil { sqlDB.Close() } - log.Println("Server exited") + a.Logger.Info("Server exited") return nil } diff --git a/cmd/server/main.go b/cmd/server/main.go index b09fc64..43b2832 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,24 +2,29 @@ package main import ( "context" - "log" "os" "os/signal" "syscall" "time" + + "go.uber.org/zap" ) func main() { // 初始化应用程序(Wire 自动生成) app, err := InitializeApp() if err != nil { - log.Fatalf("failed to initialize app: %v", err) + zap.L().Fatal("failed to initialize app", + zap.Error(err), + ) } // 启动应用程序 ctx := context.Background() if err := app.Start(ctx); err != nil { - log.Fatalf("failed to start app: %v", err) + app.Logger.Fatal("failed to start app", + zap.Error(err), + ) } // 优雅关闭 @@ -31,6 +36,8 @@ func main() { defer cancel() if err := app.Stop(shutdownCtx); err != nil { - log.Printf("Error during shutdown: %v", err) + app.Logger.Error("Error during shutdown", + zap.Error(err), + ) } } diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 95be523..fbcf182 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -109,7 +109,7 @@ func InitializeApp() (*App, error) { adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService) adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService) router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, logService, userActivityService, casbinService) - app := NewApp(config, db, router, pushService, server) + app := NewApp(config, db, router, pushService, server, logger) return app, nil } diff --git a/go.mod b/go.mod index a98b4f3..6cbe67e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25 require ( github.com/alicebob/miniredis/v2 v2.31.0 - github.com/casbin/casbin/v2 v2.135.0 github.com/casbin/casbin/v3 v3.10.0 github.com/casbin/gorm-adapter/v3 v3.41.0 github.com/gin-gonic/gin v1.9.1 diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 3e88d75..d5f8b1d 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,6 +1,7 @@ package cache import ( + "cmp" "context" "encoding/json" "math/rand" @@ -123,17 +124,11 @@ func GetSettings() Settings { } func normalizeKey(key string) string { - if settings.KeyPrefix == "" { - return key - } - return settings.KeyPrefix + ":" + key + return cmp.Or(settings.KeyPrefix, "") + ":" + key } func normalizePrefix(prefix string) string { - if settings.KeyPrefix == "" { - return prefix - } - return settings.KeyPrefix + ":" + prefix + return cmp.Or(settings.KeyPrefix, "") + ":" + prefix } func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) { diff --git a/internal/cache/conversation_cache.go b/internal/cache/conversation_cache.go index b46fd81..f7d0134 100644 --- a/internal/cache/conversation_cache.go +++ b/internal/cache/conversation_cache.go @@ -4,10 +4,11 @@ import ( "context" "encoding/json" "fmt" - "log" "time" "carrot_bbs/internal/model" + + "go.uber.org/zap" ) // CachedConversation 带缓存元数据的会话 @@ -713,6 +714,10 @@ func (c *ConversationCache) getMessagesBySeqs(ctx context.Context, convID string // asyncCacheMessage 异步缓存单条消息 func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) { if err := c.CacheMessage(ctx, convID, msg); err != nil { - log.Printf("[ConversationCache] async cache message failed, convID=%s, msgID=%s, err=%v", convID, msg.ID, err) + zap.L().Error("async cache message failed", + zap.String("convID", convID), + zap.String("msgID", msg.ID), + zap.Error(err), + ) } } diff --git a/internal/cache/redis_cache.go b/internal/cache/redis_cache.go index 3aded36..8b59f80 100644 --- a/internal/cache/redis_cache.go +++ b/internal/cache/redis_cache.go @@ -3,10 +3,10 @@ package cache import ( "context" "encoding/json" - "log" "time" "github.com/redis/go-redis/v9" + "go.uber.org/zap" redisPkg "carrot_bbs/internal/pkg/redis" ) @@ -32,13 +32,19 @@ func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) { data, err := json.Marshal(value) if err != nil { recordSetError() - log.Printf("[RedisCache] Failed to marshal value for key %s: %v", key, err) + zap.L().Error("Failed to marshal value for key", + zap.String("key", key), + zap.Error(err), + ) return } if err := c.client.Set(c.ctx, key, data, ttl); err != nil { recordSetError() - log.Printf("[RedisCache] Failed to set key %s: %v", key, err) + zap.L().Error("Failed to set key", + zap.String("key", key), + zap.Error(err), + ) } } @@ -50,7 +56,10 @@ func (c *RedisCache) Get(key string) (interface{}, bool) { if err == redis.Nil { return nil, false } - log.Printf("[RedisCache] Failed to get key %s: %v", key, err) + zap.L().Error("Failed to get key", + zap.String("key", key), + zap.Error(err), + ) return nil, false } @@ -63,7 +72,10 @@ func (c *RedisCache) Delete(key string) { key = normalizeKey(key) recordInvalidate() if err := c.client.Del(c.ctx, key); err != nil { - log.Printf("[RedisCache] Failed to delete key %s: %v", key, err) + zap.L().Error("Failed to delete key", + zap.String("key", key), + zap.Error(err), + ) } } @@ -76,14 +88,20 @@ func (c *RedisCache) DeleteByPrefix(prefix string) { for { keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result() if err != nil { - log.Printf("[RedisCache] Failed to scan keys with prefix %s: %v", prefix, err) + zap.L().Error("Failed to scan keys with prefix", + zap.String("prefix", prefix), + zap.Error(err), + ) return } if len(keys) > 0 { recordInvalidateMultiple(int64(len(keys))) if err := c.client.Del(c.ctx, keys...); err != nil { - log.Printf("[RedisCache] Failed to delete keys with prefix %s: %v", prefix, err) + zap.L().Error("Failed to delete keys with prefix", + zap.String("prefix", prefix), + zap.Error(err), + ) } } @@ -97,13 +115,15 @@ func (c *RedisCache) DeleteByPrefix(prefix string) { // Clear 清空所有缓存 func (c *RedisCache) Clear() { if settings.DisableFlushDB { - log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true") + zap.L().Debug("Skip FlushDB because cache.disable_flushdb=true") return } recordInvalidate() rdb := c.client.GetClient() if err := rdb.FlushDB(c.ctx).Err(); err != nil { - log.Printf("[RedisCache] Failed to clear cache: %v", err) + zap.L().Error("Failed to clear cache", + zap.Error(err), + ) } } @@ -112,7 +132,10 @@ func (c *RedisCache) Exists(key string) bool { key = normalizeKey(key) n, err := c.client.Exists(c.ctx, key) if err != nil { - log.Printf("[RedisCache] Failed to check existence of key %s: %v", key, err) + zap.L().Error("Failed to check existence of key", + zap.String("key", key), + zap.Error(err), + ) return false } return n > 0 @@ -129,7 +152,10 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 { rdb := c.client.GetClient() result, err := rdb.IncrBy(c.ctx, key, value).Result() if err != nil { - log.Printf("[RedisCache] Failed to increment key %s: %v", key, err) + zap.L().Error("Failed to increment key", + zap.String("key", key), + zap.Error(err), + ) return 0 } return result diff --git a/internal/config/config.go b/internal/config/config.go index 34b6227..84d2cd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "cmp" "fmt" "os" "strconv" @@ -236,8 +237,5 @@ func Load(configPath string) (*Config, error) { // getEnvOrDefault 获取环境变量值,如果未设置则返回默认值 func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue + return cmp.Or(os.Getenv(key), defaultValue) } diff --git a/internal/dto/dto.go b/internal/dto/dto.go index eea6e23..e8edbdb 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -224,34 +224,34 @@ type TextSegmentData struct { // ImageSegmentData 图片数据 type ImageSegmentData struct { URL string `json:"url"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` + Width int `json:"width,omitzero"` + Height int `json:"height,omitzero"` ThumbnailURL string `json:"thumbnail_url,omitempty"` - FileSize int64 `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitzero"` } // VoiceSegmentData 语音数据 type VoiceSegmentData struct { URL string `json:"url"` - Duration int `json:"duration,omitempty"` // 秒 - FileSize int64 `json:"file_size,omitempty"` + Duration int `json:"duration,omitzero"` // 秒 + FileSize int64 `json:"file_size,omitzero"` } // VideoSegmentData 视频数据 type VideoSegmentData struct { URL string `json:"url"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Duration int `json:"duration,omitempty"` // 秒 + Width int `json:"width,omitzero"` + Height int `json:"height,omitzero"` + Duration int `json:"duration,omitzero"` // 秒 ThumbnailURL string `json:"thumbnail_url,omitempty"` - FileSize int64 `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitzero"` } // FileSegmentData 文件数据 type FileSegmentData struct { URL string `json:"url"` Name string `json:"name"` - Size int64 `json:"size,omitempty"` + Size int64 `json:"size,omitzero"` MimeType string `json:"mime_type,omitempty"` } @@ -268,7 +268,7 @@ type ReplySegmentData struct { // FaceSegmentData 表情数据 type FaceSegmentData struct { - ID int `json:"id"` + ID int `json:"id,omitzero"` Name string `json:"name,omitempty"` URL string `json:"url,omitempty"` } @@ -458,7 +458,7 @@ type DeviceTokenResponse struct { DeviceType string `json:"device_type"` IsActive bool `json:"is_active"` DeviceName string `json:"device_name"` - LastUsedAt time.Time `json:"last_used_at,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitzero"` CreatedAt time.Time `json:"created_at"` } @@ -470,9 +470,9 @@ type PushRecordResponse struct { MessageID string `json:"message_id"` PushChannel string `json:"push_channel"` PushStatus string `json:"push_status"` - RetryCount int `json:"retry_count"` - PushedAt time.Time `json:"pushed_at,omitempty"` - DeliveredAt time.Time `json:"delivered_at,omitempty"` + RetryCount int `json:"retry_count,omitzero"` + PushedAt time.Time `json:"pushed_at,omitzero"` + DeliveredAt time.Time `json:"delivered_at,omitzero"` CreatedAt time.Time `json:"created_at"` } @@ -859,7 +859,7 @@ type VoteResultDTO struct { Options []VoteOptionDTO `json:"options"` TotalVotes int `json:"total_votes"` HasVoted bool `json:"has_voted"` - VotedOptionID string `json:"voted_option_id,omitempty"` + VotedOptionID string `json:"voted_option_id,omitzero"` } // ==================== WebSocket Response DTOs ==================== @@ -882,10 +882,10 @@ type AdminPostListResponse struct { Content string `json:"content"` Images []PostImageResponse `json:"images"` Status string `json:"status"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - FavoritesCount int `json:"favorites_count"` - ViewsCount int `json:"views_count"` + LikesCount int `json:"likes_count,omitzero"` + CommentsCount int `json:"comments_count,omitzero"` + FavoritesCount int `json:"favorites_count,omitzero"` + ViewsCount int `json:"views_count,omitzero"` IsPinned bool `json:"is_pinned"` IsFeatured bool `json:"is_featured"` IsLocked bool `json:"is_locked"` @@ -906,11 +906,11 @@ type AdminPostDetailResponse struct { Content string `json:"content"` Images []PostImageResponse `json:"images"` Status string `json:"status"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - FavoritesCount int `json:"favorites_count"` - SharesCount int `json:"shares_count"` - ViewsCount int `json:"views_count"` + LikesCount int `json:"likes_count,omitzero"` + CommentsCount int `json:"comments_count,omitzero"` + FavoritesCount int `json:"favorites_count,omitzero"` + SharesCount int `json:"shares_count,omitzero"` + ViewsCount int `json:"views_count,omitzero"` HotScore float64 `json:"hot_score"` IsPinned bool `json:"is_pinned"` IsFeatured bool `json:"is_featured"` diff --git a/internal/dto/schedule_dto.go b/internal/dto/schedule_dto.go index c5f0da5..d51da31 100644 --- a/internal/dto/schedule_dto.go +++ b/internal/dto/schedule_dto.go @@ -5,9 +5,9 @@ type ScheduleCourseResponse struct { Name string `json:"name"` Teacher string `json:"teacher,omitempty"` Location string `json:"location,omitempty"` - DayOfWeek int `json:"day_of_week"` - StartSection int `json:"start_section"` - EndSection int `json:"end_section"` + DayOfWeek int `json:"day_of_week,omitzero"` + StartSection int `json:"start_section,omitzero"` + EndSection int `json:"end_section,omitzero"` Weeks []int `json:"weeks"` Color string `json:"color,omitempty"` } diff --git a/internal/dto/segment.go b/internal/dto/segment.go index 40d8131..497e732 100644 --- a/internal/dto/segment.go +++ b/internal/dto/segment.go @@ -3,6 +3,7 @@ package dto import ( "encoding/json" "fmt" + "slices" "carrot_bbs/internal/model" ) @@ -232,25 +233,27 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string { // IsAtAll 检查消息是否@了所有人 func IsAtAll(segments model.MessageSegments) bool { - for _, segment := range segments { + return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool { if segment.Type == string(SegmentTypeAt) { if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" { return true } } - } - return false + return false + }) } // GetReplyMessageID 从消息链中获取被回复的消息ID // 如果没有回复segment,返回空字符串 func GetReplyMessageID(segments model.MessageSegments) string { - for _, segment := range segments { - if segment.Type == string(SegmentTypeReply) { - if id, ok := segment.Data["id"].(string); ok { - return id - } - } + idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool { + return segment.Type == string(SegmentTypeReply) + }) + if idx == -1 { + return "" + } + if id, ok := segments[idx].Data["id"].(string); ok { + return id } return "" } @@ -291,12 +294,9 @@ func BuildSegmentsFromContent(contentType, content string, mediaURL *string) mod // HasSegmentType 检查消息链中是否包含指定类型的segment func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool { - for _, segment := range segments { - if segment.Type == string(segmentType) { - return true - } - } - return false + return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool { + return segment.Type == string(segmentType) + }) } // GetSegmentsByType 获取消息链中所有指定类型的segment @@ -313,12 +313,14 @@ func GetSegmentsByType(segments model.MessageSegments, segmentType SegmentType) // GetFirstImageURL 获取消息链中第一张图片的URL // 如果没有图片,返回空字符串 func GetFirstImageURL(segments model.MessageSegments) string { - for _, segment := range segments { - if segment.Type == string(SegmentTypeImage) { - if url, ok := segment.Data["url"].(string); ok { - return url - } - } + idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool { + return segment.Type == string(SegmentTypeImage) + }) + if idx == -1 { + return "" + } + if url, ok := segments[idx].Data["url"].(string); ok { + return url } return "" } @@ -326,13 +328,18 @@ func GetFirstImageURL(segments model.MessageSegments) string { // GetFirstMediaURL 获取消息链中第一个媒体文件的URL(图片/视频/语音/文件) // 用于兼容旧版本API func GetFirstMediaURL(segments model.MessageSegments) string { - for _, segment := range segments { + idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool { switch segment.Type { case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile): - if url, ok := segment.Data["url"].(string); ok { - return url - } + return true } + return false + }) + if idx == -1 { + return "" + } + if url, ok := segments[idx].Data["url"].(string); ok { + return url } return "" } diff --git a/internal/handler/gorse_handler.go b/internal/handler/gorse_handler.go index 132b8b1..6c1de54 100644 --- a/internal/handler/gorse_handler.go +++ b/internal/handler/gorse_handler.go @@ -2,7 +2,6 @@ package handler import ( "context" - "log" "strings" "time" @@ -13,6 +12,7 @@ import ( "github.com/gin-gonic/gin" gorseio "github.com/gorse-io/gorse-go" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -62,7 +62,7 @@ func (h *GorseHandler) ImportData(c *gin.Context) { stats, err := h.importAllData(ctx) if err != nil { - log.Printf("[ERROR] gorse import failed: %v", err) + zap.L().Error("gorse import failed", zap.Error(err)) response.InternalServerError(c, "gorse import failed: "+err.Error()) return } @@ -107,7 +107,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { for _, post := range posts { embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content)) if err != nil { - log.Printf("[WARN] get embedding failed for post %s: %v", post.ID, err) + zap.L().Warn("get embedding failed for post", + zap.String("postID", post.ID), + zap.Error(err), + ) embedding = make([]float64, 1024) } _, err = gorseClient.InsertItem(ctx, gorseio.Item{ @@ -121,7 +124,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { }, }) if err != nil { - log.Printf("[WARN] import item failed (%s): %v", post.ID, err) + zap.L().Warn("import item failed", + zap.String("postID", post.ID), + zap.Error(err), + ) continue } stats["items"] = stats["items"].(int) + 1 @@ -143,7 +149,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { Comment: user.Nickname, }) if err != nil { - log.Printf("[WARN] import user failed (%s): %v", user.ID, err) + zap.L().Warn("import user failed", + zap.String("userID", user.ID), + zap.Error(err), + ) continue } stats["users"] = stats["users"].(int) + 1 @@ -162,7 +171,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { Timestamp: like.CreatedAt.UTC().Truncate(time.Second), }}) if err != nil { - log.Printf("[WARN] import like failed (%s/%s): %v", like.UserID, like.PostID, err) + zap.L().Warn("import like failed", + zap.String("userID", like.UserID), + zap.String("postID", like.PostID), + zap.Error(err), + ) continue } stats["likes"] = stats["likes"].(int) + 1 @@ -181,7 +194,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { Timestamp: fav.CreatedAt.UTC().Truncate(time.Second), }}) if err != nil { - log.Printf("[WARN] import favorite failed (%s/%s): %v", fav.UserID, fav.PostID, err) + zap.L().Warn("import favorite failed", + zap.String("userID", fav.UserID), + zap.String("postID", fav.PostID), + zap.Error(err), + ) continue } stats["favorites"] = stats["favorites"].(int) + 1 @@ -206,7 +223,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { Timestamp: cm.CreatedAt.UTC().Truncate(time.Second), }}) if err != nil { - log.Printf("[WARN] import comment failed (%s/%s): %v", cm.UserID, cm.PostID, err) + zap.L().Warn("import comment failed", + zap.String("userID", cm.UserID), + zap.String("postID", cm.PostID), + zap.Error(err), + ) continue } stats["comments"] = stats["comments"].(int) + 1 diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go index e40e811..6ea8d39 100644 --- a/internal/handler/group_handler.go +++ b/internal/handler/group_handler.go @@ -1,10 +1,10 @@ package handler import ( - "log" "strconv" "github.com/gin-gonic/gin" + "go.uber.org/zap" "carrot_bbs/internal/dto" "carrot_bbs/internal/model" @@ -1361,12 +1361,24 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) { // duration > 0 或 duration = -1 表示禁言,duration = 0 表示解除禁言 muted := params.Duration != 0 - log.Printf("[HandleSetGroupBan] 开始禁言操作: userID=%s, groupID=%s, targetUserID=%s, duration=%d, muted=%v", userID, groupID, params.UserID, params.Duration, muted) + zap.L().Info("开始禁言操作", + zap.String("component", "HandleSetGroupBan"), + zap.String("userID", userID), + zap.String("groupID", groupID), + zap.String("targetUserID", params.UserID), + zap.Int64("duration", params.Duration), + zap.Bool("muted", muted), + ) err := h.groupService.MuteMember(userID, groupID, params.UserID, muted) if err != nil { - log.Printf("[HandleSetGroupBan] 禁言操作失败: %v", err) + zap.L().Warn("禁言操作失败", + zap.String("component", "HandleSetGroupBan"), + zap.Error(err), + ) } else { - log.Printf("[HandleSetGroupBan] 禁言操作成功") + zap.L().Info("禁言操作成功", + zap.String("component", "HandleSetGroupBan"), + ) } if err != nil { if err == service.ErrNotGroupAdmin { diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 5117bb8..bb1e2bc 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -1,6 +1,7 @@ package handler import ( + "cmp" "context" "crypto/sha256" "fmt" @@ -129,10 +130,7 @@ func (h *UserHandler) Login(c *gin.Context) { return } - account := req.Account - if account == "" { - account = req.Username - } + account := cmp.Or(req.Account, req.Username) if account == "" { response.BadRequest(c, "username or account is required") return diff --git a/internal/middleware/casbin.go b/internal/middleware/casbin.go index 54a5896..bd04272 100644 --- a/internal/middleware/casbin.go +++ b/internal/middleware/casbin.go @@ -1,6 +1,7 @@ package middleware import ( + "slices" "strings" "carrot_bbs/internal/service" @@ -104,18 +105,9 @@ func RequireRole(casbinService service.CasbinService, requiredRoles ...string) g } // 检查用户是否拥有任一所需角色 - roleMap := make(map[string]bool) - for _, role := range userRoles { - roleMap[role] = true - } - - hasRole := false - for _, required := range requiredRoles { - if roleMap[required] { - hasRole = true - break - } - } + hasRole := slices.ContainsFunc(requiredRoles, func(required string) bool { + return slices.Contains(userRoles, required) + }) if !hasRole { c.AbortWithStatusJSON(403, gin.H{ diff --git a/internal/model/init.go b/internal/model/init.go index 0961344..da14b27 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -6,6 +6,7 @@ import ( "os" "time" + "go.uber.org/zap" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -67,7 +68,9 @@ func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) { return nil, fmt.Errorf("failed to auto migrate: %w", err) } - log.Printf("Database connected (%s) and migrated successfully", cfg.Type) + zap.L().Info("Database connected and migrated successfully", + zap.String("type", cfg.Type), + ) return db, nil } @@ -220,7 +223,9 @@ func seedRoles(db *gorm.DB) error { return err } - log.Printf("Seeded %d roles successfully", len(roles)) + zap.L().Info("Seeded roles successfully", + zap.Int("count", len(roles)), + ) return nil } @@ -276,7 +281,9 @@ func seedPermissions(db *gorm.DB) error { return err } - log.Printf("Seeded %d permissions successfully", len(permissions)) + zap.L().Info("Seeded permissions successfully", + zap.Int("count", len(permissions)), + ) return nil } diff --git a/internal/pkg/gorse/client.go b/internal/pkg/gorse/client.go index 9c9e3fc..0e2fb8e 100644 --- a/internal/pkg/gorse/client.go +++ b/internal/pkg/gorse/client.go @@ -10,16 +10,17 @@ import ( "time" gorseio "github.com/gorse-io/gorse-go" + "go.uber.org/zap" ) // FeedbackType 反馈类型 type FeedbackType string const ( - FeedbackTypeLike FeedbackType = "like" // 点赞 - FeedbackTypeStar FeedbackType = "star" // 收藏 - FeedbackTypeComment FeedbackType = "comment" // 评论 - FeedbackTypeRead FeedbackType = "read" // 浏览 + FeedbackTypeLike FeedbackType = "like" // 点赞 + FeedbackTypeStar FeedbackType = "star" // 收藏 + FeedbackTypeComment FeedbackType = "comment" // 评论 + FeedbackTypeRead FeedbackType = "read" // 浏览 ) // Score 非个性化推荐返回的评分项 @@ -206,7 +207,10 @@ func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, cat var err error embedding, err = GetEmbedding(textToEmbed) if err != nil { - log.Printf("[WARN] Failed to get embedding for item %s: %v, using zero vector", itemID, err) + zap.L().Warn("Failed to get embedding for item, using zero vector", + zap.String("itemID", itemID), + zap.Error(err), + ) embedding = make([]float64, 1024) } } else { diff --git a/internal/pkg/gorse/embedding.go b/internal/pkg/gorse/embedding.go index 30057c6..eab0941 100644 --- a/internal/pkg/gorse/embedding.go +++ b/internal/pkg/gorse/embedding.go @@ -5,9 +5,10 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "time" + + "go.uber.org/zap" ) // EmbeddingConfig embedding服务配置 @@ -94,7 +95,7 @@ func GetEmbedding(text string) ([]float64, error) { // InitEmbeddingWithConfig 从应用配置初始化embedding func InitEmbeddingWithConfig(apiKey, url, model string) { if apiKey == "" { - log.Println("[WARN] Gorse embedding API key not set, using default") + zap.L().Warn("Gorse embedding API key not set, using default") } defaultEmbeddingConfig.APIKey = apiKey if url != "" { @@ -103,4 +104,4 @@ func InitEmbeddingWithConfig(apiKey, url, model string) { if model != "" { defaultEmbeddingConfig.Model = model } -} \ No newline at end of file +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 3e09a58..e487e2c 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -285,6 +285,33 @@ func (r *PostRepository) IsLiked(postID, userID string) bool { return count > 0 } +// IsLikedBatch 批量检查是否点赞(解决 N+1 问题) +// 返回 map[postID]bool +func (r *PostRepository) IsLikedBatch(postIDs []string, userID string) map[string]bool { + result := make(map[string]bool) + if len(postIDs) == 0 || userID == "" { + return result + } + + // 初始化所有 postID 为 false + for _, postID := range postIDs { + result[postID] = false + } + + // 一次性查询所有点赞记录 + var likedPostIDs []string + r.db.Model(&model.PostLike{}). + Where("post_id IN ? AND user_id = ?", postIDs, userID). + Pluck("post_id", &likedPostIDs) + + // 更新已点赞的帖子 + for _, postID := range likedPostIDs { + result[postID] = true + } + + return result +} + // Favorite 收藏帖子 func (r *PostRepository) Favorite(postID, userID string) error { return r.db.Transaction(func(tx *gorm.DB) error { @@ -336,6 +363,33 @@ func (r *PostRepository) IsFavorited(postID, userID string) bool { return count > 0 } +// IsFavoritedBatch 批量检查是否收藏(解决 N+1 问题) +// 返回 map[postID]bool +func (r *PostRepository) IsFavoritedBatch(postIDs []string, userID string) map[string]bool { + result := make(map[string]bool) + if len(postIDs) == 0 || userID == "" { + return result + } + + // 初始化所有 postID 为 false + for _, postID := range postIDs { + result[postID] = false + } + + // 一次性查询所有收藏记录 + var favoritedPostIDs []string + r.db.Model(&model.Favorite{}). + Where("post_id IN ? AND user_id = ?", postIDs, userID). + Pluck("post_id", &favoritedPostIDs) + + // 更新已收藏的帖子 + for _, postID := range favoritedPostIDs { + result[postID] = true + } + + return result +} + // IncrementViews 增加帖子观看量 func (r *PostRepository) IncrementViews(postID string) error { return r.db.Model(&model.Post{}).Where("id = ?", postID). diff --git a/internal/service/async_log_manager.go b/internal/service/async_log_manager.go index 3bacb10..229ba53 100644 --- a/internal/service/async_log_manager.go +++ b/internal/service/async_log_manager.go @@ -1,6 +1,7 @@ package service import ( + "cmp" "context" "os" "sync" @@ -67,9 +68,11 @@ func NewAsyncLogManager( } } - for i := 0; i < cfg.WorkerCount; i++ { - m.wg.Add(1) - go m.worker(i, operationRepo, loginRepo, dataChangeRepo) + for i := range cfg.WorkerCount { + workerID := i + m.wg.Go(func() { + m.worker(workerID, operationRepo, loginRepo, dataChangeRepo) + }) } go m.flushScheduler() @@ -91,8 +94,6 @@ func (m *AsyncLogManager) worker( loginRepo *repository.LoginLogRepository, dataChangeRepo *repository.DataChangeLogRepository, ) { - defer m.wg.Done() - opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize) loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize) changeBatch := make([]*model.DataChangeLog, 0, m.cfg.BatchSize) @@ -228,9 +229,7 @@ func (m *AsyncLogManager) writeToFallback(logType string, logs interface{}) erro // initFallbackWriter 初始化降级写入器 func (m *AsyncLogManager) initFallbackWriter() error { - if m.cfg.FallbackPath == "" { - m.cfg.FallbackPath = "./logs/fallback.log" - } + m.cfg.FallbackPath = cmp.Or(m.cfg.FallbackPath, "./logs/fallback.log") dir := "./logs" if _, err := os.Stat(dir); os.IsNotExist(err) { diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go index 7b37e77..2428bb9 100644 --- a/internal/service/audit_service.go +++ b/internal/service/audit_service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "strings" "sync" "time" @@ -12,6 +11,7 @@ import ( "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/openai" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -97,7 +97,9 @@ func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType // 使用 AI 审核文本 approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil) if err != nil { - log.Printf("AI audit text error: %v", err) + zap.L().Error("AI audit text error", + zap.Error(err), + ) if s.config.StrictModeration { return &AuditResult{ Pass: false, @@ -163,7 +165,9 @@ func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*Au // 使用 AI 审核图片 approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL}) if err != nil { - log.Printf("AI audit image error: %v", err) + zap.L().Error("AI audit image error", + zap.Error(err), + ) if s.config.StrictModeration { return &AuditResult{ Pass: false, @@ -219,7 +223,9 @@ func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string, // 使用 AI 审核帖子 approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images) if err != nil { - log.Printf("AI audit post error: %v", err) + zap.L().Error("AI audit post error", + zap.Error(err), + ) if s.config.StrictModeration { return &AuditResult{ Pass: false, @@ -279,7 +285,9 @@ func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, ima // 使用 AI 审核评论 approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images) if err != nil { - log.Printf("AI audit comment error: %v", err) + zap.L().Error("AI audit comment error", + zap.Error(err), + ) if s.config.StrictModeration { return &AuditResult{ Pass: false, @@ -395,7 +403,9 @@ func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, conten } if err := s.db.Create(&auditLog).Error; err != nil { - log.Printf("Failed to save audit log: %v", err) + zap.L().Error("Failed to save audit log", + zap.Error(err), + ) } } @@ -419,7 +429,11 @@ func (c *AuditCallback) HandleTextCallback(ctx context.Context, auditID string, return fmt.Errorf("invalid parameters") } - log.Printf("Processing text audit callback: auditID=%s, result=%+v", auditID, result) + zap.L().Debug("Processing text audit callback", + zap.String("auditID", auditID), + zap.Bool("pass", result.Pass), + zap.String("risk", result.Risk), + ) // 根据审核结果执行相应操作 // 例如: 更新帖子状态、发送通知等 @@ -433,7 +447,11 @@ func (c *AuditCallback) HandleImageCallback(ctx context.Context, auditID string, return fmt.Errorf("invalid parameters") } - log.Printf("Processing image audit callback: auditID=%s, result=%+v", auditID, result) + zap.L().Debug("Processing image audit callback", + zap.String("auditID", auditID), + zap.Bool("pass", result.Pass), + zap.String("risk", result.Risk), + ) // 根据审核结果执行相应操作 // 例如: 更新图片状态、删除违规图片等 @@ -568,7 +586,7 @@ func (s *AuditScheduler) processPendingAudits() { // 3. 更新审核状态 // 示例逻辑,实际需要根据业务需求实现 - log.Println("Processing pending audits...") + zap.L().Debug("Processing pending audits") } // CleanupOldLogs 清理旧的审核日志 diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 83cc4f3..6fd3bec 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "log" + "slices" "time" "carrot_bbs/internal/cache" @@ -13,6 +13,7 @@ import ( "carrot_bbs/internal/pkg/sse" "carrot_bbs/internal/repository" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -298,7 +299,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv // 异步写入缓存 go func() { if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { - log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err) + zap.L().Warn("async cache message failed", + zap.String("component", "ChatService"), + zap.String("convID", conversationID), + zap.String("msgID", message.ID), + zap.Error(err), + ) } }() @@ -379,12 +385,9 @@ func (s *chatServiceImpl) cacheMessage(ctx context.Context, convID string, msg * } func containsImageSegment(segments model.MessageSegments) bool { - for _, seg := range segments { - if seg.Type == string(model.ContentTypeImage) || seg.Type == "image" { - return true - } - } - return false + return slices.ContainsFunc(segments, func(seg model.MessageSegment) bool { + return seg.Type == string(model.ContentTypeImage) || seg.Type == "image" + }) } // GetMessages 获取消息历史(分页,带缓存) @@ -718,7 +721,12 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv // 异步写入缓存 go func() { if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { - log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err) + zap.L().Warn("async cache message failed", + zap.String("component", "ChatService"), + zap.String("convID", conversationID), + zap.String("msgID", message.ID), + zap.Error(err), + ) } }() diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index b697f97..0d167a3 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -4,13 +4,14 @@ import ( "context" "errors" "fmt" - "log" "strings" "carrot_bbs/internal/cache" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/repository" + + "go.uber.org/zap" ) // CommentService 评论服务 @@ -115,11 +116,16 @@ func (s *CommentService) reviewCommentAsync( // 未启用AI时,直接通过审核并发送后续通知 if s.postAIService == nil || !s.postAIService.IsEnabled() { if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil { - log.Printf("[WARN] Failed to publish comment without AI moderation: %v", err) + zap.L().Warn("Failed to publish comment without AI moderation", + zap.Error(err), + ) return } if err := s.applyCommentPublishedStats(commentID); err != nil { - log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, err) + zap.L().Warn("Failed to apply published stats for comment", + zap.String("commentID", commentID), + zap.Error(err), + ) } s.invalidatePostCaches(postID) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID) @@ -131,7 +137,10 @@ func (s *CommentService) reviewCommentAsync( var rejectedErr *CommentModerationRejectedError if errors.As(err, &rejectedErr) { if delErr := s.commentRepo.Delete(commentID); delErr != nil { - log.Printf("[WARN] Failed to delete rejected comment %s: %v", commentID, delErr) + zap.L().Warn("Failed to delete rejected comment", + zap.String("commentID", commentID), + zap.Error(delErr), + ) } s.notifyCommentModerationRejected(userID, rejectedErr.Reason) return @@ -139,24 +148,39 @@ func (s *CommentService) reviewCommentAsync( // 审核服务异常时降级放行,避免评论长期pending if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil { - log.Printf("[WARN] Failed to publish comment %s after moderation error: %v", commentID, updateErr) + zap.L().Warn("Failed to publish comment after moderation error", + zap.String("commentID", commentID), + zap.Error(updateErr), + ) return } if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil { - log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, statsErr) + zap.L().Warn("Failed to apply published stats for comment", + zap.String("commentID", commentID), + zap.Error(statsErr), + ) } s.invalidatePostCaches(postID) - log.Printf("[WARN] Comment moderation failed, fallback publish comment=%s err=%v", commentID, err) + zap.L().Warn("Comment moderation failed, fallback publish comment", + zap.String("commentID", commentID), + zap.Error(err), + ) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID) return } if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil { - log.Printf("[WARN] Failed to publish comment %s: %v", commentID, updateErr) + zap.L().Warn("Failed to publish comment", + zap.String("commentID", commentID), + zap.Error(updateErr), + ) return } if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil { - log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, statsErr) + zap.L().Warn("Failed to apply published stats for comment", + zap.String("commentID", commentID), + zap.Error(statsErr), + ) } s.invalidatePostCaches(postID) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID) @@ -184,7 +208,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string, if parentUserID != userID { notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID) if notifyErr != nil { - log.Printf("[ERROR] Error sending reply notification: %v", notifyErr) + zap.L().Error("Error sending reply notification", + zap.Error(notifyErr), + ) } } } else { @@ -192,7 +218,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string, if postOwnerID != userID { notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID) if notifyErr != nil { - log.Printf("[ERROR] Error sending comment notification: %v", notifyErr) + zap.L().Error("Error sending comment notification", + zap.Error(notifyErr), + ) } } } @@ -203,7 +231,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string, go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeComment, userID, postID); err != nil { - log.Printf("[WARN] Failed to insert comment feedback to Gorse: %v", err) + zap.L().Warn("Failed to insert comment feedback to Gorse", + zap.Error(err), + ) } } }() @@ -226,7 +256,9 @@ func (s *CommentService) notifyCommentModerationRejected(userID, reason string) "评论审核未通过", content, ); err != nil { - log.Printf("[WARN] Failed to send comment moderation reject notification: %v", err) + zap.L().Warn("Failed to send comment moderation reject notification", + zap.Error(err), + ) } }() } @@ -307,7 +339,9 @@ func (s *CommentService) Like(ctx context.Context, commentID, userID string) err ) } if notifyErr != nil { - log.Printf("[ERROR] Error sending like notification: %v", notifyErr) + zap.L().Error("Error sending like notification", + zap.Error(notifyErr), + ) } }() } diff --git a/internal/service/group_service.go b/internal/service/group_service.go index bf89d22..b6d54a3 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -1,9 +1,11 @@ package service import ( + "cmp" "errors" "fmt" - "log" + "maps" + "slices" "strconv" "time" @@ -14,6 +16,7 @@ import ( "carrot_bbs/internal/pkg/utils" "carrot_bbs/internal/repository" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -137,7 +140,10 @@ type groupNoticeMessage struct { func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMessage) { members, _, err := s.groupRepo.GetMembers(groupID, 1, 1000) if err != nil { - log.Printf("[groupService] 获取群成员失败: groupID=%s, err=%v", groupID, err) + zap.L().Error("获取群成员失败", + zap.String("groupID", groupID), + zap.Error(err), + ) return } if s.sseHub != nil { @@ -368,7 +374,10 @@ func (s *groupService) DissolveGroup(userID string, groupID string) error { // 先删除群组对应的会话(包括参与者、消息) if s.messageRepo != nil { if err := s.messageRepo.DeleteConversationByGroupID(groupID); err != nil { - log.Printf("[DissolveGroup] 删除会话失败: groupID=%s, err=%v", groupID, err) + zap.L().Warn("删除会话失败", + zap.String("groupID", groupID), + zap.Error(err), + ) // 继续删除群组,不因为会话删除失败而中断 } } @@ -453,7 +462,12 @@ func (s *groupService) createSystemNotification(receiverID string, notifyType mo ExtraData: extra, } if err := s.notifyRepo.Create(notification); err != nil { - log.Printf("[groupService] create system notification failed: receiverID=%s type=%s err=%v", receiverID, notifyType, err) + zap.L().Warn("create system notification failed", + zap.String("component", "groupService"), + zap.String("receiverID", receiverID), + zap.String("type", string(notifyType)), + zap.Error(err), + ) } } @@ -482,13 +496,22 @@ func (s *groupService) broadcastMemberJoinNotice(groupID string, targetUserID st Category: model.CategoryNotification, } if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { - log.Printf("[broadcastMemberJoinNotice] 保存入群提示消息失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err) + zap.L().Warn("保存入群提示消息失败", + zap.String("component", "broadcastMemberJoinNotice"), + zap.String("groupID", groupID), + zap.String("userID", targetUserID), + zap.Error(err), + ) } else { savedMessage = msg s.invalidateConversationCachesAfterSystemMessage(conv.ID) } } else { - log.Printf("[broadcastMemberJoinNotice] 获取群组会话失败: groupID=%s, err=%v", groupID, err) + zap.L().Warn("获取群组会话失败", + zap.String("component", "broadcastMemberJoinNotice"), + zap.String("groupID", groupID), + zap.Error(err), + ) } } @@ -542,7 +565,12 @@ func (s *groupService) addMemberToGroupAndConversation(group *model.Group, userI conv, err := s.messageRepo.GetConversationByGroupID(group.ID) if err == nil && conv != nil { if err := s.messageRepo.AddParticipant(conv.ID, userID); err != nil { - log.Printf("[addMemberToGroupAndConversation] 添加会话参与者失败: groupID=%s, userID=%s, err=%v", group.ID, userID, err) + zap.L().Warn("添加会话参与者失败", + zap.String("component", "addMemberToGroupAndConversation"), + zap.String("groupID", group.ID), + zap.String("userID", userID), + zap.Error(err), + ) } s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID) } @@ -568,11 +596,7 @@ func (s *groupService) collectGroupReviewerIDs(groupID string, ownerID string, s } } - result := make([]string, 0, len(reviewerSet)) - for id := range reviewerSet { - result = append(result, id) - } - return result + return slices.Collect(maps.Keys(reviewerSet)) } func (s *groupService) notifyJoinApplyReviewers( @@ -655,10 +679,8 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs [] inviter, _ := s.userRepo.GetByID(userID) inviterName := "群成员" inviterAvatar := "" - if inviter != nil && inviter.Nickname != "" { - inviterName = inviter.Nickname - } if inviter != nil { + inviterName = cmp.Or(inviter.Nickname, "群成员") inviterAvatar = inviter.Avatar } @@ -743,10 +765,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error { applicant, _ := s.userRepo.GetByID(userID) applicantName := "用户" applicantAvatar := "" - if applicant != nil && applicant.Nickname != "" { - applicantName = applicant.Nickname - } if applicant != nil { + applicantName = cmp.Or(applicant.Nickname, "用户") applicantAvatar = applicant.Avatar } // 已有待审批单时补发一次提醒,避免管理端漏看 @@ -767,10 +787,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error { applicant, _ := s.userRepo.GetByID(userID) applicantName := "用户" applicantAvatar := "" - if applicant != nil && applicant.Nickname != "" { - applicantName = applicant.Nickname - } if applicant != nil { + applicantName = cmp.Or(applicant.Nickname, "用户") applicantAvatar = applicant.Avatar } expireAt := time.Now().Add(72 * time.Hour) @@ -856,9 +874,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r targetUserName := "用户" targetUserAvatar := "" if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { - if u.Nickname != "" { - targetUserName = u.Nickname - } + targetUserName = cmp.Or(u.Nickname, "用户") targetUserAvatar = u.Avatar } @@ -866,9 +882,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r inviterName := "群成员" inviterAvatar := "" if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil { - if u.Nickname != "" { - inviterName = u.Nickname - } + inviterName = cmp.Or(u.Nickname, "群成员") inviterAvatar = u.Avatar } @@ -962,15 +976,13 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo // 保存管理员ID到ReviewerID(覆盖被邀请人的ID) req.Reason = reason targetUserName := "用户" - if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil && u.Nickname != "" { - targetUserName = u.Nickname + if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil { + targetUserName = cmp.Or(u.Nickname, "用户") } reviewerName := "管理员" reviewerAvatar := "" if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { - if u.Nickname != "" { - reviewerName = u.Nickname - } + reviewerName = cmp.Or(u.Nickname, "管理员") reviewerAvatar = u.Avatar } @@ -1185,7 +1197,12 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID conv, err := s.messageRepo.GetConversationByGroupID(groupID) if err == nil && conv != nil { if err := s.messageRepo.RemoveParticipant(conv.ID, targetUserID); err != nil { - log.Printf("[RemoveMember] 移除会话参与者失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err) + zap.L().Warn("移除会话参与者失败", + zap.String("component", "RemoveMember"), + zap.String("groupID", groupID), + zap.String("userID", targetUserID), + zap.Error(err), + ) } s.invalidateConversationCachesAfterMembershipChange(conv.ID, targetUserID) } @@ -1330,22 +1347,35 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st member, err := s.groupRepo.GetMember(groupID, targetUserID) if err != nil { - log.Printf("[MuteMember] 获取成员失败: %v", err) + zap.L().Warn("获取成员失败", + zap.String("component", "MuteMember"), + zap.Error(err), + ) return err } - log.Printf("[MuteMember] 禁言前状态: member.Muted=%v, 即将设置 muted=%v", member.Muted, muted) + zap.L().Debug("禁言前状态", + zap.String("component", "MuteMember"), + zap.Bool("member.Muted", member.Muted), + zap.Bool("muted", muted), + ) member.Muted = muted if err := s.groupRepo.UpdateMember(member); err != nil { - log.Printf("[MuteMember] 更新成员失败: %v", err) + zap.L().Warn("更新成员失败", + zap.String("component", "MuteMember"), + zap.Error(err), + ) return err } - log.Printf("[MuteMember] 禁言状态已更新到数据库") + zap.L().Debug("禁言状态已更新到数据库", zap.String("component", "MuteMember")) // 验证更新结果 updatedMember, _ := s.groupRepo.GetMember(groupID, targetUserID) if updatedMember != nil { - log.Printf("[MuteMember] 验证: member.Muted=%v", updatedMember.Muted) + zap.L().Debug("验证成员状态", + zap.String("component", "MuteMember"), + zap.Bool("member.Muted", updatedMember.Muted), + ) } // 获取被禁言用户的显示名称 @@ -1382,14 +1412,24 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st // 保存消息并获取 seq if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { - log.Printf("[MuteMember] 保存禁言消息失败: %v", err) + zap.L().Warn("保存禁言消息失败", + zap.String("component", "MuteMember"), + zap.Error(err), + ) } else { savedMessage = msg - log.Printf("[MuteMember] 禁言消息已保存, ID=%s, Seq=%d", msg.ID, msg.Seq) + zap.L().Debug("禁言消息已保存", + zap.String("component", "MuteMember"), + zap.String("msgID", msg.ID), + zap.Int64("seq", msg.Seq), + ) s.invalidateConversationCachesAfterSystemMessage(conv.ID) } } else { - log.Printf("[MuteMember] 获取群组会话失败: %v", err) + zap.L().Warn("获取群组会话失败", + zap.String("component", "MuteMember"), + zap.Error(err), + ) } } diff --git a/internal/service/message_service.go b/internal/service/message_service.go index b0a5acc..5b6c00d 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -2,13 +2,13 @@ package service import ( "context" - "log" "time" "carrot_bbs/internal/cache" "carrot_bbs/internal/model" "carrot_bbs/internal/repository" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -94,7 +94,12 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s // 异步写入缓存 go func() { if err := s.cacheMessage(context.Background(), conv.ID, msg); err != nil { - log.Printf("[MessageService] async cache message failed, convID=%s, msgID=%s, err=%v", conv.ID, msg.ID, err) + zap.L().Warn("async cache message failed", + zap.String("component", "MessageService"), + zap.String("convID", conv.ID), + zap.String("msgID", msg.ID), + zap.Error(err), + ) } }() diff --git a/internal/service/post_ai_service.go b/internal/service/post_ai_service.go index 23c947d..e27a00d 100644 --- a/internal/service/post_ai_service.go +++ b/internal/service/post_ai_service.go @@ -2,10 +2,11 @@ package service import ( "context" - "log" "strings" "carrot_bbs/internal/pkg/openai" + + "go.uber.org/zap" ) // PostModerationRejectedError 帖子审核拒绝错误 @@ -73,7 +74,9 @@ func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, if s.openAIClient.Config().StrictModeration { return err } - log.Printf("[WARN] AI moderation failed, fallback allow: %v", err) + zap.L().Warn("AI moderation failed, fallback allow", + zap.Error(err), + ) return nil } if !approved { @@ -93,7 +96,9 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima if s.openAIClient.Config().StrictModeration { return err } - log.Printf("[WARN] AI comment moderation failed, fallback allow: %v", err) + zap.L().Warn("AI comment moderation failed, fallback allow", + zap.Error(err), + ) return nil } if !approved { diff --git a/internal/service/post_service.go b/internal/service/post_service.go index d045cac..e558198 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -514,10 +514,9 @@ func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs return isLikedMap, isFavoritedMap, nil } - for _, postID := range postIDs { - isLikedMap[postID] = s.postRepo.IsLiked(postID, userID) - isFavoritedMap[postID] = s.postRepo.IsFavorited(postID, userID) - } + // 使用批量查询替代循环中的单个查询,解决 N+1 问题 + isLikedMap = s.postRepo.IsLikedBatch(postIDs, userID) + isFavoritedMap = s.postRepo.IsFavoritedBatch(postIDs, userID) return isLikedMap, isFavoritedMap, nil } diff --git a/internal/service/schedule_service.go b/internal/service/schedule_service.go index 039d2a0..a43662c 100644 --- a/internal/service/schedule_service.go +++ b/internal/service/schedule_service.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "regexp" + "slices" "sort" "strings" @@ -188,12 +189,7 @@ func normalizeWeeks(source []int) []int { } func containsWeek(weeks []int, target int) bool { - for _, week := range weeks { - if week == target { - return true - } - } - return false + return slices.Contains(weeks, target) } func normalizeHexColor(color string) string { diff --git a/internal/service/sensitive_service.go b/internal/service/sensitive_service.go index 8ef69fa..fc57ae8 100644 --- a/internal/service/sensitive_service.go +++ b/internal/service/sensitive_service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "regexp" "strings" "sync" @@ -14,6 +13,7 @@ import ( "carrot_bbs/internal/model" redisclient "carrot_bbs/internal/pkg/redis" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -289,13 +289,17 @@ func NewSensitiveService(db *gorm.DB, redisClient *redisclient.Client, config *S // 初始化加载敏感词 if config.LoadFromDB { if err := s.loadFromDB(context.Background()); err != nil { - log.Printf("Failed to load sensitive words from database: %v", err) + zap.L().Warn("Failed to load sensitive words from database", + zap.Error(err), + ) } } if config.LoadFromRedis && redisClient != nil { if err := s.loadFromRedis(context.Background()); err != nil { - log.Printf("Failed to load sensitive words from redis: %v", err) + zap.L().Warn("Failed to load sensitive words from redis", + zap.Error(err), + ) } } @@ -365,7 +369,9 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor result := s.db.Where("word = ?", word).First(&existing) if result.Error == gorm.ErrRecordNotFound { if err := s.db.Create(&sensitiveWord).Error; err != nil { - log.Printf("Failed to save sensitive word to database: %v", err) + zap.L().Warn("Failed to save sensitive word to database", + zap.Error(err), + ) } } else if result.Error == nil { // 更新已存在的记录 @@ -373,7 +379,9 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor existing.Level = wordLevel existing.IsActive = true if err := s.db.Save(&existing).Error; err != nil { - log.Printf("Failed to update sensitive word in database: %v", err) + zap.L().Warn("Failed to update sensitive word in database", + zap.Error(err), + ) } } } @@ -406,7 +414,9 @@ func (s *sensitiveServiceImpl) RemoveWord(ctx context.Context, word string) erro if s.db != nil { result := s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Update("is_active", false) if result.Error != nil { - log.Printf("Failed to deactivate sensitive word in database: %v", result.Error) + zap.L().Warn("Failed to deactivate sensitive word in database", + zap.Error(result.Error), + ) } } @@ -461,7 +471,9 @@ func (s *sensitiveServiceImpl) loadFromDB(ctx context.Context) error { s.tree.AddWord(word.Word, word.Level, word.Category) } - log.Printf("Loaded %d sensitive words from database", len(words)) + zap.L().Info("Loaded sensitive words from database", + zap.Int("count", len(words)), + ) return nil } diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 4042465..2a1fd54 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "cmp" "context" "crypto/sha256" "fmt" @@ -9,7 +10,6 @@ import ( "image/jpeg" "image/png" "io" - "log" "mime" "mime/multipart" "net/http" @@ -17,7 +17,9 @@ import ( "strings" "carrot_bbs/internal/pkg/s3" + "github.com/disintegration/imaging" + "go.uber.org/zap" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" @@ -65,7 +67,9 @@ func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHea // 生成预览图 previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr) if err != nil { - log.Printf("[WARN] Failed to generate preview images: %v", err) + zap.L().Warn("Failed to generate preview images", + zap.Error(err), + ) } return url, previewURL, previewURLLarge, nil @@ -189,10 +193,7 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string, // 优先从文件字节探测真实类型,避免前端压缩/转码后 header 与实际格式不一致 detectedType := normalizeImageContentType(http.DetectContentType(originalData)) headerType := normalizeImageContentType(file.Header.Get("Content-Type")) - contentType := detectedType - if contentType == "" || contentType == "application/octet-stream" { - contentType = headerType - } + contentType := cmp.Or(detectedType, headerType, "application/octet-stream") compressedData, compressedType, err := compressImageData(originalData, contentType) if err != nil { @@ -201,21 +202,9 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string, compressedType = contentType } - if compressedType == "" { - compressedType = contentType - } - if compressedType == "" { - compressedType = http.DetectContentType(compressedData) - } + compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData)) - ext := getExtFromContentType(compressedType) - if ext == "" { - ext = strings.ToLower(filepath.Ext(file.Filename)) - } - if ext == "" { - // 最终兜底,避免对象名无扩展名导致 URL 语义不明确 - ext = ".jpg" - } + ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(file.Filename)), ".jpg") return compressedData, compressedType, ext, nil } @@ -302,13 +291,17 @@ func (s *UploadService) GeneratePreviewImages(ctx context.Context, originalData // 生成普通预览图(列表/网格模式) previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash) if err != nil { - log.Printf("[WARN] Failed to generate preview: %v", err) + zap.L().Warn("Failed to generate preview", + zap.Error(err), + ) } // 生成大预览图(详情页) previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash) if err != nil { - log.Printf("[WARN] Failed to generate large preview: %v", err) + zap.L().Warn("Failed to generate large preview", + zap.Error(err), + ) } return previewURL, previewURLLarge, nil diff --git a/internal/service/user_activity_service.go b/internal/service/user_activity_service.go index d5054e5..09bd3b3 100644 --- a/internal/service/user_activity_service.go +++ b/internal/service/user_activity_service.go @@ -3,10 +3,11 @@ package service import ( "context" "fmt" - "log" "time" "carrot_bbs/internal/repository" + + "go.uber.org/zap" ) // UserActivityService 用户活跃统计服务接口 @@ -76,14 +77,18 @@ func (s *userActivityService) RecordUserActive(ctx context.Context, userID, logi // 1. 记录到 Redis(同步,快速) if err := s.repo.RecordActivityToRedis(ctx, userID); err != nil { // 记录日志但不阻断流程 - log.Printf("failed to record activity to redis: %v", err) + zap.L().Warn("failed to record activity to redis", + zap.Error(err), + ) } // 2. 异步写入数据库(可以使用 goroutine 或消息队列) go func() { bgCtx := context.Background() if err := s.repo.RecordActivity(bgCtx, userID, loginType, ip, userAgent); err != nil { - log.Printf("failed to record activity to db: %v", err) + zap.L().Warn("failed to record activity to db", + zap.Error(err), + ) } }() @@ -158,7 +163,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt // 获取今日 DAU todayDAU, err := s.GetDAU(ctx, today) if err != nil { - log.Printf("failed to get today DAU: %v", err) + zap.L().Warn("failed to get today DAU", + zap.Error(err), + ) } resp.Today = DailyStats{ Date: today.Format("2006-01-02"), @@ -168,7 +175,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt // 获取昨日 DAU yesterdayDAU, err := s.GetDAU(ctx, yesterday) if err != nil { - log.Printf("failed to get yesterday DAU: %v", err) + zap.L().Warn("failed to get yesterday DAU", + zap.Error(err), + ) } resp.Yesterday = DailyStats{ Date: yesterday.Format("2006-01-02"), @@ -178,7 +187,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt // 获取本周 WAU wau, err := s.GetWAU(ctx, year, week) if err != nil { - log.Printf("failed to get WAU: %v", err) + zap.L().Warn("failed to get WAU", + zap.Error(err), + ) } resp.ThisWeek = PeriodStats{ Period: fmt.Sprintf("%d-W%02d", year, week), @@ -188,7 +199,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt // 获取本月 MAU mau, err := s.GetMAU(ctx, now.Year(), int(now.Month())) if err != nil { - log.Printf("failed to get MAU: %v", err) + zap.L().Warn("failed to get MAU", + zap.Error(err), + ) } resp.ThisMonth = PeriodStats{ Period: now.Format("2006-01"), @@ -199,7 +212,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt // 次日留存 day1Retention, err := s.GetRetention(ctx, yesterday, today) if err != nil { - log.Printf("failed to get day 1 retention: %v", err) + zap.L().Warn("failed to get day 1 retention", + zap.Error(err), + ) } resp.Retention.Day1 = day1Retention @@ -207,7 +222,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt day7Date := today.AddDate(0, 0, -7) day7Retention, err := s.GetRetention(ctx, day7Date, today) if err != nil { - log.Printf("failed to get day 7 retention: %v", err) + zap.L().Warn("failed to get day 7 retention", + zap.Error(err), + ) } resp.Retention.Day7 = day7Retention @@ -215,7 +232,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt day30Date := today.AddDate(0, 0, -30) day30Retention, err := s.GetRetention(ctx, day30Date, today) if err != nil { - log.Printf("failed to get day 30 retention: %v", err) + zap.L().Warn("failed to get day 30 retention", + zap.Error(err), + ) } resp.Retention.Day30 = day30Retention diff --git a/internal/wire/casbin.go b/internal/wire/casbin.go index dd220cd..83e6769 100644 --- a/internal/wire/casbin.go +++ b/internal/wire/casbin.go @@ -1,8 +1,6 @@ package wire import ( - "log" - "carrot_bbs/internal/cache" "carrot_bbs/internal/config" "carrot_bbs/internal/repository" @@ -11,6 +9,7 @@ import ( "github.com/casbin/casbin/v3" gormadapter "github.com/casbin/gorm-adapter/v3" "github.com/google/wire" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -26,21 +25,27 @@ func ProvideCasbinEnforcer(cfg *config.Config, db *gorm.DB) *casbin.Enforcer { // 创建 GORM 适配器,连接数据库 adapter, err := gormadapter.NewAdapterByDB(db) if err != nil { - log.Fatalf("Failed to create Casbin GORM adapter: %v", err) + zap.L().Fatal("Failed to create Casbin GORM adapter", + zap.Error(err), + ) } // 创建 Casbin Enforcer,使用数据库适配器 enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter) if err != nil { - log.Fatalf("Failed to create Casbin enforcer: %v", err) + zap.L().Fatal("Failed to create Casbin enforcer", + zap.Error(err), + ) } // 从数据库加载策略 if err := enforcer.LoadPolicy(); err != nil { - log.Printf("[WARNING] Failed to load Casbin policy: %v", err) + zap.L().Warn("Failed to load Casbin policy", + zap.Error(err), + ) } - log.Println("[Wire] Initialized Casbin enforcer with GORM adapter") + zap.L().Info("Initialized Casbin enforcer with GORM adapter") return enforcer } diff --git a/internal/wire/infrastructure.go b/internal/wire/infrastructure.go index 3fbf8f7..9c22d4f 100644 --- a/internal/wire/infrastructure.go +++ b/internal/wire/infrastructure.go @@ -1,7 +1,6 @@ package wire import ( - "log" "time" "carrot_bbs/internal/cache" @@ -62,7 +61,9 @@ func ProvideDB(cfg *config.Config) (*gorm.DB, error) { func ProvideRedisClient(cfg *config.Config) *redis.Client { client, err := redis.New(&cfg.Redis) if err != nil { - log.Printf("[WARNING] Failed to connect to Redis: %v, falling back to memory cache", err) + zap.L().Warn("Failed to connect to Redis, falling back to memory cache", + zap.Error(err), + ) return nil } return client @@ -85,12 +86,12 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache { // 直接创建 RedisCache 实例,不依赖全局变量 if redisClient == nil { - log.Println("[Wire] Warning: Redis client is nil, cache will not be available") + zap.L().Warn("Redis client is nil, cache will not be available") return nil } cacheInstance := cache.NewRedisCache(redisClient) - log.Println("[Wire] Initialized Redis cache via dependency injection") + zap.L().Info("Initialized Redis cache via dependency injection") return cacheInstance } @@ -128,7 +129,9 @@ func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager { func ProvideLogger() *zap.Logger { logger, err := zap.NewProduction() if err != nil { - log.Fatalf("Failed to create logger: %v", err) + panic("Failed to create logger: " + err.Error()) } + // 设置为全局 logger,以便在其他地方使用 zap.L() + zap.ReplaceGlobals(logger) return logger }