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
This commit is contained in:
lafay
2026-03-17 00:47:17 +08:00
parent b028f7e1d3
commit 5d6c982c9c
38 changed files with 2256 additions and 290 deletions

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ logs/
# Local environment files # Local environment files
.env .env
.env.* .env.*
plans/

View File

@@ -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 代码应该以最好的方式显得“乏味”——可预测、一致且易于理解。如有疑问,保持简单。

View File

@@ -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.

View File

@@ -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 Ill stick to modern Go best practices and freely use language features up to and including this version. If youd 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)
}
```

View File

@@ -1,10 +1,12 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"go.uber.org/zap"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -73,17 +75,17 @@ func main() {
defer sqlDB.Close() defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil { 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 { 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 { func runMigration(db *gorm.DB) error {
@@ -105,26 +107,31 @@ func runMigration(db *gorm.DB) error {
} }
if hasPreviewURL && hasPreviewURLLarge { if hasPreviewURL && hasPreviewURLLarge {
log.Println("Columns already exist, skipping migration") zap.L().Info("Columns already exist, skipping migration")
return nil return nil
} }
// 添加字段 // 添加字段
var errs []error
if !hasPreviewURL { 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 { 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 { 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 { 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 return nil
} }

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/http" "net/http"
"carrot_bbs/internal/config" "carrot_bbs/internal/config"
@@ -11,6 +10,7 @@ import (
"carrot_bbs/internal/router" "carrot_bbs/internal/router"
"carrot_bbs/internal/service" "carrot_bbs/internal/service"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,6 +22,7 @@ type App struct {
PushService service.PushService PushService service.PushService
GRPCServer *runner.Server GRPCServer *runner.Server
Server *http.Server Server *http.Server
Logger *zap.Logger
} }
// NewApp 创建应用程序 // NewApp 创建应用程序
@@ -31,6 +32,7 @@ func NewApp(
r *router.Router, r *router.Router,
pushService service.PushService, pushService service.PushService,
grpcServer *runner.Server, grpcServer *runner.Server,
logger *zap.Logger,
) *App { ) *App {
return &App{ return &App{
Config: cfg, Config: cfg,
@@ -38,6 +40,7 @@ func NewApp(
Router: r, Router: r,
PushService: pushService, PushService: pushService,
GRPCServer: grpcServer, GRPCServer: grpcServer,
Logger: logger,
} }
} }
@@ -45,7 +48,7 @@ func NewApp(
func (a *App) Start(ctx context.Context) error { func (a *App) Start(ctx context.Context) error {
// 1. 启动 gRPC 服务器(在 HTTP 服务器之前) // 1. 启动 gRPC 服务器(在 HTTP 服务器之前)
if a.GRPCServer != nil { if a.GRPCServer != nil {
log.Println("Starting gRPC server...") a.Logger.Info("Starting gRPC server")
if err := a.GRPCServer.Start(); err != nil { if err := a.GRPCServer.Start(); err != nil {
return fmt.Errorf("failed to start gRPC server: %w", err) return fmt.Errorf("failed to start gRPC server: %w", err)
} }
@@ -56,7 +59,9 @@ func (a *App) Start(ctx context.Context) error {
// 3. 创建 HTTP 服务器 // 3. 创建 HTTP 服务器
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port) 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{ a.Server = &http.Server{
Addr: addr, Addr: addr,
@@ -66,7 +71,9 @@ func (a *App) Start(ctx context.Context) error {
// 4. 启动 HTTP 服务器 // 4. 启动 HTTP 服务器
go func() { go func() {
if err := a.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 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 停止应用程序 // Stop 停止应用程序
func (a *App) Stop(ctx context.Context) error { func (a *App) Stop(ctx context.Context) error {
log.Println("Shutting down server...") a.Logger.Info("Shutting down server")
// 1. 停止 HTTP 服务器 // 1. 停止 HTTP 服务器
if a.Server != nil { if a.Server != nil {
log.Println("Stopping HTTP server...") a.Logger.Info("Stopping HTTP server")
if err := a.Server.Shutdown(ctx); err != nil { 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 服务器 // 2. 停止 gRPC 服务器
if a.GRPCServer != nil { if a.GRPCServer != nil {
log.Println("Stopping gRPC server...") a.Logger.Info("Stopping gRPC server")
a.GRPCServer.Stop() a.GRPCServer.Stop()
} }
// 3. 停止推送 Worker // 3. 停止推送 Worker
log.Println("Stopping push worker...") a.Logger.Info("Stopping push worker")
a.PushService.StopPushWorker() a.PushService.StopPushWorker()
// 4. 关闭数据库连接 // 4. 关闭数据库连接
log.Println("Closing database connection...") a.Logger.Info("Closing database connection")
sqlDB, err := a.DB.DB() sqlDB, err := a.DB.DB()
if err == nil { if err == nil {
sqlDB.Close() sqlDB.Close()
} }
log.Println("Server exited") a.Logger.Info("Server exited")
return nil return nil
} }

View File

@@ -2,24 +2,29 @@ package main
import ( import (
"context" "context"
"log"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"go.uber.org/zap"
) )
func main() { func main() {
// 初始化应用程序Wire 自动生成) // 初始化应用程序Wire 自动生成)
app, err := InitializeApp() app, err := InitializeApp()
if err != nil { if err != nil {
log.Fatalf("failed to initialize app: %v", err) zap.L().Fatal("failed to initialize app",
zap.Error(err),
)
} }
// 启动应用程序 // 启动应用程序
ctx := context.Background() ctx := context.Background()
if err := app.Start(ctx); err != nil { 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() defer cancel()
if err := app.Stop(shutdownCtx); err != nil { if err := app.Stop(shutdownCtx); err != nil {
log.Printf("Error during shutdown: %v", err) app.Logger.Error("Error during shutdown",
zap.Error(err),
)
} }
} }

View File

@@ -109,7 +109,7 @@ func InitializeApp() (*App, error) {
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService) adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService) 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) 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 return app, nil
} }

1
go.mod
View File

@@ -4,7 +4,6 @@ go 1.25
require ( require (
github.com/alicebob/miniredis/v2 v2.31.0 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/casbin/v3 v3.10.0
github.com/casbin/gorm-adapter/v3 v3.41.0 github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1

View File

@@ -1,6 +1,7 @@
package cache package cache
import ( import (
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"math/rand" "math/rand"
@@ -123,17 +124,11 @@ func GetSettings() Settings {
} }
func normalizeKey(key string) string { func normalizeKey(key string) string {
if settings.KeyPrefix == "" { return cmp.Or(settings.KeyPrefix, "") + ":" + key
return key
}
return settings.KeyPrefix + ":" + key
} }
func normalizePrefix(prefix string) string { func normalizePrefix(prefix string) string {
if settings.KeyPrefix == "" { return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
return prefix
}
return settings.KeyPrefix + ":" + prefix
} }
func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) { func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) {

View File

@@ -4,10 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"time" "time"
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
"go.uber.org/zap"
) )
// CachedConversation 带缓存元数据的会话 // CachedConversation 带缓存元数据的会话
@@ -713,6 +714,10 @@ func (c *ConversationCache) getMessagesBySeqs(ctx context.Context, convID string
// asyncCacheMessage 异步缓存单条消息 // asyncCacheMessage 异步缓存单条消息
func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) { func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) {
if err := c.CacheMessage(ctx, convID, msg); err != nil { 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),
)
} }
} }

View File

@@ -3,10 +3,10 @@ package cache
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap"
redisPkg "carrot_bbs/internal/pkg/redis" 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) data, err := json.Marshal(value)
if err != nil { if err != nil {
recordSetError() 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 return
} }
if err := c.client.Set(c.ctx, key, data, ttl); err != nil { if err := c.client.Set(c.ctx, key, data, ttl); err != nil {
recordSetError() 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 { if err == redis.Nil {
return nil, false 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 return nil, false
} }
@@ -63,7 +72,10 @@ func (c *RedisCache) Delete(key string) {
key = normalizeKey(key) key = normalizeKey(key)
recordInvalidate() recordInvalidate()
if err := c.client.Del(c.ctx, key); err != nil { 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 { for {
keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result() keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result()
if err != nil { 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 return
} }
if len(keys) > 0 { if len(keys) > 0 {
recordInvalidateMultiple(int64(len(keys))) recordInvalidateMultiple(int64(len(keys)))
if err := c.client.Del(c.ctx, keys...); err != nil { 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 清空所有缓存 // Clear 清空所有缓存
func (c *RedisCache) Clear() { func (c *RedisCache) Clear() {
if settings.DisableFlushDB { if settings.DisableFlushDB {
log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true") zap.L().Debug("Skip FlushDB because cache.disable_flushdb=true")
return return
} }
recordInvalidate() recordInvalidate()
rdb := c.client.GetClient() rdb := c.client.GetClient()
if err := rdb.FlushDB(c.ctx).Err(); err != nil { 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) key = normalizeKey(key)
n, err := c.client.Exists(c.ctx, key) n, err := c.client.Exists(c.ctx, key)
if err != nil { 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 false
} }
return n > 0 return n > 0
@@ -129,7 +152,10 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
rdb := c.client.GetClient() rdb := c.client.GetClient()
result, err := rdb.IncrBy(c.ctx, key, value).Result() result, err := rdb.IncrBy(c.ctx, key, value).Result()
if err != nil { 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 0
} }
return result return result

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"cmp"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@@ -236,8 +237,5 @@ func Load(configPath string) (*Config, error) {
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值 // getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
func getEnvOrDefault(key, defaultValue string) string { func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { return cmp.Or(os.Getenv(key), defaultValue)
return value
}
return defaultValue
} }

View File

@@ -224,34 +224,34 @@ type TextSegmentData struct {
// ImageSegmentData 图片数据 // ImageSegmentData 图片数据
type ImageSegmentData struct { type ImageSegmentData struct {
URL string `json:"url"` URL string `json:"url"`
Width int `json:"width,omitempty"` Width int `json:"width,omitzero"`
Height int `json:"height,omitempty"` Height int `json:"height,omitzero"`
ThumbnailURL string `json:"thumbnail_url,omitempty"` ThumbnailURL string `json:"thumbnail_url,omitempty"`
FileSize int64 `json:"file_size,omitempty"` FileSize int64 `json:"file_size,omitzero"`
} }
// VoiceSegmentData 语音数据 // VoiceSegmentData 语音数据
type VoiceSegmentData struct { type VoiceSegmentData struct {
URL string `json:"url"` URL string `json:"url"`
Duration int `json:"duration,omitempty"` // 秒 Duration int `json:"duration,omitzero"` // 秒
FileSize int64 `json:"file_size,omitempty"` FileSize int64 `json:"file_size,omitzero"`
} }
// VideoSegmentData 视频数据 // VideoSegmentData 视频数据
type VideoSegmentData struct { type VideoSegmentData struct {
URL string `json:"url"` URL string `json:"url"`
Width int `json:"width,omitempty"` Width int `json:"width,omitzero"`
Height int `json:"height,omitempty"` Height int `json:"height,omitzero"`
Duration int `json:"duration,omitempty"` // 秒 Duration int `json:"duration,omitzero"` // 秒
ThumbnailURL string `json:"thumbnail_url,omitempty"` ThumbnailURL string `json:"thumbnail_url,omitempty"`
FileSize int64 `json:"file_size,omitempty"` FileSize int64 `json:"file_size,omitzero"`
} }
// FileSegmentData 文件数据 // FileSegmentData 文件数据
type FileSegmentData struct { type FileSegmentData struct {
URL string `json:"url"` URL string `json:"url"`
Name string `json:"name"` Name string `json:"name"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitzero"`
MimeType string `json:"mime_type,omitempty"` MimeType string `json:"mime_type,omitempty"`
} }
@@ -268,7 +268,7 @@ type ReplySegmentData struct {
// FaceSegmentData 表情数据 // FaceSegmentData 表情数据
type FaceSegmentData struct { type FaceSegmentData struct {
ID int `json:"id"` ID int `json:"id,omitzero"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
} }
@@ -458,7 +458,7 @@ type DeviceTokenResponse struct {
DeviceType string `json:"device_type"` DeviceType string `json:"device_type"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
DeviceName string `json:"device_name"` 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"` CreatedAt time.Time `json:"created_at"`
} }
@@ -470,9 +470,9 @@ type PushRecordResponse struct {
MessageID string `json:"message_id"` MessageID string `json:"message_id"`
PushChannel string `json:"push_channel"` PushChannel string `json:"push_channel"`
PushStatus string `json:"push_status"` PushStatus string `json:"push_status"`
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count,omitzero"`
PushedAt time.Time `json:"pushed_at,omitempty"` PushedAt time.Time `json:"pushed_at,omitzero"`
DeliveredAt time.Time `json:"delivered_at,omitempty"` DeliveredAt time.Time `json:"delivered_at,omitzero"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -859,7 +859,7 @@ type VoteResultDTO struct {
Options []VoteOptionDTO `json:"options"` Options []VoteOptionDTO `json:"options"`
TotalVotes int `json:"total_votes"` TotalVotes int `json:"total_votes"`
HasVoted bool `json:"has_voted"` HasVoted bool `json:"has_voted"`
VotedOptionID string `json:"voted_option_id,omitempty"` VotedOptionID string `json:"voted_option_id,omitzero"`
} }
// ==================== WebSocket Response DTOs ==================== // ==================== WebSocket Response DTOs ====================
@@ -882,10 +882,10 @@ type AdminPostListResponse struct {
Content string `json:"content"` Content string `json:"content"`
Images []PostImageResponse `json:"images"` Images []PostImageResponse `json:"images"`
Status string `json:"status"` Status string `json:"status"`
LikesCount int `json:"likes_count"` LikesCount int `json:"likes_count,omitzero"`
CommentsCount int `json:"comments_count"` CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count"` FavoritesCount int `json:"favorites_count,omitzero"`
ViewsCount int `json:"views_count"` ViewsCount int `json:"views_count,omitzero"`
IsPinned bool `json:"is_pinned"` IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"` IsFeatured bool `json:"is_featured"`
IsLocked bool `json:"is_locked"` IsLocked bool `json:"is_locked"`
@@ -906,11 +906,11 @@ type AdminPostDetailResponse struct {
Content string `json:"content"` Content string `json:"content"`
Images []PostImageResponse `json:"images"` Images []PostImageResponse `json:"images"`
Status string `json:"status"` Status string `json:"status"`
LikesCount int `json:"likes_count"` LikesCount int `json:"likes_count,omitzero"`
CommentsCount int `json:"comments_count"` CommentsCount int `json:"comments_count,omitzero"`
FavoritesCount int `json:"favorites_count"` FavoritesCount int `json:"favorites_count,omitzero"`
SharesCount int `json:"shares_count"` SharesCount int `json:"shares_count,omitzero"`
ViewsCount int `json:"views_count"` ViewsCount int `json:"views_count,omitzero"`
HotScore float64 `json:"hot_score"` HotScore float64 `json:"hot_score"`
IsPinned bool `json:"is_pinned"` IsPinned bool `json:"is_pinned"`
IsFeatured bool `json:"is_featured"` IsFeatured bool `json:"is_featured"`

View File

@@ -5,9 +5,9 @@ type ScheduleCourseResponse struct {
Name string `json:"name"` Name string `json:"name"`
Teacher string `json:"teacher,omitempty"` Teacher string `json:"teacher,omitempty"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
DayOfWeek int `json:"day_of_week"` DayOfWeek int `json:"day_of_week,omitzero"`
StartSection int `json:"start_section"` StartSection int `json:"start_section,omitzero"`
EndSection int `json:"end_section"` EndSection int `json:"end_section,omitzero"`
Weeks []int `json:"weeks"` Weeks []int `json:"weeks"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
} }

View File

@@ -3,6 +3,7 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
) )
@@ -232,26 +233,28 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
// IsAtAll 检查消息是否@了所有人 // IsAtAll 检查消息是否@了所有人
func IsAtAll(segments model.MessageSegments) bool { 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 segment.Type == string(SegmentTypeAt) {
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" { if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
return true return true
} }
} }
}
return false return false
})
} }
// GetReplyMessageID 从消息链中获取被回复的消息ID // GetReplyMessageID 从消息链中获取被回复的消息ID
// 如果没有回复segment返回空字符串 // 如果没有回复segment返回空字符串
func GetReplyMessageID(segments model.MessageSegments) string { func GetReplyMessageID(segments model.MessageSegments) string {
for _, segment := range segments { idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
if segment.Type == string(SegmentTypeReply) { return segment.Type == string(SegmentTypeReply)
if id, ok := segment.Data["id"].(string); ok { })
if idx == -1 {
return ""
}
if id, ok := segments[idx].Data["id"].(string); ok {
return id return id
} }
}
}
return "" return ""
} }
@@ -291,12 +294,9 @@ func BuildSegmentsFromContent(contentType, content string, mediaURL *string) mod
// HasSegmentType 检查消息链中是否包含指定类型的segment // HasSegmentType 检查消息链中是否包含指定类型的segment
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool { func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
for _, segment := range segments { return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
if segment.Type == string(segmentType) { return segment.Type == string(segmentType)
return true })
}
}
return false
} }
// GetSegmentsByType 获取消息链中所有指定类型的segment // GetSegmentsByType 获取消息链中所有指定类型的segment
@@ -313,27 +313,34 @@ func GetSegmentsByType(segments model.MessageSegments, segmentType SegmentType)
// GetFirstImageURL 获取消息链中第一张图片的URL // GetFirstImageURL 获取消息链中第一张图片的URL
// 如果没有图片,返回空字符串 // 如果没有图片,返回空字符串
func GetFirstImageURL(segments model.MessageSegments) string { func GetFirstImageURL(segments model.MessageSegments) string {
for _, segment := range segments { idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
if segment.Type == string(SegmentTypeImage) { return segment.Type == string(SegmentTypeImage)
if url, ok := segment.Data["url"].(string); ok { })
if idx == -1 {
return ""
}
if url, ok := segments[idx].Data["url"].(string); ok {
return url return url
} }
}
}
return "" return ""
} }
// GetFirstMediaURL 获取消息链中第一个媒体文件的URL图片/视频/语音/文件) // GetFirstMediaURL 获取消息链中第一个媒体文件的URL图片/视频/语音/文件)
// 用于兼容旧版本API // 用于兼容旧版本API
func GetFirstMediaURL(segments model.MessageSegments) string { func GetFirstMediaURL(segments model.MessageSegments) string {
for _, segment := range segments { idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
switch segment.Type { switch segment.Type {
case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile): case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile):
if url, ok := segment.Data["url"].(string); ok { return true
}
return false
})
if idx == -1 {
return ""
}
if url, ok := segments[idx].Data["url"].(string); ok {
return url return url
} }
}
}
return "" return ""
} }

