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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ logs/
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
|
||||
plans/
|
||||
674
.kilocode/skills/golang-patterns/SKILL.md
Normal file
674
.kilocode/skills/golang-patterns/SKILL.md
Normal 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 代码应该以最好的方式显得“乏味”——可预测、一致且易于理解。如有疑问,保持简单。
|
||||
720
.kilocode/skills/golang-testing/SKILL.md
Normal file
720
.kilocode/skills/golang-testing/SKILL.md
Normal 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.
|
||||
291
.kilocode/skills/use-modern-go/SKILL.md
Normal file
291
.kilocode/skills/use-modern-go/SKILL.md
Normal 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 I’ll stick to modern Go best practices and freely use language features up to and including this version. If you’d prefer a different target version, just let me know."
|
||||
- Do NOT list features, do NOT ask for confirmation
|
||||
|
||||
**If version is "unknown":**
|
||||
- Say: "Could not detect Go version in this repository"
|
||||
- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26]
|
||||
|
||||
**When writing Go code**, use ALL features from this document up to the target version:
|
||||
- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns
|
||||
- Never use features from newer Go versions than the target
|
||||
- Never use outdated patterns when a modern alternative is available
|
||||
|
||||
---
|
||||
|
||||
## Features by Go Version
|
||||
|
||||
### Go 1.0+
|
||||
|
||||
- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)`
|
||||
|
||||
### Go 1.8+
|
||||
|
||||
- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())`
|
||||
|
||||
### Go 1.13+
|
||||
|
||||
- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors)
|
||||
|
||||
### Go 1.18+
|
||||
|
||||
- `any`: Use `any` instead of `interface{}`
|
||||
- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice
|
||||
- `strings.Cut`: `before, after, found := strings.Cut(s, sep)`
|
||||
|
||||
### Go 1.19+
|
||||
|
||||
- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))`
|
||||
- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32`
|
||||
|
||||
```go
|
||||
var flag atomic.Bool
|
||||
flag.Store(true)
|
||||
if flag.Load() { ... }
|
||||
|
||||
var ptr atomic.Pointer[Config]
|
||||
ptr.Store(cfg)
|
||||
```
|
||||
|
||||
### Go 1.20+
|
||||
|
||||
- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory
|
||||
- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice
|
||||
- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }`
|
||||
- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors
|
||||
- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)`
|
||||
- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation
|
||||
|
||||
### Go 1.21+
|
||||
|
||||
**Built-ins:**
|
||||
- `min`/`max`: `max(a, b)` instead of if/else comparisons
|
||||
- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements
|
||||
|
||||
**slices package:**
|
||||
- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops
|
||||
- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found)
|
||||
- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })`
|
||||
- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })`
|
||||
- `slices.Sort`: `slices.Sort(items)` for ordered types
|
||||
- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop
|
||||
- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop
|
||||
- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place
|
||||
- `slices.Clip`: `slices.Clip(s)` removes unused capacity
|
||||
- `slices.Clone`: `slices.Clone(s)` creates a copy
|
||||
|
||||
**maps package:**
|
||||
- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration
|
||||
- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst
|
||||
- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })`
|
||||
|
||||
**sync package:**
|
||||
- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper
|
||||
- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })`
|
||||
|
||||
**context package:**
|
||||
- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation
|
||||
- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)`
|
||||
- `context.WithDeadlineCause`: Similar with deadline instead of duration
|
||||
|
||||
### Go 1.22+
|
||||
|
||||
**Loops:**
|
||||
- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++`
|
||||
- Loop variables are now safe to capture in goroutines (each iteration has its own copy)
|
||||
|
||||
**cmp package:**
|
||||
- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value
|
||||
|
||||
```go
|
||||
// Instead of:
|
||||
name := os.Getenv("NAME")
|
||||
if name == "" {
|
||||
name = "default"
|
||||
}
|
||||
// Use:
|
||||
name := cmp.Or(os.Getenv("NAME"), "default")
|
||||
```
|
||||
|
||||
**reflect package:**
|
||||
- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()`
|
||||
|
||||
**net/http:**
|
||||
- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params
|
||||
- `r.PathValue("id")` to get path parameters
|
||||
|
||||
### Go 1.23+
|
||||
|
||||
- `maps.Keys(m)` / `maps.Values(m)` return iterators
|
||||
- `slices.Collect(iter)` not manual loop to build slice from iterator
|
||||
- `slices.Sorted(iter)` to collect and sort in one step
|
||||
|
||||
```go
|
||||
keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) }
|
||||
sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort
|
||||
for k := range maps.Keys(m) { process(k) } // iterate directly
|
||||
```
|
||||
|
||||
**time package**
|
||||
|
||||
- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do.
|
||||
|
||||
### Go 1.24+
|
||||
|
||||
- `t.Context()` not `context.WithCancel(context.Background())` in tests.
|
||||
ALWAYS use t.Context() when a test function needs a context.
|
||||
|
||||
Before:
|
||||
```go
|
||||
func TestFoo(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := doSomething(ctx)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
func TestFoo(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
result := doSomething(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
- `omitzero` not `omitempty` in JSON struct tags.
|
||||
ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps.
|
||||
|
||||
Before:
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration!
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout,omitzero"`
|
||||
}
|
||||
```
|
||||
|
||||
- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks.
|
||||
ALWAYS use b.Loop() for the main loop in benchmark functions.
|
||||
|
||||
Before:
|
||||
```go
|
||||
func BenchmarkFoo(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
func BenchmarkFoo(b *testing.B) {
|
||||
for b.Loop() {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `strings.SplitSeq` not `strings.Split` when iterating.
|
||||
ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop.
|
||||
|
||||
Before:
|
||||
```go
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
process(part)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
process(part)
|
||||
}
|
||||
```
|
||||
Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`.
|
||||
|
||||
### Go 1.25+
|
||||
|
||||
- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`.
|
||||
ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup.
|
||||
|
||||
Before:
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
process(item)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
After:
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Go(func() {
|
||||
process(item)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Go 1.26+
|
||||
|
||||
- `new(val)` not `x := val; &x` — returns pointer to any value.
|
||||
Go 1.26 extends new() to accept expressions, not just types.
|
||||
Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T.
|
||||
DO NOT use `x := val; &x` pattern — always use new(val) directly.
|
||||
DO NOT use redundant casts like new(int(0)) — just write new(0).
|
||||
Common use case: struct fields with pointer types.
|
||||
|
||||
Before:
|
||||
```go
|
||||
timeout := 30
|
||||
debug := true
|
||||
cfg := Config{
|
||||
Timeout: &timeout,
|
||||
Debug: &debug,
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
cfg := Config{
|
||||
Timeout: new(30), // *int
|
||||
Debug: new(true), // *bool
|
||||
}
|
||||
```
|
||||
|
||||
- `errors.AsType[T](err)` not `errors.As(err, &target)`.
|
||||
ALWAYS use errors.AsType when checking if error matches a specific type.
|
||||
|
||||
Before:
|
||||
```go
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
handle(pathErr)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
|
||||
handle(pathErr)
|
||||
}
|
||||
```
|
||||
@@ -1,10 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -73,17 +75,17 @@ func main() {
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping database: %v", err)
|
||||
zap.L().Fatal("Failed to ping database", zap.Error(err))
|
||||
}
|
||||
|
||||
log.Println("Connected to database")
|
||||
zap.L().Info("Connected to database")
|
||||
|
||||
// 运行迁移
|
||||
if err := runMigration(db); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
zap.L().Fatal("Migration failed", zap.Error(err))
|
||||
}
|
||||
|
||||
log.Println("Migration completed successfully")
|
||||
zap.L().Info("Migration completed successfully")
|
||||
}
|
||||
|
||||
func runMigration(db *gorm.DB) error {
|
||||
@@ -105,26 +107,31 @@ func runMigration(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
if hasPreviewURL && hasPreviewURLLarge {
|
||||
log.Println("Columns already exist, skipping migration")
|
||||
zap.L().Info("Columns already exist, skipping migration")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 添加字段
|
||||
var errs []error
|
||||
if !hasPreviewURL {
|
||||
log.Println("Adding preview_url column...")
|
||||
zap.L().Info("Adding preview_url column...")
|
||||
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url TEXT").Error; err != nil {
|
||||
return fmt.Errorf("failed to add preview_url column: %w", err)
|
||||
errs = append(errs, fmt.Errorf("failed to add preview_url column: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if !hasPreviewURLLarge {
|
||||
log.Println("Adding preview_url_large column...")
|
||||
zap.L().Info("Adding preview_url_large column...")
|
||||
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url_large TEXT").Error; err != nil {
|
||||
return fmt.Errorf("failed to add preview_url_large column: %w", err)
|
||||
errs = append(errs, fmt.Errorf("failed to add preview_url_large column: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Migration completed successfully")
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
zap.L().Info("Migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"carrot_bbs/internal/router"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ type App struct {
|
||||
PushService service.PushService
|
||||
GRPCServer *runner.Server
|
||||
Server *http.Server
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewApp 创建应用程序
|
||||
@@ -31,6 +32,7 @@ func NewApp(
|
||||
r *router.Router,
|
||||
pushService service.PushService,
|
||||
grpcServer *runner.Server,
|
||||
logger *zap.Logger,
|
||||
) *App {
|
||||
return &App{
|
||||
Config: cfg,
|
||||
@@ -38,6 +40,7 @@ func NewApp(
|
||||
Router: r,
|
||||
PushService: pushService,
|
||||
GRPCServer: grpcServer,
|
||||
Logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +48,7 @@ func NewApp(
|
||||
func (a *App) Start(ctx context.Context) error {
|
||||
// 1. 启动 gRPC 服务器(在 HTTP 服务器之前)
|
||||
if a.GRPCServer != nil {
|
||||
log.Println("Starting gRPC server...")
|
||||
a.Logger.Info("Starting gRPC server")
|
||||
if err := a.GRPCServer.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start gRPC server: %w", err)
|
||||
}
|
||||
@@ -56,7 +59,9 @@ func (a *App) Start(ctx context.Context) error {
|
||||
|
||||
// 3. 创建 HTTP 服务器
|
||||
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
||||
log.Printf("Starting HTTP server at %s", addr)
|
||||
a.Logger.Info("Starting HTTP server",
|
||||
zap.String("address", addr),
|
||||
)
|
||||
|
||||
a.Server = &http.Server{
|
||||
Addr: addr,
|
||||
@@ -66,7 +71,9 @@ func (a *App) Start(ctx context.Context) error {
|
||||
// 4. 启动 HTTP 服务器
|
||||
go func() {
|
||||
if err := a.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
a.Logger.Fatal("HTTP server listen error",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -75,33 +82,35 @@ func (a *App) Start(ctx context.Context) error {
|
||||
|
||||
// Stop 停止应用程序
|
||||
func (a *App) Stop(ctx context.Context) error {
|
||||
log.Println("Shutting down server...")
|
||||
a.Logger.Info("Shutting down server")
|
||||
|
||||
// 1. 停止 HTTP 服务器
|
||||
if a.Server != nil {
|
||||
log.Println("Stopping HTTP server...")
|
||||
a.Logger.Info("Stopping HTTP server")
|
||||
if err := a.Server.Shutdown(ctx); err != nil {
|
||||
log.Printf("HTTP server forced to shutdown: %v", err)
|
||||
a.Logger.Error("HTTP server forced to shutdown",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止 gRPC 服务器
|
||||
if a.GRPCServer != nil {
|
||||
log.Println("Stopping gRPC server...")
|
||||
a.Logger.Info("Stopping gRPC server")
|
||||
a.GRPCServer.Stop()
|
||||
}
|
||||
|
||||
// 3. 停止推送 Worker
|
||||
log.Println("Stopping push worker...")
|
||||
a.Logger.Info("Stopping push worker")
|
||||
a.PushService.StopPushWorker()
|
||||
|
||||
// 4. 关闭数据库连接
|
||||
log.Println("Closing database connection...")
|
||||
a.Logger.Info("Closing database connection")
|
||||
sqlDB, err := a.DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
a.Logger.Info("Server exited")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,24 +2,29 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化应用程序(Wire 自动生成)
|
||||
app, err := InitializeApp()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize app: %v", err)
|
||||
zap.L().Fatal("failed to initialize app",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 启动应用程序
|
||||
ctx := context.Background()
|
||||
if err := app.Start(ctx); err != nil {
|
||||
log.Fatalf("failed to start app: %v", err)
|
||||
app.Logger.Fatal("failed to start app",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
@@ -31,6 +36,8 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
if err := app.Stop(shutdownCtx); err != nil {
|
||||
log.Printf("Error during shutdown: %v", err)
|
||||
app.Logger.Error("Error during shutdown",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func InitializeApp() (*App, error) {
|
||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
||||
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, logService, userActivityService, casbinService)
|
||||
app := NewApp(config, db, router, pushService, server)
|
||||
app := NewApp(config, db, router, pushService, server, logger)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
||||
1
go.mod
1
go.mod
@@ -4,7 +4,6 @@ go 1.25
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.31.0
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/casbin/casbin/v3 v3.10.0
|
||||
github.com/casbin/gorm-adapter/v3 v3.41.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
|
||||
11
internal/cache/cache.go
vendored
11
internal/cache/cache.go
vendored
@@ -1,6 +1,7 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
@@ -123,17 +124,11 @@ func GetSettings() Settings {
|
||||
}
|
||||
|
||||
func normalizeKey(key string) string {
|
||||
if settings.KeyPrefix == "" {
|
||||
return key
|
||||
}
|
||||
return settings.KeyPrefix + ":" + key
|
||||
return cmp.Or(settings.KeyPrefix, "") + ":" + key
|
||||
}
|
||||
|
||||
func normalizePrefix(prefix string) string {
|
||||
if settings.KeyPrefix == "" {
|
||||
return prefix
|
||||
}
|
||||
return settings.KeyPrefix + ":" + prefix
|
||||
return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
|
||||
}
|
||||
|
||||
func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) {
|
||||
|
||||
9
internal/cache/conversation_cache.go
vendored
9
internal/cache/conversation_cache.go
vendored
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CachedConversation 带缓存元数据的会话
|
||||
@@ -713,6 +714,10 @@ func (c *ConversationCache) getMessagesBySeqs(ctx context.Context, convID string
|
||||
// asyncCacheMessage 异步缓存单条消息
|
||||
func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) {
|
||||
if err := c.CacheMessage(ctx, convID, msg); err != nil {
|
||||
log.Printf("[ConversationCache] async cache message failed, convID=%s, msgID=%s, err=%v", convID, msg.ID, err)
|
||||
zap.L().Error("async cache message failed",
|
||||
zap.String("convID", convID),
|
||||
zap.String("msgID", msg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
48
internal/cache/redis_cache.go
vendored
48
internal/cache/redis_cache.go
vendored
@@ -3,10 +3,10 @@ package cache
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "carrot_bbs/internal/pkg/redis"
|
||||
)
|
||||
@@ -32,13 +32,19 @@ func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
recordSetError()
|
||||
log.Printf("[RedisCache] Failed to marshal value for key %s: %v", key, err)
|
||||
zap.L().Error("Failed to marshal value for key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.client.Set(c.ctx, key, data, ttl); err != nil {
|
||||
recordSetError()
|
||||
log.Printf("[RedisCache] Failed to set key %s: %v", key, err)
|
||||
zap.L().Error("Failed to set key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +56,10 @@ func (c *RedisCache) Get(key string) (interface{}, bool) {
|
||||
if err == redis.Nil {
|
||||
return nil, false
|
||||
}
|
||||
log.Printf("[RedisCache] Failed to get key %s: %v", key, err)
|
||||
zap.L().Error("Failed to get key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -63,7 +72,10 @@ func (c *RedisCache) Delete(key string) {
|
||||
key = normalizeKey(key)
|
||||
recordInvalidate()
|
||||
if err := c.client.Del(c.ctx, key); err != nil {
|
||||
log.Printf("[RedisCache] Failed to delete key %s: %v", key, err)
|
||||
zap.L().Error("Failed to delete key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +88,20 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
for {
|
||||
keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result()
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to scan keys with prefix %s: %v", prefix, err)
|
||||
zap.L().Error("Failed to scan keys with prefix",
|
||||
zap.String("prefix", prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
recordInvalidateMultiple(int64(len(keys)))
|
||||
if err := c.client.Del(c.ctx, keys...); err != nil {
|
||||
log.Printf("[RedisCache] Failed to delete keys with prefix %s: %v", prefix, err)
|
||||
zap.L().Error("Failed to delete keys with prefix",
|
||||
zap.String("prefix", prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +115,15 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
// Clear 清空所有缓存
|
||||
func (c *RedisCache) Clear() {
|
||||
if settings.DisableFlushDB {
|
||||
log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true")
|
||||
zap.L().Debug("Skip FlushDB because cache.disable_flushdb=true")
|
||||
return
|
||||
}
|
||||
recordInvalidate()
|
||||
rdb := c.client.GetClient()
|
||||
if err := rdb.FlushDB(c.ctx).Err(); err != nil {
|
||||
log.Printf("[RedisCache] Failed to clear cache: %v", err)
|
||||
zap.L().Error("Failed to clear cache",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +132,10 @@ func (c *RedisCache) Exists(key string) bool {
|
||||
key = normalizeKey(key)
|
||||
n, err := c.client.Exists(c.ctx, key)
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to check existence of key %s: %v", key, err)
|
||||
zap.L().Error("Failed to check existence of key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
@@ -129,7 +152,10 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
||||
rdb := c.client.GetClient()
|
||||
result, err := rdb.IncrBy(c.ctx, key, value).Result()
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to increment key %s: %v", key, err)
|
||||
zap.L().Error("Failed to increment key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -236,8 +237,5 @@ func Load(configPath string) (*Config, error) {
|
||||
|
||||
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
return cmp.Or(os.Getenv(key), defaultValue)
|
||||
}
|
||||
|
||||
@@ -224,34 +224,34 @@ type TextSegmentData struct {
|
||||
// ImageSegmentData 图片数据
|
||||
type ImageSegmentData struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Width int `json:"width,omitzero"`
|
||||
Height int `json:"height,omitzero"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitzero"`
|
||||
}
|
||||
|
||||
// VoiceSegmentData 语音数据
|
||||
type VoiceSegmentData struct {
|
||||
URL string `json:"url"`
|
||||
Duration int `json:"duration,omitempty"` // 秒
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
Duration int `json:"duration,omitzero"` // 秒
|
||||
FileSize int64 `json:"file_size,omitzero"`
|
||||
}
|
||||
|
||||
// VideoSegmentData 视频数据
|
||||
type VideoSegmentData struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Duration int `json:"duration,omitempty"` // 秒
|
||||
Width int `json:"width,omitzero"`
|
||||
Height int `json:"height,omitzero"`
|
||||
Duration int `json:"duration,omitzero"` // 秒
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitzero"`
|
||||
}
|
||||
|
||||
// FileSegmentData 文件数据
|
||||
type FileSegmentData struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Size int64 `json:"size,omitzero"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ type ReplySegmentData struct {
|
||||
|
||||
// FaceSegmentData 表情数据
|
||||
type FaceSegmentData struct {
|
||||
ID int `json:"id"`
|
||||
ID int `json:"id,omitzero"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
@@ -458,7 +458,7 @@ type DeviceTokenResponse struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
DeviceName string `json:"device_name"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitzero"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -470,9 +470,9 @@ type PushRecordResponse struct {
|
||||
MessageID string `json:"message_id"`
|
||||
PushChannel string `json:"push_channel"`
|
||||
PushStatus string `json:"push_status"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
PushedAt time.Time `json:"pushed_at,omitempty"`
|
||||
DeliveredAt time.Time `json:"delivered_at,omitempty"`
|
||||
RetryCount int `json:"retry_count,omitzero"`
|
||||
PushedAt time.Time `json:"pushed_at,omitzero"`
|
||||
DeliveredAt time.Time `json:"delivered_at,omitzero"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -859,7 +859,7 @@ type VoteResultDTO struct {
|
||||
Options []VoteOptionDTO `json:"options"`
|
||||
TotalVotes int `json:"total_votes"`
|
||||
HasVoted bool `json:"has_voted"`
|
||||
VotedOptionID string `json:"voted_option_id,omitempty"`
|
||||
VotedOptionID string `json:"voted_option_id,omitzero"`
|
||||
}
|
||||
|
||||
// ==================== WebSocket Response DTOs ====================
|
||||
@@ -882,10 +882,10 @@ type AdminPostListResponse struct {
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
FavoritesCount int `json:"favorites_count"`
|
||||
ViewsCount int `json:"views_count"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
@@ -906,11 +906,11 @@ type AdminPostDetailResponse struct {
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
FavoritesCount int `json:"favorites_count"`
|
||||
SharesCount int `json:"shares_count"`
|
||||
ViewsCount int `json:"views_count"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
SharesCount int `json:"shares_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
HotScore float64 `json:"hot_score"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
|
||||
@@ -5,9 +5,9 @@ type ScheduleCourseResponse struct {
|
||||
Name string `json:"name"`
|
||||
Teacher string `json:"teacher,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
StartSection int `json:"start_section"`
|
||||
EndSection int `json:"end_section"`
|
||||
DayOfWeek int `json:"day_of_week,omitzero"`
|
||||
StartSection int `json:"start_section,omitzero"`
|
||||
EndSection int `json:"end_section,omitzero"`
|
||||
Weeks []int `json:"weeks"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
)
|
||||
@@ -232,25 +233,27 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
|
||||
|
||||
// IsAtAll 检查消息是否@了所有人
|
||||
func IsAtAll(segments model.MessageSegments) bool {
|
||||
for _, segment := range segments {
|
||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||
if segment.Type == string(SegmentTypeAt) {
|
||||
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// GetReplyMessageID 从消息链中获取被回复的消息ID
|
||||
// 如果没有回复segment,返回空字符串
|
||||
func GetReplyMessageID(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeReply) {
|
||||
if id, ok := segment.Data["id"].(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(SegmentTypeReply)
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if id, ok := segments[idx].Data["id"].(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -291,12 +294,9 @@ func BuildSegmentsFromContent(contentType, content string, mediaURL *string) mod
|
||||
|
||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(segmentType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(segmentType)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSegmentsByType 获取消息链中所有指定类型的segment
|
||||
@@ -313,12 +313,14 @@ func GetSegmentsByType(segments model.MessageSegments, segmentType SegmentType)
|
||||
// GetFirstImageURL 获取消息链中第一张图片的URL
|
||||
// 如果没有图片,返回空字符串
|
||||
func GetFirstImageURL(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeImage) {
|
||||
if url, ok := segment.Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
}
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(SegmentTypeImage)
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if url, ok := segments[idx].Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -326,13 +328,18 @@ func GetFirstImageURL(segments model.MessageSegments) string {
|
||||
// GetFirstMediaURL 获取消息链中第一个媒体文件的URL(图片/视频/语音/文件)
|
||||
// 用于兼容旧版本API
|
||||
func GetFirstMediaURL(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
switch segment.Type {
|
||||
case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile):
|
||||
if url, ok := segment.Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if url, ok := segments[idx].Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gorseio "github.com/gorse-io/gorse-go"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *GorseHandler) ImportData(c *gin.Context) {
|
||||
|
||||
stats, err := h.importAllData(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] gorse import failed: %v", err)
|
||||
zap.L().Error("gorse import failed", zap.Error(err))
|
||||
response.InternalServerError(c, "gorse import failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -107,7 +107,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
for _, post := range posts {
|
||||
embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] get embedding failed for post %s: %v", post.ID, err)
|
||||
zap.L().Warn("get embedding failed for post",
|
||||
zap.String("postID", post.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
embedding = make([]float64, 1024)
|
||||
}
|
||||
_, err = gorseClient.InsertItem(ctx, gorseio.Item{
|
||||
@@ -121,7 +124,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import item failed (%s): %v", post.ID, err)
|
||||
zap.L().Warn("import item failed",
|
||||
zap.String("postID", post.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
stats["items"] = stats["items"].(int) + 1
|
||||
@@ -143,7 +149,10 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
Comment: user.Nickname,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import user failed (%s): %v", user.ID, err)
|
||||
zap.L().Warn("import user failed",
|
||||
zap.String("userID", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
stats["users"] = stats["users"].(int) + 1
|
||||
@@ -162,7 +171,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
Timestamp: like.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import like failed (%s/%s): %v", like.UserID, like.PostID, err)
|
||||
zap.L().Warn("import like failed",
|
||||
zap.String("userID", like.UserID),
|
||||
zap.String("postID", like.PostID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
stats["likes"] = stats["likes"].(int) + 1
|
||||
@@ -181,7 +194,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
Timestamp: fav.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import favorite failed (%s/%s): %v", fav.UserID, fav.PostID, err)
|
||||
zap.L().Warn("import favorite failed",
|
||||
zap.String("userID", fav.UserID),
|
||||
zap.String("postID", fav.PostID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
stats["favorites"] = stats["favorites"].(int) + 1
|
||||
@@ -206,7 +223,11 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
Timestamp: cm.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import comment failed (%s/%s): %v", cm.UserID, cm.PostID, err)
|
||||
zap.L().Warn("import comment failed",
|
||||
zap.String("userID", cm.UserID),
|
||||
zap.String("postID", cm.PostID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
stats["comments"] = stats["comments"].(int) + 1
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
@@ -1361,12 +1361,24 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
|
||||
|
||||
// duration > 0 或 duration = -1 表示禁言,duration = 0 表示解除禁言
|
||||
muted := params.Duration != 0
|
||||
log.Printf("[HandleSetGroupBan] 开始禁言操作: userID=%s, groupID=%s, targetUserID=%s, duration=%d, muted=%v", userID, groupID, params.UserID, params.Duration, muted)
|
||||
zap.L().Info("开始禁言操作",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
zap.String("userID", userID),
|
||||
zap.String("groupID", groupID),
|
||||
zap.String("targetUserID", params.UserID),
|
||||
zap.Int64("duration", params.Duration),
|
||||
zap.Bool("muted", muted),
|
||||
)
|
||||
err := h.groupService.MuteMember(userID, groupID, params.UserID, muted)
|
||||
if err != nil {
|
||||
log.Printf("[HandleSetGroupBan] 禁言操作失败: %v", err)
|
||||
zap.L().Warn("禁言操作失败",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
log.Printf("[HandleSetGroupBan] 禁言操作成功")
|
||||
zap.L().Info("禁言操作成功",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if err == service.ErrNotGroupAdmin {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
@@ -129,10 +130,7 @@ func (h *UserHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
account := req.Account
|
||||
if account == "" {
|
||||
account = req.Username
|
||||
}
|
||||
account := cmp.Or(req.Account, req.Username)
|
||||
if account == "" {
|
||||
response.BadRequest(c, "username or account is required")
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/service"
|
||||
@@ -104,18 +105,9 @@ func RequireRole(casbinService service.CasbinService, requiredRoles ...string) g
|
||||
}
|
||||
|
||||
// 检查用户是否拥有任一所需角色
|
||||
roleMap := make(map[string]bool)
|
||||
for _, role := range userRoles {
|
||||
roleMap[role] = true
|
||||
}
|
||||
|
||||
hasRole := false
|
||||
for _, required := range requiredRoles {
|
||||
if roleMap[required] {
|
||||
hasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
hasRole := slices.ContainsFunc(requiredRoles, func(required string) bool {
|
||||
return slices.Contains(userRoles, required)
|
||||
})
|
||||
|
||||
if !hasRole {
|
||||
c.AbortWithStatusJSON(403, gin.H{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -67,7 +68,9 @@ func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("failed to auto migrate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Database connected (%s) and migrated successfully", cfg.Type)
|
||||
zap.L().Info("Database connected and migrated successfully",
|
||||
zap.String("type", cfg.Type),
|
||||
)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -220,7 +223,9 @@ func seedRoles(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Seeded %d roles successfully", len(roles))
|
||||
zap.L().Info("Seeded roles successfully",
|
||||
zap.Int("count", len(roles)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -276,7 +281,9 @@ func seedPermissions(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Seeded %d permissions successfully", len(permissions))
|
||||
zap.L().Info("Seeded permissions successfully",
|
||||
zap.Int("count", len(permissions)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,17 @@ import (
|
||||
"time"
|
||||
|
||||
gorseio "github.com/gorse-io/gorse-go"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// FeedbackType 反馈类型
|
||||
type FeedbackType string
|
||||
|
||||
const (
|
||||
FeedbackTypeLike FeedbackType = "like" // 点赞
|
||||
FeedbackTypeStar FeedbackType = "star" // 收藏
|
||||
FeedbackTypeComment FeedbackType = "comment" // 评论
|
||||
FeedbackTypeRead FeedbackType = "read" // 浏览
|
||||
FeedbackTypeLike FeedbackType = "like" // 点赞
|
||||
FeedbackTypeStar FeedbackType = "star" // 收藏
|
||||
FeedbackTypeComment FeedbackType = "comment" // 评论
|
||||
FeedbackTypeRead FeedbackType = "read" // 浏览
|
||||
)
|
||||
|
||||
// Score 非个性化推荐返回的评分项
|
||||
@@ -206,7 +207,10 @@ func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, cat
|
||||
var err error
|
||||
embedding, err = GetEmbedding(textToEmbed)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to get embedding for item %s: %v, using zero vector", itemID, err)
|
||||
zap.L().Warn("Failed to get embedding for item, using zero vector",
|
||||
zap.String("itemID", itemID),
|
||||
zap.Error(err),
|
||||
)
|
||||
embedding = make([]float64, 1024)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// EmbeddingConfig embedding服务配置
|
||||
@@ -94,7 +95,7 @@ func GetEmbedding(text string) ([]float64, error) {
|
||||
// InitEmbeddingWithConfig 从应用配置初始化embedding
|
||||
func InitEmbeddingWithConfig(apiKey, url, model string) {
|
||||
if apiKey == "" {
|
||||
log.Println("[WARN] Gorse embedding API key not set, using default")
|
||||
zap.L().Warn("Gorse embedding API key not set, using default")
|
||||
}
|
||||
defaultEmbeddingConfig.APIKey = apiKey
|
||||
if url != "" {
|
||||
@@ -103,4 +104,4 @@ func InitEmbeddingWithConfig(apiKey, url, model string) {
|
||||
if model != "" {
|
||||
defaultEmbeddingConfig.Model = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,33 @@ func (r *PostRepository) IsLiked(postID, userID string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// IsLikedBatch 批量检查是否点赞(解决 N+1 问题)
|
||||
// 返回 map[postID]bool
|
||||
func (r *PostRepository) IsLikedBatch(postIDs []string, userID string) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
if len(postIDs) == 0 || userID == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// 初始化所有 postID 为 false
|
||||
for _, postID := range postIDs {
|
||||
result[postID] = false
|
||||
}
|
||||
|
||||
// 一次性查询所有点赞记录
|
||||
var likedPostIDs []string
|
||||
r.db.Model(&model.PostLike{}).
|
||||
Where("post_id IN ? AND user_id = ?", postIDs, userID).
|
||||
Pluck("post_id", &likedPostIDs)
|
||||
|
||||
// 更新已点赞的帖子
|
||||
for _, postID := range likedPostIDs {
|
||||
result[postID] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Favorite 收藏帖子
|
||||
func (r *PostRepository) Favorite(postID, userID string) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -336,6 +363,33 @@ func (r *PostRepository) IsFavorited(postID, userID string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// IsFavoritedBatch 批量检查是否收藏(解决 N+1 问题)
|
||||
// 返回 map[postID]bool
|
||||
func (r *PostRepository) IsFavoritedBatch(postIDs []string, userID string) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
if len(postIDs) == 0 || userID == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// 初始化所有 postID 为 false
|
||||
for _, postID := range postIDs {
|
||||
result[postID] = false
|
||||
}
|
||||
|
||||
// 一次性查询所有收藏记录
|
||||
var favoritedPostIDs []string
|
||||
r.db.Model(&model.Favorite{}).
|
||||
Where("post_id IN ? AND user_id = ?", postIDs, userID).
|
||||
Pluck("post_id", &favoritedPostIDs)
|
||||
|
||||
// 更新已收藏的帖子
|
||||
for _, postID := range favoritedPostIDs {
|
||||
result[postID] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// IncrementViews 增加帖子观看量
|
||||
func (r *PostRepository) IncrementViews(postID string) error {
|
||||
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -67,9 +68,11 @@ func NewAsyncLogManager(
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < cfg.WorkerCount; i++ {
|
||||
m.wg.Add(1)
|
||||
go m.worker(i, operationRepo, loginRepo, dataChangeRepo)
|
||||
for i := range cfg.WorkerCount {
|
||||
workerID := i
|
||||
m.wg.Go(func() {
|
||||
m.worker(workerID, operationRepo, loginRepo, dataChangeRepo)
|
||||
})
|
||||
}
|
||||
|
||||
go m.flushScheduler()
|
||||
@@ -91,8 +94,6 @@ func (m *AsyncLogManager) worker(
|
||||
loginRepo *repository.LoginLogRepository,
|
||||
dataChangeRepo *repository.DataChangeLogRepository,
|
||||
) {
|
||||
defer m.wg.Done()
|
||||
|
||||
opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize)
|
||||
loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize)
|
||||
changeBatch := make([]*model.DataChangeLog, 0, m.cfg.BatchSize)
|
||||
@@ -228,9 +229,7 @@ func (m *AsyncLogManager) writeToFallback(logType string, logs interface{}) erro
|
||||
|
||||
// initFallbackWriter 初始化降级写入器
|
||||
func (m *AsyncLogManager) initFallbackWriter() error {
|
||||
if m.cfg.FallbackPath == "" {
|
||||
m.cfg.FallbackPath = "./logs/fallback.log"
|
||||
}
|
||||
m.cfg.FallbackPath = cmp.Or(m.cfg.FallbackPath, "./logs/fallback.log")
|
||||
|
||||
dir := "./logs"
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -97,7 +97,9 @@ func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType
|
||||
// 使用 AI 审核文本
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil)
|
||||
if err != nil {
|
||||
log.Printf("AI audit text error: %v", err)
|
||||
zap.L().Error("AI audit text error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
@@ -163,7 +165,9 @@ func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*Au
|
||||
// 使用 AI 审核图片
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL})
|
||||
if err != nil {
|
||||
log.Printf("AI audit image error: %v", err)
|
||||
zap.L().Error("AI audit image error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
@@ -219,7 +223,9 @@ func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string,
|
||||
// 使用 AI 审核帖子
|
||||
approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images)
|
||||
if err != nil {
|
||||
log.Printf("AI audit post error: %v", err)
|
||||
zap.L().Error("AI audit post error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
@@ -279,7 +285,9 @@ func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, ima
|
||||
// 使用 AI 审核评论
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images)
|
||||
if err != nil {
|
||||
log.Printf("AI audit comment error: %v", err)
|
||||
zap.L().Error("AI audit comment error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
@@ -395,7 +403,9 @@ func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, conten
|
||||
}
|
||||
|
||||
if err := s.db.Create(&auditLog).Error; err != nil {
|
||||
log.Printf("Failed to save audit log: %v", err)
|
||||
zap.L().Error("Failed to save audit log",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +429,11 @@ func (c *AuditCallback) HandleTextCallback(ctx context.Context, auditID string,
|
||||
return fmt.Errorf("invalid parameters")
|
||||
}
|
||||
|
||||
log.Printf("Processing text audit callback: auditID=%s, result=%+v", auditID, result)
|
||||
zap.L().Debug("Processing text audit callback",
|
||||
zap.String("auditID", auditID),
|
||||
zap.Bool("pass", result.Pass),
|
||||
zap.String("risk", result.Risk),
|
||||
)
|
||||
|
||||
// 根据审核结果执行相应操作
|
||||
// 例如: 更新帖子状态、发送通知等
|
||||
@@ -433,7 +447,11 @@ func (c *AuditCallback) HandleImageCallback(ctx context.Context, auditID string,
|
||||
return fmt.Errorf("invalid parameters")
|
||||
}
|
||||
|
||||
log.Printf("Processing image audit callback: auditID=%s, result=%+v", auditID, result)
|
||||
zap.L().Debug("Processing image audit callback",
|
||||
zap.String("auditID", auditID),
|
||||
zap.Bool("pass", result.Pass),
|
||||
zap.String("risk", result.Risk),
|
||||
)
|
||||
|
||||
// 根据审核结果执行相应操作
|
||||
// 例如: 更新图片状态、删除违规图片等
|
||||
@@ -568,7 +586,7 @@ func (s *AuditScheduler) processPendingAudits() {
|
||||
// 3. 更新审核状态
|
||||
|
||||
// 示例逻辑,实际需要根据业务需求实现
|
||||
log.Println("Processing pending audits...")
|
||||
zap.L().Debug("Processing pending audits")
|
||||
}
|
||||
|
||||
// CleanupOldLogs 清理旧的审核日志
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -298,7 +299,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
// 异步写入缓存
|
||||
go func() {
|
||||
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
|
||||
log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err)
|
||||
zap.L().Warn("async cache message failed",
|
||||
zap.String("component", "ChatService"),
|
||||
zap.String("convID", conversationID),
|
||||
zap.String("msgID", message.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -379,12 +385,9 @@ func (s *chatServiceImpl) cacheMessage(ctx context.Context, convID string, msg *
|
||||
}
|
||||
|
||||
func containsImageSegment(segments model.MessageSegments) bool {
|
||||
for _, seg := range segments {
|
||||
if seg.Type == string(model.ContentTypeImage) || seg.Type == "image" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(segments, func(seg model.MessageSegment) bool {
|
||||
return seg.Type == string(model.ContentTypeImage) || seg.Type == "image"
|
||||
})
|
||||
}
|
||||
|
||||
// GetMessages 获取消息历史(分页,带缓存)
|
||||
@@ -718,7 +721,12 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
|
||||
// 异步写入缓存
|
||||
go func() {
|
||||
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
|
||||
log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err)
|
||||
zap.L().Warn("async cache message failed",
|
||||
zap.String("component", "ChatService"),
|
||||
zap.String("convID", conversationID),
|
||||
zap.String("msgID", message.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
@@ -115,11 +116,16 @@ func (s *CommentService) reviewCommentAsync(
|
||||
// 未启用AI时,直接通过审核并发送后续通知
|
||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
||||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
||||
log.Printf("[WARN] Failed to publish comment without AI moderation: %v", err)
|
||||
zap.L().Warn("Failed to publish comment without AI moderation",
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := s.applyCommentPublishedStats(commentID); err != nil {
|
||||
log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, err)
|
||||
zap.L().Warn("Failed to apply published stats for comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
s.invalidatePostCaches(postID)
|
||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||||
@@ -131,7 +137,10 @@ func (s *CommentService) reviewCommentAsync(
|
||||
var rejectedErr *CommentModerationRejectedError
|
||||
if errors.As(err, &rejectedErr) {
|
||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||
log.Printf("[WARN] Failed to delete rejected comment %s: %v", commentID, delErr)
|
||||
zap.L().Warn("Failed to delete rejected comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(delErr),
|
||||
)
|
||||
}
|
||||
s.notifyCommentModerationRejected(userID, rejectedErr.Reason)
|
||||
return
|
||||
@@ -139,24 +148,39 @@ func (s *CommentService) reviewCommentAsync(
|
||||
|
||||
// 审核服务异常时降级放行,避免评论长期pending
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to publish comment %s after moderation error: %v", commentID, updateErr)
|
||||
zap.L().Warn("Failed to publish comment after moderation error",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
||||
log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, statsErr)
|
||||
zap.L().Warn("Failed to apply published stats for comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(statsErr),
|
||||
)
|
||||
}
|
||||
s.invalidatePostCaches(postID)
|
||||
log.Printf("[WARN] Comment moderation failed, fallback publish comment=%s err=%v", commentID, err)
|
||||
zap.L().Warn("Comment moderation failed, fallback publish comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(err),
|
||||
)
|
||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||||
return
|
||||
}
|
||||
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to publish comment %s: %v", commentID, updateErr)
|
||||
zap.L().Warn("Failed to publish comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
||||
log.Printf("[WARN] Failed to apply published stats for comment %s: %v", commentID, statsErr)
|
||||
zap.L().Warn("Failed to apply published stats for comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(statsErr),
|
||||
)
|
||||
}
|
||||
s.invalidatePostCaches(postID)
|
||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||||
@@ -184,7 +208,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string,
|
||||
if parentUserID != userID {
|
||||
notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID)
|
||||
if notifyErr != nil {
|
||||
log.Printf("[ERROR] Error sending reply notification: %v", notifyErr)
|
||||
zap.L().Error("Error sending reply notification",
|
||||
zap.Error(notifyErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -192,7 +218,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string,
|
||||
if postOwnerID != userID {
|
||||
notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID)
|
||||
if notifyErr != nil {
|
||||
log.Printf("[ERROR] Error sending comment notification: %v", notifyErr)
|
||||
zap.L().Error("Error sending comment notification",
|
||||
zap.Error(notifyErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +231,9 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string,
|
||||
go func() {
|
||||
if s.gorseClient.IsEnabled() {
|
||||
if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeComment, userID, postID); err != nil {
|
||||
log.Printf("[WARN] Failed to insert comment feedback to Gorse: %v", err)
|
||||
zap.L().Warn("Failed to insert comment feedback to Gorse",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -226,7 +256,9 @@ func (s *CommentService) notifyCommentModerationRejected(userID, reason string)
|
||||
"评论审核未通过",
|
||||
content,
|
||||
); err != nil {
|
||||
log.Printf("[WARN] Failed to send comment moderation reject notification: %v", err)
|
||||
zap.L().Warn("Failed to send comment moderation reject notification",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -307,7 +339,9 @@ func (s *CommentService) Like(ctx context.Context, commentID, userID string) err
|
||||
)
|
||||
}
|
||||
if notifyErr != nil {
|
||||
log.Printf("[ERROR] Error sending like notification: %v", notifyErr)
|
||||
zap.L().Error("Error sending like notification",
|
||||
zap.Error(notifyErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -137,7 +140,10 @@ type groupNoticeMessage struct {
|
||||
func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMessage) {
|
||||
members, _, err := s.groupRepo.GetMembers(groupID, 1, 1000)
|
||||
if err != nil {
|
||||
log.Printf("[groupService] 获取群成员失败: groupID=%s, err=%v", groupID, err)
|
||||
zap.L().Error("获取群成员失败",
|
||||
zap.String("groupID", groupID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if s.sseHub != nil {
|
||||
@@ -368,7 +374,10 @@ func (s *groupService) DissolveGroup(userID string, groupID string) error {
|
||||
// 先删除群组对应的会话(包括参与者、消息)
|
||||
if s.messageRepo != nil {
|
||||
if err := s.messageRepo.DeleteConversationByGroupID(groupID); err != nil {
|
||||
log.Printf("[DissolveGroup] 删除会话失败: groupID=%s, err=%v", groupID, err)
|
||||
zap.L().Warn("删除会话失败",
|
||||
zap.String("groupID", groupID),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 继续删除群组,不因为会话删除失败而中断
|
||||
}
|
||||
}
|
||||
@@ -453,7 +462,12 @@ func (s *groupService) createSystemNotification(receiverID string, notifyType mo
|
||||
ExtraData: extra,
|
||||
}
|
||||
if err := s.notifyRepo.Create(notification); err != nil {
|
||||
log.Printf("[groupService] create system notification failed: receiverID=%s type=%s err=%v", receiverID, notifyType, err)
|
||||
zap.L().Warn("create system notification failed",
|
||||
zap.String("component", "groupService"),
|
||||
zap.String("receiverID", receiverID),
|
||||
zap.String("type", string(notifyType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,13 +496,22 @@ func (s *groupService) broadcastMemberJoinNotice(groupID string, targetUserID st
|
||||
Category: model.CategoryNotification,
|
||||
}
|
||||
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil {
|
||||
log.Printf("[broadcastMemberJoinNotice] 保存入群提示消息失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err)
|
||||
zap.L().Warn("保存入群提示消息失败",
|
||||
zap.String("component", "broadcastMemberJoinNotice"),
|
||||
zap.String("groupID", groupID),
|
||||
zap.String("userID", targetUserID),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
savedMessage = msg
|
||||
s.invalidateConversationCachesAfterSystemMessage(conv.ID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[broadcastMemberJoinNotice] 获取群组会话失败: groupID=%s, err=%v", groupID, err)
|
||||
zap.L().Warn("获取群组会话失败",
|
||||
zap.String("component", "broadcastMemberJoinNotice"),
|
||||
zap.String("groupID", groupID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +565,12 @@ func (s *groupService) addMemberToGroupAndConversation(group *model.Group, userI
|
||||
conv, err := s.messageRepo.GetConversationByGroupID(group.ID)
|
||||
if err == nil && conv != nil {
|
||||
if err := s.messageRepo.AddParticipant(conv.ID, userID); err != nil {
|
||||
log.Printf("[addMemberToGroupAndConversation] 添加会话参与者失败: groupID=%s, userID=%s, err=%v", group.ID, userID, err)
|
||||
zap.L().Warn("添加会话参与者失败",
|
||||
zap.String("component", "addMemberToGroupAndConversation"),
|
||||
zap.String("groupID", group.ID),
|
||||
zap.String("userID", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID)
|
||||
}
|
||||
@@ -568,11 +596,7 @@ func (s *groupService) collectGroupReviewerIDs(groupID string, ownerID string, s
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(reviewerSet))
|
||||
for id := range reviewerSet {
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
return slices.Collect(maps.Keys(reviewerSet))
|
||||
}
|
||||
|
||||
func (s *groupService) notifyJoinApplyReviewers(
|
||||
@@ -655,10 +679,8 @@ func (s *groupService) InviteMembers(userID string, groupID string, memberIDs []
|
||||
inviter, _ := s.userRepo.GetByID(userID)
|
||||
inviterName := "群成员"
|
||||
inviterAvatar := ""
|
||||
if inviter != nil && inviter.Nickname != "" {
|
||||
inviterName = inviter.Nickname
|
||||
}
|
||||
if inviter != nil {
|
||||
inviterName = cmp.Or(inviter.Nickname, "群成员")
|
||||
inviterAvatar = inviter.Avatar
|
||||
}
|
||||
|
||||
@@ -743,10 +765,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error {
|
||||
applicant, _ := s.userRepo.GetByID(userID)
|
||||
applicantName := "用户"
|
||||
applicantAvatar := ""
|
||||
if applicant != nil && applicant.Nickname != "" {
|
||||
applicantName = applicant.Nickname
|
||||
}
|
||||
if applicant != nil {
|
||||
applicantName = cmp.Or(applicant.Nickname, "用户")
|
||||
applicantAvatar = applicant.Avatar
|
||||
}
|
||||
// 已有待审批单时补发一次提醒,避免管理端漏看
|
||||
@@ -767,10 +787,8 @@ func (s *groupService) JoinGroup(userID string, groupID string) error {
|
||||
applicant, _ := s.userRepo.GetByID(userID)
|
||||
applicantName := "用户"
|
||||
applicantAvatar := ""
|
||||
if applicant != nil && applicant.Nickname != "" {
|
||||
applicantName = applicant.Nickname
|
||||
}
|
||||
if applicant != nil {
|
||||
applicantName = cmp.Or(applicant.Nickname, "用户")
|
||||
applicantAvatar = applicant.Avatar
|
||||
}
|
||||
expireAt := time.Now().Add(72 * time.Hour)
|
||||
@@ -856,9 +874,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r
|
||||
targetUserName := "用户"
|
||||
targetUserAvatar := ""
|
||||
if u, e := s.userRepo.GetByID(userID); e == nil && u != nil {
|
||||
if u.Nickname != "" {
|
||||
targetUserName = u.Nickname
|
||||
}
|
||||
targetUserName = cmp.Or(u.Nickname, "用户")
|
||||
targetUserAvatar = u.Avatar
|
||||
}
|
||||
|
||||
@@ -866,9 +882,7 @@ func (s *groupService) RespondInvite(userID string, flag string, approve bool, r
|
||||
inviterName := "群成员"
|
||||
inviterAvatar := ""
|
||||
if u, e := s.userRepo.GetByID(req.InitiatorID); e == nil && u != nil {
|
||||
if u.Nickname != "" {
|
||||
inviterName = u.Nickname
|
||||
}
|
||||
inviterName = cmp.Or(u.Nickname, "群成员")
|
||||
inviterAvatar = u.Avatar
|
||||
}
|
||||
|
||||
@@ -962,15 +976,13 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo
|
||||
// 保存管理员ID到ReviewerID(覆盖被邀请人的ID)
|
||||
req.Reason = reason
|
||||
targetUserName := "用户"
|
||||
if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil && u.Nickname != "" {
|
||||
targetUserName = u.Nickname
|
||||
if u, e := s.userRepo.GetByID(req.TargetUserID); e == nil && u != nil {
|
||||
targetUserName = cmp.Or(u.Nickname, "用户")
|
||||
}
|
||||
reviewerName := "管理员"
|
||||
reviewerAvatar := ""
|
||||
if u, e := s.userRepo.GetByID(userID); e == nil && u != nil {
|
||||
if u.Nickname != "" {
|
||||
reviewerName = u.Nickname
|
||||
}
|
||||
reviewerName = cmp.Or(u.Nickname, "管理员")
|
||||
reviewerAvatar = u.Avatar
|
||||
}
|
||||
|
||||
@@ -1185,7 +1197,12 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID
|
||||
conv, err := s.messageRepo.GetConversationByGroupID(groupID)
|
||||
if err == nil && conv != nil {
|
||||
if err := s.messageRepo.RemoveParticipant(conv.ID, targetUserID); err != nil {
|
||||
log.Printf("[RemoveMember] 移除会话参与者失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err)
|
||||
zap.L().Warn("移除会话参与者失败",
|
||||
zap.String("component", "RemoveMember"),
|
||||
zap.String("groupID", groupID),
|
||||
zap.String("userID", targetUserID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
s.invalidateConversationCachesAfterMembershipChange(conv.ID, targetUserID)
|
||||
}
|
||||
@@ -1330,22 +1347,35 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st
|
||||
|
||||
member, err := s.groupRepo.GetMember(groupID, targetUserID)
|
||||
if err != nil {
|
||||
log.Printf("[MuteMember] 获取成员失败: %v", err)
|
||||
zap.L().Warn("获取成员失败",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[MuteMember] 禁言前状态: member.Muted=%v, 即将设置 muted=%v", member.Muted, muted)
|
||||
zap.L().Debug("禁言前状态",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Bool("member.Muted", member.Muted),
|
||||
zap.Bool("muted", muted),
|
||||
)
|
||||
member.Muted = muted
|
||||
if err := s.groupRepo.UpdateMember(member); err != nil {
|
||||
log.Printf("[MuteMember] 更新成员失败: %v", err)
|
||||
zap.L().Warn("更新成员失败",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
log.Printf("[MuteMember] 禁言状态已更新到数据库")
|
||||
zap.L().Debug("禁言状态已更新到数据库", zap.String("component", "MuteMember"))
|
||||
|
||||
// 验证更新结果
|
||||
updatedMember, _ := s.groupRepo.GetMember(groupID, targetUserID)
|
||||
if updatedMember != nil {
|
||||
log.Printf("[MuteMember] 验证: member.Muted=%v", updatedMember.Muted)
|
||||
zap.L().Debug("验证成员状态",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Bool("member.Muted", updatedMember.Muted),
|
||||
)
|
||||
}
|
||||
|
||||
// 获取被禁言用户的显示名称
|
||||
@@ -1382,14 +1412,24 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st
|
||||
|
||||
// 保存消息并获取 seq
|
||||
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil {
|
||||
log.Printf("[MuteMember] 保存禁言消息失败: %v", err)
|
||||
zap.L().Warn("保存禁言消息失败",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
savedMessage = msg
|
||||
log.Printf("[MuteMember] 禁言消息已保存, ID=%s, Seq=%d", msg.ID, msg.Seq)
|
||||
zap.L().Debug("禁言消息已保存",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.String("msgID", msg.ID),
|
||||
zap.Int64("seq", msg.Seq),
|
||||
)
|
||||
s.invalidateConversationCachesAfterSystemMessage(conv.ID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[MuteMember] 获取群组会话失败: %v", err)
|
||||
zap.L().Warn("获取群组会话失败",
|
||||
zap.String("component", "MuteMember"),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -94,7 +94,12 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s
|
||||
// 异步写入缓存
|
||||
go func() {
|
||||
if err := s.cacheMessage(context.Background(), conv.ID, msg); err != nil {
|
||||
log.Printf("[MessageService] async cache message failed, convID=%s, msgID=%s, err=%v", conv.ID, msg.ID, err)
|
||||
zap.L().Warn("async cache message failed",
|
||||
zap.String("component", "MessageService"),
|
||||
zap.String("convID", conv.ID),
|
||||
zap.String("msgID", msg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PostModerationRejectedError 帖子审核拒绝错误
|
||||
@@ -73,7 +74,9 @@ func (s *PostAIService) ModeratePost(ctx context.Context, title, content string,
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
}
|
||||
log.Printf("[WARN] AI moderation failed, fallback allow: %v", err)
|
||||
zap.L().Warn("AI moderation failed, fallback allow",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !approved {
|
||||
@@ -93,7 +96,9 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
}
|
||||
log.Printf("[WARN] AI comment moderation failed, fallback allow: %v", err)
|
||||
zap.L().Warn("AI comment moderation failed, fallback allow",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !approved {
|
||||
|
||||
@@ -514,10 +514,9 @@ func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs
|
||||
return isLikedMap, isFavoritedMap, nil
|
||||
}
|
||||
|
||||
for _, postID := range postIDs {
|
||||
isLikedMap[postID] = s.postRepo.IsLiked(postID, userID)
|
||||
isFavoritedMap[postID] = s.postRepo.IsFavorited(postID, userID)
|
||||
}
|
||||
// 使用批量查询替代循环中的单个查询,解决 N+1 问题
|
||||
isLikedMap = s.postRepo.IsLikedBatch(postIDs, userID)
|
||||
isFavoritedMap = s.postRepo.IsFavoritedBatch(postIDs, userID)
|
||||
|
||||
return isLikedMap, isFavoritedMap, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -188,12 +189,7 @@ func normalizeWeeks(source []int) []int {
|
||||
}
|
||||
|
||||
func containsWeek(weeks []int, target int) bool {
|
||||
for _, week := range weeks {
|
||||
if week == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(weeks, target)
|
||||
}
|
||||
|
||||
func normalizeHexColor(color string) string {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"carrot_bbs/internal/model"
|
||||
redisclient "carrot_bbs/internal/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -289,13 +289,17 @@ func NewSensitiveService(db *gorm.DB, redisClient *redisclient.Client, config *S
|
||||
// 初始化加载敏感词
|
||||
if config.LoadFromDB {
|
||||
if err := s.loadFromDB(context.Background()); err != nil {
|
||||
log.Printf("Failed to load sensitive words from database: %v", err)
|
||||
zap.L().Warn("Failed to load sensitive words from database",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if config.LoadFromRedis && redisClient != nil {
|
||||
if err := s.loadFromRedis(context.Background()); err != nil {
|
||||
log.Printf("Failed to load sensitive words from redis: %v", err)
|
||||
zap.L().Warn("Failed to load sensitive words from redis",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +369,9 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor
|
||||
result := s.db.Where("word = ?", word).First(&existing)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
if err := s.db.Create(&sensitiveWord).Error; err != nil {
|
||||
log.Printf("Failed to save sensitive word to database: %v", err)
|
||||
zap.L().Warn("Failed to save sensitive word to database",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
} else if result.Error == nil {
|
||||
// 更新已存在的记录
|
||||
@@ -373,7 +379,9 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor
|
||||
existing.Level = wordLevel
|
||||
existing.IsActive = true
|
||||
if err := s.db.Save(&existing).Error; err != nil {
|
||||
log.Printf("Failed to update sensitive word in database: %v", err)
|
||||
zap.L().Warn("Failed to update sensitive word in database",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +414,9 @@ func (s *sensitiveServiceImpl) RemoveWord(ctx context.Context, word string) erro
|
||||
if s.db != nil {
|
||||
result := s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Update("is_active", false)
|
||||
if result.Error != nil {
|
||||
log.Printf("Failed to deactivate sensitive word in database: %v", result.Error)
|
||||
zap.L().Warn("Failed to deactivate sensitive word in database",
|
||||
zap.Error(result.Error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +471,9 @@ func (s *sensitiveServiceImpl) loadFromDB(ctx context.Context) error {
|
||||
s.tree.AddWord(word.Word, word.Level, word.Category)
|
||||
}
|
||||
|
||||
log.Printf("Loaded %d sensitive words from database", len(words))
|
||||
zap.L().Info("Loaded sensitive words from database",
|
||||
zap.Int("count", len(words)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
@@ -65,7 +67,9 @@ func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHea
|
||||
// 生成预览图
|
||||
previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to generate preview images: %v", err)
|
||||
zap.L().Warn("Failed to generate preview images",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
return url, previewURL, previewURLLarge, nil
|
||||
@@ -189,10 +193,7 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string,
|
||||
// 优先从文件字节探测真实类型,避免前端压缩/转码后 header 与实际格式不一致
|
||||
detectedType := normalizeImageContentType(http.DetectContentType(originalData))
|
||||
headerType := normalizeImageContentType(file.Header.Get("Content-Type"))
|
||||
contentType := detectedType
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
contentType = headerType
|
||||
}
|
||||
contentType := cmp.Or(detectedType, headerType, "application/octet-stream")
|
||||
|
||||
compressedData, compressedType, err := compressImageData(originalData, contentType)
|
||||
if err != nil {
|
||||
@@ -201,21 +202,9 @@ func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string,
|
||||
compressedType = contentType
|
||||
}
|
||||
|
||||
if compressedType == "" {
|
||||
compressedType = contentType
|
||||
}
|
||||
if compressedType == "" {
|
||||
compressedType = http.DetectContentType(compressedData)
|
||||
}
|
||||
compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData))
|
||||
|
||||
ext := getExtFromContentType(compressedType)
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(file.Filename))
|
||||
}
|
||||
if ext == "" {
|
||||
// 最终兜底,避免对象名无扩展名导致 URL 语义不明确
|
||||
ext = ".jpg"
|
||||
}
|
||||
ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(file.Filename)), ".jpg")
|
||||
|
||||
return compressedData, compressedType, ext, nil
|
||||
}
|
||||
@@ -302,13 +291,17 @@ func (s *UploadService) GeneratePreviewImages(ctx context.Context, originalData
|
||||
// 生成普通预览图(列表/网格模式)
|
||||
previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to generate preview: %v", err)
|
||||
zap.L().Warn("Failed to generate preview",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 生成大预览图(详情页)
|
||||
previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to generate large preview: %v", err)
|
||||
zap.L().Warn("Failed to generate large preview",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
return previewURL, previewURLLarge, nil
|
||||
|
||||
@@ -3,10 +3,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserActivityService 用户活跃统计服务接口
|
||||
@@ -76,14 +77,18 @@ func (s *userActivityService) RecordUserActive(ctx context.Context, userID, logi
|
||||
// 1. 记录到 Redis(同步,快速)
|
||||
if err := s.repo.RecordActivityToRedis(ctx, userID); err != nil {
|
||||
// 记录日志但不阻断流程
|
||||
log.Printf("failed to record activity to redis: %v", err)
|
||||
zap.L().Warn("failed to record activity to redis",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 异步写入数据库(可以使用 goroutine 或消息队列)
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := s.repo.RecordActivity(bgCtx, userID, loginType, ip, userAgent); err != nil {
|
||||
log.Printf("failed to record activity to db: %v", err)
|
||||
zap.L().Warn("failed to record activity to db",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -158,7 +163,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
// 获取今日 DAU
|
||||
todayDAU, err := s.GetDAU(ctx, today)
|
||||
if err != nil {
|
||||
log.Printf("failed to get today DAU: %v", err)
|
||||
zap.L().Warn("failed to get today DAU",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.Today = DailyStats{
|
||||
Date: today.Format("2006-01-02"),
|
||||
@@ -168,7 +175,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
// 获取昨日 DAU
|
||||
yesterdayDAU, err := s.GetDAU(ctx, yesterday)
|
||||
if err != nil {
|
||||
log.Printf("failed to get yesterday DAU: %v", err)
|
||||
zap.L().Warn("failed to get yesterday DAU",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.Yesterday = DailyStats{
|
||||
Date: yesterday.Format("2006-01-02"),
|
||||
@@ -178,7 +187,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
// 获取本周 WAU
|
||||
wau, err := s.GetWAU(ctx, year, week)
|
||||
if err != nil {
|
||||
log.Printf("failed to get WAU: %v", err)
|
||||
zap.L().Warn("failed to get WAU",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.ThisWeek = PeriodStats{
|
||||
Period: fmt.Sprintf("%d-W%02d", year, week),
|
||||
@@ -188,7 +199,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
// 获取本月 MAU
|
||||
mau, err := s.GetMAU(ctx, now.Year(), int(now.Month()))
|
||||
if err != nil {
|
||||
log.Printf("failed to get MAU: %v", err)
|
||||
zap.L().Warn("failed to get MAU",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.ThisMonth = PeriodStats{
|
||||
Period: now.Format("2006-01"),
|
||||
@@ -199,7 +212,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
// 次日留存
|
||||
day1Retention, err := s.GetRetention(ctx, yesterday, today)
|
||||
if err != nil {
|
||||
log.Printf("failed to get day 1 retention: %v", err)
|
||||
zap.L().Warn("failed to get day 1 retention",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.Retention.Day1 = day1Retention
|
||||
|
||||
@@ -207,7 +222,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
day7Date := today.AddDate(0, 0, -7)
|
||||
day7Retention, err := s.GetRetention(ctx, day7Date, today)
|
||||
if err != nil {
|
||||
log.Printf("failed to get day 7 retention: %v", err)
|
||||
zap.L().Warn("failed to get day 7 retention",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.Retention.Day7 = day7Retention
|
||||
|
||||
@@ -215,7 +232,9 @@ func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivitySt
|
||||
day30Date := today.AddDate(0, 0, -30)
|
||||
day30Retention, err := s.GetRetention(ctx, day30Date, today)
|
||||
if err != nil {
|
||||
log.Printf("failed to get day 30 retention: %v", err)
|
||||
zap.L().Warn("failed to get day 30 retention",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resp.Retention.Day30 = day30Retention
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/internal/repository"
|
||||
@@ -11,6 +9,7 @@ import (
|
||||
"github.com/casbin/casbin/v3"
|
||||
gormadapter "github.com/casbin/gorm-adapter/v3"
|
||||
"github.com/google/wire"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -26,21 +25,27 @@ func ProvideCasbinEnforcer(cfg *config.Config, db *gorm.DB) *casbin.Enforcer {
|
||||
// 创建 GORM 适配器,连接数据库
|
||||
adapter, err := gormadapter.NewAdapterByDB(db)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Casbin GORM adapter: %v", err)
|
||||
zap.L().Fatal("Failed to create Casbin GORM adapter",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 创建 Casbin Enforcer,使用数据库适配器
|
||||
enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Casbin enforcer: %v", err)
|
||||
zap.L().Fatal("Failed to create Casbin enforcer",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 从数据库加载策略
|
||||
if err := enforcer.LoadPolicy(); err != nil {
|
||||
log.Printf("[WARNING] Failed to load Casbin policy: %v", err)
|
||||
zap.L().Warn("Failed to load Casbin policy",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
log.Println("[Wire] Initialized Casbin enforcer with GORM adapter")
|
||||
zap.L().Info("Initialized Casbin enforcer with GORM adapter")
|
||||
return enforcer
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
@@ -62,7 +61,9 @@ func ProvideDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
func ProvideRedisClient(cfg *config.Config) *redis.Client {
|
||||
client, err := redis.New(&cfg.Redis)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] Failed to connect to Redis: %v, falling back to memory cache", err)
|
||||
zap.L().Warn("Failed to connect to Redis, falling back to memory cache",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
@@ -85,12 +86,12 @@ func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache {
|
||||
|
||||
// 直接创建 RedisCache 实例,不依赖全局变量
|
||||
if redisClient == nil {
|
||||
log.Println("[Wire] Warning: Redis client is nil, cache will not be available")
|
||||
zap.L().Warn("Redis client is nil, cache will not be available")
|
||||
return nil
|
||||
}
|
||||
|
||||
cacheInstance := cache.NewRedisCache(redisClient)
|
||||
log.Println("[Wire] Initialized Redis cache via dependency injection")
|
||||
zap.L().Info("Initialized Redis cache via dependency injection")
|
||||
return cacheInstance
|
||||
}
|
||||
|
||||
@@ -128,7 +129,9 @@ func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager {
|
||||
func ProvideLogger() *zap.Logger {
|
||||
logger, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create logger: %v", err)
|
||||
panic("Failed to create logger: " + err.Error())
|
||||
}
|
||||
// 设置为全局 logger,以便在其他地方使用 zap.L()
|
||||
zap.ReplaceGlobals(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user