refactor(core): align with cordis spatiotemporal composability architecture

- Purify core micro-kernel by removing context hardcoded helpers and reverse dependencies
- Eliminate init() side effects in infra plugins with reversible lifecycle disposal
- Completely isolate plugins by removing cross-plugin imports and using core/contracts
- Introduce TaskService and RiskControlService contracts for unified cross-plugin APIs
- Regenerate Swagger documentation and update developer guide matrix
- Achieve 0 violations in check_cordis_architecture.sh and 100% test pass
This commit is contained in:
ryan
2026-08-28 15:05:31 +08:00
parent fc7fae7b0e
commit 299ac30ee4
150 changed files with 4328 additions and 2923 deletions
@@ -139,3 +139,55 @@ func Drain(ctx context.Context) error {
}
}
}
// MigrateAndSwitchEngine migrates access logs to target database and switches the active store.
func MigrateAndSwitchEngine(ctx context.Context, targetEngine string, reportProgress func(copied int)) error {
if err := Drain(ctx); err != nil {
return err
}
src, err := logstore.Active(ctx)
if err != nil {
return err
}
dst, err := logstore.BuildForMigration(ctx, targetEngine)
if err != nil {
return err
}
if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil {
return err
}
from, to, err := src.UserAccessLogs.MigrationRange(ctx)
if err != nil {
return err
}
if !from.IsZero() && !to.IsZero() {
if err := dst.UserAccessLogs.EnsurePartitions(ctx, from, to); err != nil {
return err
}
}
var afterID uint64
var copied int
const copyBatchSize = 1000
for {
rows, err := src.UserAccessLogs.ListForMigration(ctx, afterID, copyBatchSize)
if err != nil {
return err
}
if len(rows) == 0 {
break
}
if err := dst.UserAccessLogs.BatchInsert(ctx, rows); err != nil {
return err
}
afterID = rows[len(rows)-1].ID
copied += len(rows)
if reportProgress != nil {
reportProgress(copied)
}
if len(rows) < copyBatchSize {
break
}
}
logstore.InvalidateCache()
return nil
}
@@ -9,14 +9,14 @@ import (
"fmt"
"time"
"Wavelet/pkg/util"
db "Wavelet/plugins/infra/database"
"gorm.io/gorm"
"Wavelet/pkg/util"
)
// CountAccessLogs returns the number of access logs matching filter.
func CountAccessLogs(ctx context.Context, filter AccessLogFilter) (uint64, error) {
ch := db.ChDB(ctx)
ch := getChDB(ctx)
if ch == nil {
return 0, fmt.Errorf("clickhouse gorm connection is not initialized")
}
@@ -31,7 +31,7 @@ func CountAccessLogs(ctx context.Context, filter AccessLogFilter) (uint64, error
// ListAccessLogs returns paginated access logs and the total match count.
func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) {
ch := db.ChDB(ctx)
ch := getChDB(ctx)
if ch == nil {
return nil, 0, fmt.Errorf("clickhouse gorm connection is not initialized")
}
@@ -40,42 +40,28 @@ func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize
return []UserAccessLog{}, 0, nil
}
var total int64
baseQuery := applyFilter(ch.Model(&UserAccessLog{}), filter)
if err := baseQuery.Count(&total).Error; err != nil {
var count int64
query := applyFilter(ch.Model(&UserAccessLog{}), filter)
if err := query.Count(&count).Error; err != nil {
return nil, 0, fmt.Errorf("count access logs: %w", err)
}
if total == 0 {
return []UserAccessLog{}, 0, nil
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
var logs []UserAccessLog
err := applyFilter(ch.Model(&UserAccessLog{}), filter).
Order("created_at DESC, id DESC").
Limit(pageSize).
Offset(offset).
Find(&logs).Error
if err != nil {
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&logs).Error; err != nil {
return nil, 0, fmt.Errorf("list access logs: %w", err)
}
return logs, safeUint64Count(total), nil
return logs, safeUint64Count(count), nil
}
// DeleteAllUserAccessLogs hard-deletes all user access logs via TRUNCATE.
func DeleteAllUserAccessLogs(ctx context.Context) (int64, error) {
if db.ChConn == nil {
conn := getChConn()
if conn == nil {
return 0, fmt.Errorf("clickhouse connection is not initialized")
}
if err := db.ChConn.Exec(ctx, "TRUNCATE TABLE "+UserAccessLog{}.TableName()); err != nil {
if err := conn.Exec(ctx, "TRUNCATE TABLE "+UserAccessLog{}.TableName()); err != nil {
return 0, fmt.Errorf("truncate user access logs: %w", err)
}
return 0, nil
@@ -83,10 +69,11 @@ func DeleteAllUserAccessLogs(ctx context.Context) (int64, error) {
// DeleteUserAccessLogsBefore deletes user access logs older than cutoff.
func DeleteUserAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
if db.ChConn == nil {
conn := getChConn()
if conn == nil {
return 0, fmt.Errorf("clickhouse connection is not initialized")
}
if err := db.ChConn.Exec(ctx, "ALTER TABLE "+UserAccessLog{}.TableName()+" DELETE WHERE created_at < ?", cutoff); err != nil {
if err := conn.Exec(ctx, "ALTER TABLE "+UserAccessLog{}.TableName()+" DELETE WHERE created_at < ?", cutoff); err != nil {
return 0, fmt.Errorf("delete expired user access logs: %w", err)
}
return 0, nil
@@ -8,8 +8,6 @@ import (
"fmt"
"sort"
"time"
db "Wavelet/plugins/infra/database"
)
const hoursInDay = 24
@@ -20,7 +18,7 @@ func GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) {
days = 7
}
ch := db.ChDB(ctx)
ch := getChDB(ctx)
if ch == nil {
return nil, fmt.Errorf("clickhouse gorm connection is not initialized")
}
@@ -69,7 +67,7 @@ func GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) {
// GetBrowserDistribution returns browser-grouped access counts since startTime.
func GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]BrowserShare, error) {
ch := db.ChDB(ctx)
ch := getChDB(ctx)
if ch == nil {
return nil, fmt.Errorf("clickhouse gorm connection is not initialized")
}
@@ -117,7 +115,7 @@ func GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]T
limit = 10
}
ch := db.ChDB(ctx)
ch := getChDB(ctx)
if ch == nil {
return nil, fmt.Errorf("clickhouse gorm connection is not initialized")
}
@@ -16,8 +16,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
db "Wavelet/plugins/infra/database"
)
func setupChGormDB(t *testing.T) *gorm.DB {
@@ -28,7 +26,7 @@ func setupChGormDB(t *testing.T) *gorm.DB {
})
require.NoError(t, err)
require.NoError(t, gormDB.AutoMigrate(&UserAccessLog{}))
db.SetChDBForTest(gormDB)
SetChDBForTest(gormDB)
return gormDB
}
@@ -56,7 +54,7 @@ func TestParseBrowserName(t *testing.T) {
func TestCountAccessLogs_EmptyUserIDs(t *testing.T) {
setupChGormDB(t)
t.Cleanup(func() { db.SetChDBForTest(nil) })
t.Cleanup(func() { SetChDBForTest(nil) })
count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}})
require.NoError(t, err)
@@ -65,7 +63,7 @@ func TestCountAccessLogs_EmptyUserIDs(t *testing.T) {
func TestListAccessLogs_EmptyUserIDs(t *testing.T) {
setupChGormDB(t)
t.Cleanup(func() { db.SetChDBForTest(nil) })
t.Cleanup(func() { SetChDBForTest(nil) })
logs, total, err := ListAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}}, 1, 20)
require.NoError(t, err)
@@ -75,7 +73,7 @@ func TestListAccessLogs_EmptyUserIDs(t *testing.T) {
func TestListAccessLogs_WithFilters(t *testing.T) {
gormDB := setupChGormDB(t)
t.Cleanup(func() { db.SetChDBForTest(nil) })
t.Cleanup(func() { SetChDBForTest(nil) })
now := time.Now().UTC().Truncate(time.Second)
logs := []UserAccessLog{
@@ -116,8 +114,8 @@ func TestBatchInsert_UsesModelBatchSQL(t *testing.T) {
batch: mockBatch,
batchQuery: UserAccessLog{}.BatchInsertSQL(),
}
db.SetChConnForTest(mockConn)
t.Cleanup(func() { db.SetChConnForTest(nil) })
SetChConnForTest(mockConn)
t.Cleanup(func() { SetChConnForTest(nil) })
createdAt := time.Now().UTC()
err := BatchInsert(ctx, []UserAccessLog{
@@ -6,8 +6,6 @@ package logstore
import (
"context"
"fmt"
db "Wavelet/plugins/infra/database"
)
// BatchInsert writes access logs to ClickHouse using the native batch API.
@@ -15,11 +13,12 @@ func BatchInsert(ctx context.Context, logs []UserAccessLog) error {
if len(logs) == 0 {
return nil
}
if db.ChConn == nil {
conn := getChConn()
if conn == nil {
return fmt.Errorf("clickhouse connection is not initialized")
}
batch, err := db.ChConn.PrepareBatch(ctx, UserAccessLog{}.BatchInsertSQL())
batch, err := conn.PrepareBatch(ctx, UserAccessLog{}.BatchInsertSQL())
if err != nil {
return fmt.Errorf("prepare clickhouse batch: %w", err)
}
@@ -8,7 +8,6 @@ import (
"fmt"
"time"
db "Wavelet/plugins/infra/database"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
@@ -93,12 +92,13 @@ func (s *clickhouseUserAccessLogStore) DropExpiredPartitions(_ context.Context,
}
func (s *clickhouseUserAccessLogStore) MigrationRange(ctx context.Context) (time.Time, time.Time, error) {
if db.ChConn == nil {
conn := getChConn()
if conn == nil {
return time.Time{}, time.Time{}, fmt.Errorf("clickhouse connection is not initialized")
}
table := UserAccessLog{}.TableName()
var minTime, maxTime *time.Time
if err := db.ChConn.QueryRow(ctx, "SELECT min(created_at), max(created_at) FROM "+table).Scan(&minTime, &maxTime); err != nil {
if err := conn.QueryRow(ctx, "SELECT min(created_at), max(created_at) FROM "+table).Scan(&minTime, &maxTime); err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("query migration range %s: %w", table, err)
}
if minTime == nil || maxTime == nil {
@@ -108,7 +108,8 @@ func (s *clickhouseUserAccessLogStore) MigrationRange(ctx context.Context) (time
}
func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]UserAccessLog, error) {
if db.ChConn == nil {
conn := getChConn()
if conn == nil {
return nil, fmt.Errorf("clickhouse connection is not initialized")
}
if limit <= 0 {
@@ -116,7 +117,7 @@ func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, aft
}
table := UserAccessLog{}.TableName()
columns := UserAccessLog{}.InsertColumns()
rows, err := db.ChConn.Query(ctx, fmt.Sprintf(
rows, err := conn.Query(ctx, fmt.Sprintf(
"SELECT %s FROM %s WHERE id > ? ORDER BY id ASC LIMIT ?",
columns, table,
), afterID, limit)
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package logstore
import (
"context"
"sync"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"gorm.io/gorm"
"Wavelet/core"
"Wavelet/core/contracts"
)
var (
dbMu sync.RWMutex
dbSvc contracts.DBService
chConn driver.Conn
chDB *gorm.DB
)
// SetDBService configures the DBService instance for logstore.
func SetDBService(s contracts.DBService) {
dbMu.Lock()
defer dbMu.Unlock()
dbSvc = s
}
// SetChConnForTest configures ClickHouse native connection for test or runtime.
func SetChConnForTest(conn driver.Conn) {
dbMu.Lock()
defer dbMu.Unlock()
chConn = conn
}
// SetChDBForTest configures ClickHouse GORM DB for test or runtime.
func SetChDBForTest(db *gorm.DB) {
dbMu.Lock()
defer dbMu.Unlock()
chDB = db
}
func getDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
}
dbMu.RLock()
s := dbSvc
dbMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
func getChDB(ctx context.Context) *gorm.DB {
dbMu.RLock()
customCh := chDB
s := dbSvc
dbMu.RUnlock()
if customCh != nil {
return customCh.WithContext(ctx)
}
if s != nil {
if ch := s.Named("clickhouse"); ch != nil {
return ch.WithContext(ctx)
}
}
return nil
}
func getChConn() driver.Conn {
dbMu.RLock()
defer dbMu.RUnlock()
return chConn
}
@@ -11,8 +11,9 @@ import (
"strings"
"time"
"Wavelet/pkg/idgen"
"gorm.io/gorm"
"Wavelet/pkg/idgen"
)
const (
@@ -12,7 +12,6 @@ import (
"Wavelet/pkg/config"
"Wavelet/pkg/logger"
db "Wavelet/plugins/infra/database"
)
const (
@@ -98,7 +97,7 @@ func buildStore(ctx context.Context, database string, skipFreeze bool) (*Store,
ual.skipFreeze = skipFreeze
return &Store{UserAccessLogs: ual, Status: ual}, nil
case dbNamePostgres, dbNameSQLite:
gdb := db.DB(ctx)
gdb := getDB(ctx)
ual := newUserAccessLogGormStore(gdb)
ual.skipFreeze = skipFreeze
return &Store{UserAccessLogs: ual, Status: ual}, nil
@@ -9,13 +9,14 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"Wavelet/core/contracts"
"Wavelet/pkg/config"
"Wavelet/pkg/idgen"
"Wavelet/pkg/response"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/risk_control/logstore"
"github.com/gin-gonic/gin"
)
// Middleware is an alias for RiskControlMiddleware.
@@ -12,6 +12,9 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"Wavelet/core/contracts"
"Wavelet/pkg/batchwriter"
"Wavelet/pkg/config"
@@ -19,8 +22,6 @@ import (
"Wavelet/pkg/util"
"Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/risk_control/logstore"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) {
+97 -2
View File
@@ -9,10 +9,12 @@ import (
"embed"
"reflect"
"github.com/gin-gonic/gin"
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"github.com/gin-gonic/gin"
"Wavelet/plugins/domain/risk_control/logstore"
)
//go:embed logstore/migrations/*.sql
@@ -69,6 +71,19 @@ func (p *Plugin) Manifest() core.Manifest {
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 0. Bind DBService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
logstore.SetDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
logstore.SetDBService(db)
})
}
ctx.OnDispose(func() error {
logstore.SetDBService(nil)
return nil
})
// 0. Register user access log table migrations
ctx.Migrations().Register("risk_control/logstore", riskControlMigrations)
@@ -98,10 +113,90 @@ func (p *Plugin) Apply(ctx *core.Context) error {
Category: "security",
})
// 4. Register lifecycle disposal cleanup
// 4. Register RiskControlService contract
core.Provide[contracts.RiskControlService](ctx, &riskControlServiceImpl{})
// 5. Register lifecycle disposal cleanup
ctx.OnDispose(func() error {
return StopLogWriter(context.Background())
})
return nil
}
type riskControlServiceImpl struct{}
func (s *riskControlServiceImpl) QueryAccessLogs(ctx context.Context, filter contracts.AccessLogFilterDTO, page, pageSize int) ([]contracts.AccessLogDTO, uint64, error) {
store, err := logstore.Active(ctx)
if err != nil {
return nil, 0, err
}
f := logstore.AccessLogFilter{
UserIDs: filter.UserIDs,
Path: filter.Path,
StartTime: filter.StartTime,
EndTime: filter.EndTime,
}
list, total, err := store.UserAccessLogs.List(ctx, f, page, pageSize)
if err != nil {
return nil, 0, err
}
items := make([]contracts.AccessLogDTO, len(list))
for i, item := range list {
items[i] = contracts.AccessLogDTO{
ID: item.ID,
UserID: item.UserID,
IP: item.IP,
UserAgent: item.UserAgent,
Method: item.Method,
Path: item.Path,
Status: item.Status,
Latency: item.Latency,
CreatedAt: item.CreatedAt,
}
}
return items, total, nil
}
func (s *riskControlServiceImpl) QueryAccessLogStats(ctx context.Context, days int) ([]contracts.AccessLogDailyStatsDTO, error) {
store, err := logstore.Active(ctx)
if err != nil {
return nil, err
}
trend, err := store.UserAccessLogs.GetDailyTrend(ctx, days)
if err != nil {
return nil, err
}
res := make([]contracts.AccessLogDailyStatsDTO, len(trend))
for i, t := range trend {
res[i] = contracts.AccessLogDailyStatsDTO{
Date: t.Date,
PV: t.Count,
}
}
return res, nil
}
func (s *riskControlServiceImpl) ActiveLogEngine(ctx context.Context) string {
store, err := logstore.Active(ctx)
if err != nil {
return "sqlite"
}
active, err := store.Status.ActiveDatabase(ctx)
if err != nil {
return "sqlite"
}
return active
}
func (s *riskControlServiceImpl) IsLogEngineMigrating(ctx context.Context) bool {
return logstore.Migrating(ctx)
}
func (s *riskControlServiceImpl) Drain(ctx context.Context) error {
return Drain(ctx)
}
func (s *riskControlServiceImpl) SwitchLogEngine(ctx context.Context, targetEngine string) error {
return MigrateAndSwitchEngine(ctx, targetEngine, nil)
}