View File

@@ -2,7 +2,6 @@ package handler
import ( import (
"context" "context"
"log"
"strings" "strings"
"time" "time"
@@ -13,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
gorseio "github.com/gorse-io/gorse-go" gorseio "github.com/gorse-io/gorse-go"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -62,7 +62,7 @@ func (h *GorseHandler) ImportData(c *gin.Context) {
stats, err := h.importAllData(ctx) stats, err := h.importAllData(ctx)
if err != nil { 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()) response.InternalServerError(c, "gorse import failed: "+err.Error())
return return
} }
@@ -107,7 +107,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
for _, post := range posts { for _, post := range posts {
embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content)) embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content))
if err != nil { 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) embedding = make([]float64, 1024)
} }
_, err = gorseClient.InsertItem(ctx, gorseio.Item{ _, err = gorseClient.InsertItem(ctx, gorseio.Item{
@@ -121,7 +124,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
}, },
}) })
if err != nil { 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 continue
} }
stats["items"] = stats["items"].(int) + 1 stats["items"] = stats["items"].(int) + 1
@@ -143,7 +149,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
Comment: user.Nickname, Comment: user.Nickname,
}) })
if err != nil { 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 continue
} }
stats["users"] = stats["users"].(int) + 1 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), Timestamp: like.CreatedAt.UTC().Truncate(time.Second),
}}) }})
if err != nil { 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 continue
} }
stats["likes"] = stats["likes"].(int) + 1 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), Timestamp: fav.CreatedAt.UTC().Truncate(time.Second),
}}) }})
if err != nil { 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 continue
} }
stats["favorites"] = stats["favorites"].(int) + 1 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), Timestamp: cm.CreatedAt.UTC().Truncate(time.Second),
}}) }})
if err != nil { 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 continue
} }
stats["comments"] = stats["comments"].(int) + 1 stats["comments"] = stats["comments"].(int) + 1

View File

@@ -1,10 +1,10 @@
package handler package handler
import ( import (
"log"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"carrot_bbs/internal/dto" "carrot_bbs/internal/dto"
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
@@ -1361,12 +1361,24 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
// duration > 0 或 duration = -1 表示禁言duration = 0 表示解除禁言 // duration > 0 或 duration = -1 表示禁言duration = 0 表示解除禁言
muted := params.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) err := h.groupService.MuteMember(userID, groupID, params.UserID, muted)
if err != nil { if err != nil {
log.Printf("[HandleSetGroupBan] 禁言操作失败: %v", err) zap.L().Warn("禁言操作失败",
zap.String("component", "HandleSetGroupBan"),
zap.Error(err),
)
} else { } else {
log.Printf("[HandleSetGroupBan] 禁言操作成功") zap.L().Info("禁言操作成功",
zap.String("component", "HandleSetGroupBan"),
)
} }
if err != nil { if err != nil {
if err == service.ErrNotGroupAdmin { if err == service.ErrNotGroupAdmin {

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"cmp"
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
@@ -129,10 +130,7 @@ func (h *UserHandler) Login(c *gin.Context) {
return return
} }
account := req.Account account := cmp.Or(req.Account, req.Username)
if account == "" {
account = req.Username
}
if account == "" { if account == "" {
response.BadRequest(c, "username or account is required") response.BadRequest(c, "username or account is required")
return return

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"slices"
"strings" "strings"
"carrot_bbs/internal/service" "carrot_bbs/internal/service"
@@ -104,18 +105,9 @@ func RequireRole(casbinService service.CasbinService, requiredRoles ...string) g
} }
// 检查用户是否拥有任一所需角色 // 检查用户是否拥有任一所需角色
roleMap := make(map[string]bool) hasRole := slices.ContainsFunc(requiredRoles, func(required string) bool {
for _, role := range userRoles { return slices.Contains(userRoles, required)
roleMap[role] = true })
}
hasRole := false
for _, required := range requiredRoles {
if roleMap[required] {
hasRole = true
break
}
}
if !hasRole { if !hasRole {
c.AbortWithStatusJSON(403, gin.H{ c.AbortWithStatusJSON(403, gin.H{

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"time" "time"
"go.uber.org/zap"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "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) 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 return db, nil
} }
@@ -220,7 +223,9 @@ func seedRoles(db *gorm.DB) error {
return err return err
} }
log.Printf("Seeded %d roles successfully", len(roles)) zap.L().Info("Seeded roles successfully",
zap.Int("count", len(roles)),
)
return nil return nil
} }
@@ -276,7 +281,9 @@ func seedPermissions(db *gorm.DB) error {
return err return err
} }
log.Printf("Seeded %d permissions successfully", len(permissions)) zap.L().Info("Seeded permissions successfully",
zap.Int("count", len(permissions)),
)
return nil return nil
} }

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
gorseio "github.com/gorse-io/gorse-go" gorseio "github.com/gorse-io/gorse-go"
"go.uber.org/zap"
) )
// FeedbackType 反馈类型 // FeedbackType 反馈类型
@@ -206,7 +207,10 @@ func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, cat
var err error var err error
embedding, err = GetEmbedding(textToEmbed) embedding, err = GetEmbedding(textToEmbed)
if err != nil { 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) embedding = make([]float64, 1024)
} }
} else { } else {

View File

@@ -5,9 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"time" "time"
"go.uber.org/zap"
) )
// EmbeddingConfig embedding服务配置 // EmbeddingConfig embedding服务配置
@@ -94,7 +95,7 @@ func GetEmbedding(text string) ([]float64, error) {
// InitEmbeddingWithConfig 从应用配置初始化embedding // InitEmbeddingWithConfig 从应用配置初始化embedding
func InitEmbeddingWithConfig(apiKey, url, model string) { func InitEmbeddingWithConfig(apiKey, url, model string) {
if apiKey == "" { 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 defaultEmbeddingConfig.APIKey = apiKey
if url != "" { if url != "" {

View File

@@ -285,6 +285,33 @@ func (r *PostRepository) IsLiked(postID, userID string) bool {
return count > 0 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 收藏帖子 // Favorite 收藏帖子
func (r *PostRepository) Favorite(postID, userID string) error { func (r *PostRepository) Favorite(postID, userID string) error {
return r.db.Transaction(func(tx *gorm.DB) 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 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 增加帖子观看量 // IncrementViews 增加帖子观看量
func (r *PostRepository) IncrementViews(postID string) error { func (r *PostRepository) IncrementViews(postID string) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID). return r.db.Model(&model.Post{}).Where("id = ?", postID).

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"cmp"
"context" "context"
"os" "os"
"sync" "sync"
@@ -67,9 +68,11 @@ func NewAsyncLogManager(
} }
} }
for i := 0; i < cfg.WorkerCount; i++ { for i := range cfg.WorkerCount {
m.wg.Add(1) workerID := i
go m.worker(i, operationRepo, loginRepo, dataChangeRepo) m.wg.Go(func() {
m.worker(workerID, operationRepo, loginRepo, dataChangeRepo)
})
} }
go m.flushScheduler() go m.flushScheduler()
@@ -91,8 +94,6 @@ func (m *AsyncLogManager) worker(
loginRepo *repository.LoginLogRepository, loginRepo *repository.LoginLogRepository,
dataChangeRepo *repository.DataChangeLogRepository, dataChangeRepo *repository.DataChangeLogRepository,
) { ) {
defer m.wg.Done()
opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize) opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize)
loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize) loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize)
changeBatch := make([]*model.DataChangeLog, 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 初始化降级写入器 // initFallbackWriter 初始化降级写入器
func (m *AsyncLogManager) initFallbackWriter() error { func (m *AsyncLogManager) initFallbackWriter() error {
if m.cfg.FallbackPath == "" { m.cfg.FallbackPath = cmp.Or(m.cfg.FallbackPath, "./logs/fallback.log")
m.cfg.FallbackPath = "./logs/fallback.log"
}
dir := "./logs" dir := "./logs"
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -12,6 +11,7 @@ import (
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/openai"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -97,7 +97,9 @@ func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType
// 使用 AI 审核文本 // 使用 AI 审核文本
approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil) approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil)
if err != 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 { if s.config.StrictModeration {
return &AuditResult{ return &AuditResult{
Pass: false, Pass: false,
@@ -163,7 +165,9 @@ func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*Au
// 使用 AI 审核图片 // 使用 AI 审核图片
approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL}) approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL})
if err != nil { 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 { if s.config.StrictModeration {
return &AuditResult{ return &AuditResult{
Pass: false, Pass: false,
@@ -219,7 +223,9 @@ func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string,
// 使用 AI 审核帖子 // 使用 AI 审核帖子
approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images) approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images)
if err != nil { 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 { if s.config.StrictModeration {
return &AuditResult{ return &AuditResult{
Pass: false, Pass: false,
@@ -279,7 +285,9 @@ func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, ima
// 使用 AI 审核评论 // 使用 AI 审核评论
approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images) approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images)
if err != nil { 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 { if s.config.StrictModeration {
return &AuditResult{ return &AuditResult{
Pass: false, Pass: false,
@@ -395,7 +403,9 @@ func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, conten
} }
if err := s.db.Create(&auditLog).Error; err != nil { 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") 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") 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. 更新审核状态 // 3. 更新审核状态
// 示例逻辑,实际需要根据业务需求实现 // 示例逻辑,实际需要根据业务需求实现
log.Println("Processing pending audits...") zap.L().Debug("Processing pending audits")
} }
// CleanupOldLogs 清理旧的审核日志 // CleanupOldLogs 清理旧的审核日志

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log" "slices"
"time" "time"
"carrot_bbs/internal/cache" "carrot_bbs/internal/cache"
@@ -13,6 +13,7 @@ import (
"carrot_bbs/internal/pkg/sse" "carrot_bbs/internal/pkg/sse"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -298,7 +299,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
// 异步写入缓存 // 异步写入缓存
go func() { go func() {
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { 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 { func containsImageSegment(segments model.MessageSegments) bool {
for _, seg := range segments { return slices.ContainsFunc(segments, func(seg model.MessageSegment) bool {
if seg.Type == string(model.ContentTypeImage) || seg.Type == "image" { return seg.Type == string(model.ContentTypeImage) || seg.Type == "image"
return true })
}
}
return false
} }
// GetMessages 获取消息历史(分页,带缓存) // GetMessages 获取消息历史(分页,带缓存)
@@ -718,7 +721,12 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
// 异步写入缓存 // 异步写入缓存
go func() { go func() {
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { 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),
)
} }
}() }()

View File

@@ -4,13 +4,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"strings" "strings"
"carrot_bbs/internal/cache" "carrot_bbs/internal/cache"
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/gorse"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"go.uber.org/zap"
) )
// CommentService 评论服务 // CommentService 评论服务
@@ -115,11 +116,16 @@ func (s *CommentService) reviewCommentAsync(
// 未启用AI时直接通过审核并发送后续通知 // 未启用AI时直接通过审核并发送后续通知
if s.postAIService == nil || !s.postAIService.IsEnabled() { if s.postAIService == nil || !s.postAIService.IsEnabled() {
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil { 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 return
} }
if err := s.applyCommentPublishedStats(commentID); err != nil { 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.invalidatePostCaches(postID)
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
@@ -131,7 +137,10 @@ func (s *CommentService) reviewCommentAsync(
var rejectedErr *CommentModerationRejectedError var rejectedErr *CommentModerationRejectedError
if errors.As(err, &rejectedErr) { if errors.As(err, &rejectedErr) {
if delErr := s.commentRepo.Delete(commentID); delErr != nil { 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) s.notifyCommentModerationRejected(userID, rejectedErr.Reason)
return return
@@ -139,24 +148,39 @@ func (s *CommentService) reviewCommentAsync(
// 审核服务异常时降级放行避免评论长期pending // 审核服务异常时降级放行避免评论长期pending
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil { 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 return
} }
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil { 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.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) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
return return
} }
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil { 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 return
} }
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil { 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.invalidatePostCaches(postID)
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID) s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
@@ -184,7 +208,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string,
if parentUserID != userID { if parentUserID != userID {
notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID) notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID)
if notifyErr != nil { if notifyErr != nil {
log.Printf("[ERROR] Error sending reply notification: %v", notifyErr) zap.L().Error("Error sending reply notification",
zap.Error(notifyErr),
)
} }
} }
} else { } else {
@@ -192,7 +218,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string,
if postOwnerID != userID { if postOwnerID != userID {
notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID) notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID)
if notifyErr != nil { 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() { go func() {
if s.gorseClient.IsEnabled() { if s.gorseClient.IsEnabled() {
if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeComment, userID, postID); err != nil { 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, content,
); err != nil { ); 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 { if notifyErr != nil {
log.Printf("[ERROR] Error sending like notification: %v", notifyErr) zap.L().Error("Error sending like notification",
zap.Error(notifyErr),
)
} }
}() }()
} }

View File

@@ -1,9 +1,11 @@
package service package service
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"log" "maps"
"slices"
"strconv" "strconv"
"time" "time"
@@ -14,6 +16,7 @@ import (
"carrot_bbs/internal/pkg/utils" "carrot_bbs/internal/pkg/utils"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -137,7 +140,10 @@ type groupNoticeMessage struct {
func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMessage) { func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMessage) {
members, _, err := s.groupRepo.GetMembers(groupID, 1, 1000) members, _, err := s.groupRepo.GetMembers(groupID, 1, 1000)
if err != nil { if err != nil {
log.Printf("[groupService] 获取群成员失败: groupID=%s, err=%v", groupID, err) zap.L().Error("获取群成员失败",
zap.String("groupID", groupID),
zap.Error(err),
)
return return
} }
if s.sseHub != nil { if s.sseHub != nil {
@@ -368,7 +374,10 @@ func (s *groupService) DissolveGroup(userID string, groupID string) error {
// 先删除群组对应的会话(包括参与者、消息) // 先删除群组对应的会话(包括参与者、消息)
if s.messageRepo != nil { if s.messageRepo != nil {
if err := s.messageRepo.DeleteConversationByGroupID(groupID); err != 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, ExtraData: extra,
} }
if err := s.notifyRepo.Create(notification); err != nil { 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, Category: model.CategoryNotification,
} }
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { 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 { } else {
savedMessage = msg savedMessage = msg
s.invalidateConversationCachesAfterSystemMessage(conv.ID) s.invalidateConversationCachesAfterSystemMessage(conv.ID)
} }
} else { } 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) conv, err := s.messageRepo.GetConversationByGroupID(group.ID)
if err == nil && conv != nil { if err == nil && conv != nil {
if err := s.messageRepo.AddParticipant(conv.ID, userID); err != 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) s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID)
} }
@@ -568,11 +596,7 @@ func (s *groupService) collectGroupReviewerIDs(groupID string, ownerID string, s
} }
} }
result := make([]string, 0, len(reviewerSet)) return slices.Collect(maps.Keys(reviewerSet))
for id := range reviewerSet {
result = append(result, id)
}
return result
} }
func (s *groupService) notifyJoinApplyReviewers( func (s *groupService) notifyJoinApplyReviewers(
@@ -655,10 +679,8 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs []
inviter, _ := s.userRepo.GetByID(userID) inviter, _ := s.userRepo.GetByID(userID)
inviterName := "群成员" inviterName := "群成员"
inviterAvatar := "" inviterAvatar := ""
if inviter != nil && inviter.Nickname != "" {
inviterName = inviter.Nickname
}
if inviter != nil { if inviter != nil {
inviterName = cmp.Or(inviter.Nickname, "群成员")
inviterAvatar = inviter.Avatar inviterAvatar = inviter.Avatar
} }
@@ -743,10 +765,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error {
applicant, _ := s.userRepo.GetByID(userID) applicant, _ := s.userRepo.GetByID(userID)
applicantName := "用户" applicantName := "用户"
applicantAvatar := "" applicantAvatar := ""
if applicant != nil && applicant.Nickname != "" {
applicantName = applicant.Nickname
}
if applicant != nil { if applicant != nil {
applicantName = cmp.Or(applicant.Nickname, "用户")
applicantAvatar = applicant.Avatar applicantAvatar = applicant.Avatar
} }
// 已有待审批单时补发一次提醒,避免管理端漏看 // 已有待审批单时补发一次提醒,避免管理端漏看
@@ -767,10 +787,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error {
applicant, _ := s.userRepo.GetByID(userID) applicant, _ := s.userRepo.GetByID(userID)
applicantName := "用户" applicantName := "用户"
applicantAvatar := "" applicantAvatar := ""
if applicant != nil && applicant.Nickname != "" {
applicantName = applicant.Nickname
}
if applicant != nil { if applicant != nil {
applicantName = cmp.Or(applicant.Nickname, "用户")
applicantAvatar = applicant.Avatar applicantAvatar = applicant.Avatar
} }
expireAt := time.Now().Add(72 * time.Hour) expireAt := time.Now().Add(72 * time.Hour)
@@ -856,9 +874,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r
targetUserName := "用户" targetUserName := "用户"
targetUserAvatar := "" targetUserAvatar := ""
if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { if u, e := s.userRepo.GetByID(userID); e == nil && u != nil {
if u.Nickname != "" { targetUserName = cmp.Or(u.Nickname, "用户")
targetUserName = u.Nickname
}
targetUserAvatar = u.Avatar targetUserAvatar = u.Avatar
} }
@@ -866,9 +882,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r
inviterName := "群成员" inviterName := "群成员"
inviterAvatar := "" inviterAvatar := ""
if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil { if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil {
if u.Nickname != "" { inviterName = cmp.Or(u.Nickname, "群成员")
inviterName = u.Nickname
}
inviterAvatar = u.Avatar inviterAvatar = u.Avatar
} }
@@ -962,15 +976,13 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo
// 保存管理员ID到ReviewerID覆盖被邀请人的ID // 保存管理员ID到ReviewerID覆盖被邀请人的ID
req.Reason = reason req.Reason = reason
targetUserName := "用户" targetUserName := "用户"
if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil && u.Nickname != "" { if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil {
targetUserName = u.Nickname targetUserName = cmp.Or(u.Nickname, "用户")
} }
reviewerName := "管理员" reviewerName := "管理员"
reviewerAvatar := "" reviewerAvatar := ""
if u, e := s.userRepo.GetByID(userID); e == nil && u != nil { if u, e := s.userRepo.GetByID(userID); e == nil && u != nil {
if u.Nickname != "" { reviewerName = cmp.Or(u.Nickname, "管理员")
reviewerName = u.Nickname
}
reviewerAvatar = u.Avatar reviewerAvatar = u.Avatar
} }
@@ -1185,7 +1197,12 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID
conv, err := s.messageRepo.GetConversationByGroupID(groupID) conv, err := s.messageRepo.GetConversationByGroupID(groupID)
if err == nil && conv != nil { if err == nil && conv != nil {
if err := s.messageRepo.RemoveParticipant(conv.ID, targetUserID); err != 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) 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) member, err := s.groupRepo.GetMember(groupID, targetUserID)
if err != nil { if err != nil {
log.Printf("[MuteMember] 获取成员失败: %v", err) zap.L().Warn("获取成员失败",
zap.String("component", "MuteMember"),
zap.Error(err),
)
return 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 member.Muted = muted
if err := s.groupRepo.UpdateMember(member); err != nil { 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 return err
} }
log.Printf("[MuteMember] 禁言状态已更新到数据库") zap.L().Debug("禁言状态已更新到数据库", zap.String("component", "MuteMember"))
// 验证更新结果 // 验证更新结果
updatedMember, _ := s.groupRepo.GetMember(groupID, targetUserID) updatedMember, _ := s.groupRepo.GetMember(groupID, targetUserID)
if updatedMember != nil { 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 // 保存消息并获取 seq
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil { if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil {
log.Printf("[MuteMember] 保存禁言消息失败: %v", err) zap.L().Warn("保存禁言消息失败",
zap.String("component", "MuteMember"),
zap.Error(err),
)
} else { } else {
savedMessage = msg 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) s.invalidateConversationCachesAfterSystemMessage(conv.ID)
} }
} else { } else {
log.Printf("[MuteMember] 获取群组会话失败: %v", err) zap.L().Warn("获取群组会话失败",
zap.String("component", "MuteMember"),
zap.Error(err),
)
} }
} }

View File

@@ -2,13 +2,13 @@ package service
import ( import (
"context" "context"
"log"
"time" "time"
"carrot_bbs/internal/cache" "carrot_bbs/internal/cache"
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -94,7 +94,12 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s
// 异步写入缓存 // 异步写入缓存
go func() { go func() {
if err := s.cacheMessage(context.Background(), conv.ID, msg); err != nil { 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),
)
} }
}() }()

View File

@@ -2,10 +2,11 @@ package service
import ( import (
"context" "context"
"log"
"strings" "strings"
"carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/openai"
"go.uber.org/zap"
) )
// PostModerationRejectedError 帖子审核拒绝错误 // PostModerationRejectedError 帖子审核拒绝错误
@@ -73,7 +74,9 @@ func (s *PostAIService) ModeratePost(ctx context.Context, title, content string,
if s.openAIClient.Config().StrictModeration { if s.openAIClient.Config().StrictModeration {
return err 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 return nil
} }
if !approved { if !approved {
@@ -93,7 +96,9 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima
if s.openAIClient.Config().StrictModeration { if s.openAIClient.Config().StrictModeration {
return err 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 return nil
} }
if !approved { if !approved {

View File

@@ -514,10 +514,9 @@ func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs
return isLikedMap, isFavoritedMap, nil return isLikedMap, isFavoritedMap, nil
} }
for _, postID := range postIDs { // 使用批量查询替代循环中的单个查询,解决 N+1 问题
isLikedMap[postID] = s.postRepo.IsLiked(postID, userID) isLikedMap = s.postRepo.IsLikedBatch(postIDs, userID)
isFavoritedMap[postID] = s.postRepo.IsFavorited(postID, userID) isFavoritedMap = s.postRepo.IsFavoritedBatch(postIDs, userID)
}
return isLikedMap, isFavoritedMap, nil return isLikedMap, isFavoritedMap, nil
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
@@ -188,12 +189,7 @@ func normalizeWeeks(source []int) []int {
} }
func containsWeek(weeks []int, target int) bool { func containsWeek(weeks []int, target int) bool {
for _, week := range weeks { return slices.Contains(weeks, target)
if week == target {
return true
}
}
return false
} }
func normalizeHexColor(color string) string { func normalizeHexColor(color string) string {

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@@ -14,6 +13,7 @@ import (
"carrot_bbs/internal/model" "carrot_bbs/internal/model"
redisclient "carrot_bbs/internal/pkg/redis" redisclient "carrot_bbs/internal/pkg/redis"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -289,13 +289,17 @@ func NewSensitiveService(db *gorm.DB, redisClient *redisclient.Client, config *S
// 初始化加载敏感词 // 初始化加载敏感词
if config.LoadFromDB { if config.LoadFromDB {
if err := s.loadFromDB(context.Background()); err != nil { 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 config.LoadFromRedis && redisClient != nil {
if err := s.loadFromRedis(context.Background()); err != 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) result := s.db.Where("word = ?", word).First(&existing)
if result.Error == gorm.ErrRecordNotFound { if result.Error == gorm.ErrRecordNotFound {
if err := s.db.Create(&sensitiveWord).Error; err != nil { 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 { } else if result.Error == nil {
// 更新已存在的记录 // 更新已存在的记录
@@ -373,7 +379,9 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor
existing.Level = wordLevel existing.Level = wordLevel
existing.IsActive = true existing.IsActive = true
if err := s.db.Save(&existing).Error; err != nil { 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 { if s.db != nil {
result := s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Update("is_active", false) result := s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Update("is_active", false)
if result.Error != nil { 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) 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 return nil
} }

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
@@ -9,7 +10,6 @@ import (
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"log"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -17,7 +17,9 @@ import (
"strings" "strings"
"carrot_bbs/internal/pkg/s3" "carrot_bbs/internal/pkg/s3"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"go.uber.org/zap"
_ "golang.org/x/image/bmp" _ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff" _ "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) previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr)
if err != nil { 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 return url, previewURL, previewURLLarge, nil
@@ -189,10 +193,7 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string,
// 优先从文件字节探测真实类型,避免前端压缩/转码后 header 与实际格式不一致 // 优先从文件字节探测真实类型,避免前端压缩/转码后 header 与实际格式不一致
detectedType := normalizeImageContentType(http.DetectContentType(originalData)) detectedType := normalizeImageContentType(http.DetectContentType(originalData))
headerType := normalizeImageContentType(file.Header.Get("Content-Type")) headerType := normalizeImageContentType(file.Header.Get("Content-Type"))
contentType := detectedType contentType := cmp.Or(detectedType, headerType, "application/octet-stream")
if contentType == "" || contentType == "application/octet-stream" {
contentType = headerType
}
compressedData, compressedType, err := compressImageData(originalData, contentType) compressedData, compressedType, err := compressImageData(originalData, contentType)
if err != nil { if err != nil {
@@ -201,21 +202,9 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string,
compressedType = contentType compressedType = contentType
} }
if compressedType == "" { compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData))
compressedType = contentType
}
if compressedType == "" {
compressedType = http.DetectContentType(compressedData)
}
ext := getExtFromContentType(compressedType) ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(file.Filename)), ".jpg")
if ext == "" {
ext = strings.ToLower(filepath.Ext(file.Filename))
}
if ext == "" {
// 最终兜底,避免对象名无扩展名导致 URL 语义不明确
ext = ".jpg"
}
return compressedData, compressedType, ext, nil 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) previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash)
if err != nil { 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) previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash)
if err != nil { 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 return previewURL, previewURLLarge, nil

View File

@@ -3,10 +3,11 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"time" "time"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"go.uber.org/zap"
) )
// UserActivityService 用户活跃统计服务接口 // UserActivityService 用户活跃统计服务接口
@@ -76,14 +77,18 @@ func (s *userActivityService) RecordUserActive(ctx context.Context, userID, logi
// 1. 记录到 Redis同步快速 // 1. 记录到 Redis同步快速
if err := s.repo.RecordActivityToRedis(ctx, userID); err != nil { 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 或消息队列) // 2. 异步写入数据库(可以使用 goroutine 或消息队列)
go func() { go func() {
bgCtx := context.Background() bgCtx := context.Background()
if err := s.repo.RecordActivity(bgCtx, userID, loginType, ip, userAgent); err != nil { 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 // 获取今日 DAU
todayDAU, err := s.GetDAU(ctx, today) todayDAU, err := s.GetDAU(ctx, today)
if err != nil { 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{ resp.Today = DailyStats{
Date: today.Format("2006-01-02"), Date: today.Format("2006-01-02"),
@@ -168,7 +175,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
// 获取昨日 DAU // 获取昨日 DAU
yesterdayDAU, err := s.GetDAU(ctx, yesterday) yesterdayDAU, err := s.GetDAU(ctx, yesterday)
if err != nil { 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{ resp.Yesterday = DailyStats{
Date: yesterday.Format("2006-01-02"), Date: yesterday.Format("2006-01-02"),
@@ -178,7 +187,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
// 获取本周 WAU // 获取本周 WAU
wau, err := s.GetWAU(ctx, year, week) wau, err := s.GetWAU(ctx, year, week)
if err != nil { if err != nil {
log.Printf("failed to get WAU: %v", err) zap.L().Warn("failed to get WAU",
zap.Error(err),
)
} }
resp.ThisWeek = PeriodStats{ resp.ThisWeek = PeriodStats{
Period: fmt.Sprintf("%d-W%02d", year, week), Period: fmt.Sprintf("%d-W%02d", year, week),
@@ -188,7 +199,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
// 获取本月 MAU // 获取本月 MAU
mau, err := s.GetMAU(ctx, now.Year(), int(now.Month())) mau, err := s.GetMAU(ctx, now.Year(), int(now.Month()))
if err != nil { if err != nil {
log.Printf("failed to get MAU: %v", err) zap.L().Warn("failed to get MAU",
zap.Error(err),
)
} }
resp.ThisMonth = PeriodStats{ resp.ThisMonth = PeriodStats{
Period: now.Format("2006-01"), Period: now.Format("2006-01"),
@@ -199,7 +212,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
// 次日留存 // 次日留存
day1Retention, err := s.GetRetention(ctx, yesterday, today) day1Retention, err := s.GetRetention(ctx, yesterday, today)
if err != nil { 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 resp.Retention.Day1 = day1Retention
@@ -207,7 +222,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
day7Date := today.AddDate(0, 0, -7) day7Date := today.AddDate(0, 0, -7)
day7Retention, err := s.GetRetention(ctx, day7Date, today) day7Retention, err := s.GetRetention(ctx, day7Date, today)
if err != nil { 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 resp.Retention.Day7 = day7Retention
@@ -215,7 +232,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
day30Date := today.AddDate(0, 0, -30) day30Date := today.AddDate(0, 0, -30)
day30Retention, err := s.GetRetention(ctx, day30Date, today) day30Retention, err := s.GetRetention(ctx, day30Date, today)
if err != nil { 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 resp.Retention.Day30 = day30Retention

View File

@@ -1,8 +1,6 @@
package wire package wire
import ( import (
"log"
"carrot_bbs/internal/cache" "carrot_bbs/internal/cache"
"carrot_bbs/internal/config" "carrot_bbs/internal/config"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
@@ -11,6 +9,7 @@ import (
"github.com/casbin/casbin/v3" "github.com/casbin/casbin/v3"
gormadapter "github.com/casbin/gorm-adapter/v3" gormadapter "github.com/casbin/gorm-adapter/v3"
"github.com/google/wire" "github.com/google/wire"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -26,21 +25,27 @@ func ProvideCasbinEnforcer(cfg *config.Config, db *gorm.DB) *casbin.Enforcer {
// 创建 GORM 适配器,连接数据库 // 创建 GORM 适配器,连接数据库
adapter, err := gormadapter.NewAdapterByDB(db) adapter, err := gormadapter.NewAdapterByDB(db)
if err != nil { 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使用数据库适配器 // 创建 Casbin Enforcer使用数据库适配器
enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter) enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter)
if err != nil { 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 { 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 return enforcer
} }

View File

@@ -1,7 +1,6 @@
package wire package wire
import ( import (
"log"
"time" "time"
"carrot_bbs/internal/cache" "carrot_bbs/internal/cache"
@@ -62,7 +61,9 @@ func ProvideDB(cfg *config.Config) (*gorm.DB, error) {
func ProvideRedisClient(cfg *config.Config) *redis.Client { func ProvideRedisClient(cfg *config.Config) *redis.Client {
client, err := redis.New(&cfg.Redis) client, err := redis.New(&cfg.Redis)
if err != nil { 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 nil
} }
return client return client
@@ -85,12 +86,12 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
// 直接创建 RedisCache 实例,不依赖全局变量 // 直接创建 RedisCache 实例,不依赖全局变量
if redisClient == nil { 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 return nil
} }
cacheInstance := cache.NewRedisCache(redisClient) cacheInstance := cache.NewRedisCache(redisClient)
log.Println("[Wire] Initialized Redis cache via dependency injection") zap.L().Info("Initialized Redis cache via dependency injection")
return cacheInstance return cacheInstance
} }
@@ -128,7 +129,9 @@ func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager {
func ProvideLogger() *zap.Logger { func ProvideLogger() *zap.Logger {
logger, err := zap.NewProduction() logger, err := zap.NewProduction()
if err != nil { 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 return logger
} }