refactor(core): align architecture with cordis spatiotemporal composability

This commit is contained in:
ryan
2026-08-28 13:28:58 +08:00
parent 9f8890d159
commit e19bf36580
23 changed files with 1469 additions and 374 deletions
+13 -1
View File
@@ -40,6 +40,12 @@ func isNil(i any) bool {
} }
} }
func (c *Container) remove(targetType reflect.Type) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.services, targetType)
}
// Provide registers a typed service implementation into the Context's IoC container. // Provide registers a typed service implementation into the Context's IoC container.
func Provide[T any](ctx *Context, service T) { func Provide[T any](ctx *Context, service T) {
if ctx == nil { if ctx == nil {
@@ -50,7 +56,13 @@ func Provide[T any](ctx *Context, service T) {
} }
targetType := reflect.TypeFor[T]() targetType := reflect.TypeFor[T]()
ctx.Container().provide(targetType, service) targetContainer := ctx.Container()
targetContainer.provide(targetType, service)
ctx.OnDispose(func() error {
targetContainer.remove(targetType)
return nil
})
} }
func (c *Container) provide(targetType reflect.Type, service any) { func (c *Container) provide(targetType reflect.Type, service any) {
+19
View File
@@ -10,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"Wavelet/core/contracts"
"Wavelet/core/extpoints" "Wavelet/core/extpoints"
) )
@@ -214,6 +215,24 @@ func (c *Context) Setting() extpoints.SettingExtension {
return c.settings return c.settings
} }
// DB returns the contracts.DBService registered in the IoC container, or nil if not registered.
func (c *Context) DB() contracts.DBService {
svc, err := Inject[contracts.DBService](c)
if err != nil {
return nil
}
return svc
}
// Cache returns the contracts.CacheService registered in the IoC container, or nil if not registered.
func (c *Context) Cache() contracts.CacheService {
svc, err := Inject[contracts.CacheService](c)
if err != nil {
return nil
}
return svc
}
// OnDispose registers a cleanup callback function to be executed when this Context is disposed. // OnDispose registers a cleanup callback function to be executed when this Context is disposed.
// It accepts func() error, func(), or Disposer. // It accepts func() error, func(), or Disposer.
func (c *Context) OnDispose(fn any) { func (c *Context) OnDispose(fn any) {
+48
View File
@@ -46,6 +46,33 @@ type OAuthUserInfoDTO struct {
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
} }
// AuthSourceDTO represents an OAuth / OIDC authentication source.
type AuthSourceDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display.
type AuthSourceViewDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthProvider defines the pluggable OAuth provider contract. // OAuthProvider defines the pluggable OAuth provider contract.
type OAuthProvider interface { type OAuthProvider interface {
Name() string Name() string
@@ -79,6 +106,27 @@ type AuthService interface {
// RevokeUserSessions revokes all active sessions and cached tokens for a user. // RevokeUserSessions revokes all active sessions and cached tokens for a user.
RevokeUserSessions(ctx context.Context, userID uint64) error RevokeUserSessions(ctx context.Context, userID uint64) error
// InvalidateCachedUser invalidates cached user profile data.
InvalidateCachedUser(ctx context.Context, userID uint64)
// InvalidateCachedToken invalidates cached access token data.
InvalidateCachedToken(ctx context.Context, tokenHash string)
// ListAuthSources lists all configured authentication sources.
ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error)
// CreateAuthSource creates a new authentication source.
CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error)
// UpdateAuthSource updates an authentication source.
UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error)
// DeleteAuthSource removes an authentication source.
DeleteAuthSource(ctx context.Context, id uint64) error
// ToggleAuthSource toggles the active state of an authentication source.
ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, error)
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token. // DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
DisallowTokenAuthMiddleware() any DisallowTokenAuthMiddleware() any
} }
+25
View File
@@ -26,6 +26,13 @@ const (
// EventTopicUserDeleted fires when a user account is deleted. // EventTopicUserDeleted fires when a user account is deleted.
EventTopicUserDeleted = "user:deleted" EventTopicUserDeleted = "user:deleted"
// EventTopicUserStatusChanged fires when a user account active status changes.
EventTopicUserStatusChanged = "user:status_changed"
// EventTopicTokenRevoked fires when an access token is revoked.
// #nosec G101
EventTopicTokenRevoked = "auth:token_revoked"
) )
// --- Admin & System Events --- // --- Admin & System Events ---
@@ -107,3 +114,21 @@ type NotificationSentEvent struct {
Success bool `json:"success"` Success bool `json:"success"`
ErrorInfo string `json:"error_info,omitempty"` ErrorInfo string `json:"error_info,omitempty"`
} }
// UserStatusChangedEvent fires when a user status is enabled/disabled.
type UserStatusChangedEvent struct {
UserID uint64 `json:"user_id,string"`
IsActive bool `json:"is_active"`
}
// TokenRevokedEvent fires when an access token is revoked.
type TokenRevokedEvent struct {
UserID uint64 `json:"user_id,string"`
TokenHash string `json:"token_hash"`
}
// UserDeletedEvent fires when a user account is deleted.
type UserDeletedEvent struct {
CurrentUserID uint64 `json:"current_user_id,string"`
TargetUserID uint64 `json:"target_user_id,string"`
}
+46
View File
@@ -29,6 +29,34 @@ type UpdateUserProfileRequest struct {
Location *string `json:"location,omitempty"` Location *string `json:"location,omitempty"`
} }
// AdminListUsersFilter contains query parameters for filtering users in admin panel.
type AdminListUsersFilter struct {
Page int
PageSize int
UserID *uint64
Username string
Email string
}
// AdminCreateUserRequest contains fields for admin to create a user.
type AdminCreateUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
// AdminUpdateUserRequest contains fields for admin to update a user.
type AdminUpdateUserRequest struct {
ID uint64 `json:"id,string"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Password string `json:"password,omitempty"`
}
// UserService defines the contract for user account management and profile queries. // UserService defines the contract for user account management and profile queries.
type UserService interface { type UserService interface {
// GetUserByID retrieves a user by ID. // GetUserByID retrieves a user by ID.
@@ -81,4 +109,22 @@ type UserService interface {
// UniqueUsername generates a unique username candidate based on base. // UniqueUsername generates a unique username candidate based on base.
UniqueUsername(ctx context.Context, base string) (string, error) UniqueUsername(ctx context.Context, base string) (string, error)
// AdminListUsers returns a filtered paginated list of users for admin management.
AdminListUsers(ctx context.Context, filter AdminListUsersFilter) (int64, []*UserDTO, error)
// AdminGetUser retrieves complete user details by ID for admin management.
AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error)
// AdminCreateUser creates a user with admin specified options.
AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error)
// AdminUpdateUser updates user details, email, nickname, admin role, and optional password.
AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error
// AdminUpdateUserStatus updates a user's active status (with admin protection).
AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error
// AdminDeleteUser deletes a user (with self and admin protection, cascading tokens and accounts).
AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error
} }
+210 -17
View File
@@ -21,13 +21,16 @@ type eventListener struct {
id uint64 id uint64
fnVal reflect.Value fnVal reflect.Value
numIn int numIn int
numOut int
hasCtx bool hasCtx bool
hasPayload bool hasPayload bool
argType reflect.Type argType reflect.Type
returnsErr bool returnsErr bool
returnsVal bool
} }
// EventBus is a thread-safe, strongly-typed in-process domain event bus. // EventBus is a thread-safe, strongly-typed in-process domain event bus supporting
// Emit, Waterfall, Parallel, and Serial dispatch semantics.
type EventBus struct { type EventBus struct {
mu sync.RWMutex mu sync.RWMutex
nextID atomic.Uint64 nextID atomic.Uint64
@@ -44,6 +47,10 @@ func NewEventBus() *EventBus {
// On registers an event handler for the given topic. // On registers an event handler for the given topic.
// //
// Supported handler signatures: // Supported handler signatures:
// - func(ctx context.Context, event T) (T, error)
// - func(ctx context.Context, event T) T
// - func(event T) (T, error)
// - func(event T) T
// - func(ctx context.Context, event T) error // - func(ctx context.Context, event T) error
// - func(ctx context.Context, event T) // - func(ctx context.Context, event T)
// - func(event T) error // - func(event T) error
@@ -71,17 +78,29 @@ func (b *EventBus) On(topic string, handler any) Disposer {
panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn)) panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn))
} }
const maxHandlerReturnValues = 2
numOut := fnType.NumOut() numOut := fnType.NumOut()
if numOut > 1 { if numOut > maxHandlerReturnValues {
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 1 supported (error)", numOut)) panic(fmt.Sprintf("core/events: handler has %d return values, maximum 2 supported (value, error)", numOut))
} }
returnsErr := false returnsErr := false
if numOut == 1 { returnsVal := false
outType := fnType.Out(0)
if !outType.Implements(errInterfaceType) { switch numOut {
panic(fmt.Sprintf("core/events: handler return type must be error, got %v", outType)) case 1:
out0 := fnType.Out(0)
if out0.Implements(errInterfaceType) {
returnsErr = true
} else {
returnsVal = true
} }
case 2:
out1 := fnType.Out(1)
if !out1.Implements(errInterfaceType) {
panic(fmt.Sprintf("core/events: second return value must be error, got %v", out1))
}
returnsVal = true
returnsErr = true returnsErr = true
} }
@@ -89,7 +108,9 @@ func (b *EventBus) On(topic string, handler any) Disposer {
id: b.nextID.Add(1), id: b.nextID.Add(1),
fnVal: fnVal, fnVal: fnVal,
numIn: numIn, numIn: numIn,
numOut: numOut,
returnsErr: returnsErr, returnsErr: returnsErr,
returnsVal: returnsVal,
} }
switch numIn { switch numIn {
@@ -162,17 +183,11 @@ func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
ctx = context.Background() ctx = context.Background()
} }
b.mu.RLock() listeners := b.getListeners(topic)
rawListeners := b.handlers[topic] if len(listeners) == 0 {
if len(rawListeners) == 0 {
b.mu.RUnlock()
return nil return nil
} }
listeners := make([]eventListener, len(rawListeners))
copy(listeners, rawListeners)
b.mu.RUnlock()
var payloadVal reflect.Value var payloadVal reflect.Value
if payload != nil { if payload != nil {
payloadVal = reflect.ValueOf(payload) payloadVal = reflect.ValueOf(payload)
@@ -190,8 +205,11 @@ func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
}() }()
results := l.fnVal.Call(args) results := l.fnVal.Call(args)
if l.returnsErr && len(results) > 0 && !results[0].IsNil() { if l.returnsErr {
resErr = results[0].Interface().(error) errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
resErr = results[errIdx].Interface().(error)
}
} }
return resErr return resErr
}() }()
@@ -204,6 +222,181 @@ func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
return errors.Join(errs...) return errors.Join(errs...)
} }
// Waterfall runs handlers sequentially as a transformation pipeline.
// The returned value of each handler becomes the payload input for the next handler.
// If any handler returns an error or panics, execution aborts immediately.
//
//nolint:contextcheck
func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error) {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return initialPayload, nil
}
currentPayload := initialPayload
for _, l := range listeners {
var payloadVal reflect.Value
if currentPayload != nil {
payloadVal = reflect.ValueOf(currentPayload)
}
args := b.buildArgs(ctx, l, payloadVal)
var stepVal any
var stepErr error
func() {
defer func() {
if r := recover(); r != nil {
stepErr = fmt.Errorf("core/events: panic in waterfall handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr {
errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
stepErr = results[errIdx].Interface().(error)
}
}
if stepErr == nil && l.returnsVal && len(results) > 0 {
stepVal = results[0].Interface()
}
}()
if stepErr != nil {
return nil, stepErr
}
if l.returnsVal {
currentPayload = stepVal
}
}
return currentPayload, nil
}
// Parallel executes all subscribers of the topic concurrently in separate goroutines.
// It waits for all handlers to complete and collects any errors via errors.Join.
//
//nolint:contextcheck
func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return nil
}
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
var wg sync.WaitGroup
errCh := make(chan error, len(listeners))
for _, l := range listeners {
wg.Add(1)
go func(listener eventListener) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("core/events: panic in parallel handler for topic %q: %v", topic, r)
}
}()
args := b.buildArgs(ctx, listener, payloadVal)
results := listener.fnVal.Call(args)
if listener.returnsErr {
errIdx := listener.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
errCh <- results[errIdx].Interface().(error)
}
}
}(l)
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Serial executes subscribers strictly in sequence.
// If any subscriber returns an error or panics, execution stops immediately and returns that error.
//
//nolint:contextcheck
func (b *EventBus) Serial(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return nil
}
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
for _, l := range listeners {
args := b.buildArgs(ctx, l, payloadVal)
var stepErr error
func() {
defer func() {
if r := recover(); r != nil {
stepErr = fmt.Errorf("core/events: panic in serial handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr {
errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
stepErr = results[errIdx].Interface().(error)
}
}
}()
if stepErr != nil {
return stepErr
}
}
return nil
}
func (b *EventBus) getListeners(topic string) []eventListener {
b.mu.RLock()
raw := b.handlers[topic]
if len(raw) == 0 {
b.mu.RUnlock()
return nil
}
listeners := make([]eventListener, len(raw))
copy(listeners, raw)
b.mu.RUnlock()
return listeners
}
func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value { func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value {
if l.numIn == 0 { if l.numIn == 0 {
return nil return nil
+82 -4
View File
@@ -247,13 +247,13 @@ func TestEventBusInvalidHandlerPanics(t *testing.T) {
}) })
assert.Panics(t, func() { assert.Panics(t, func() {
// More than 1 return value // More than 2 return values
bus.On("test:invalid", func() (int, error) { return 0, nil }) bus.On("test:invalid", func() (int, string, error) { return 0, "", nil })
}) })
assert.Panics(t, func() { assert.Panics(t, func() {
// Return value is not error // 2 return values, but second is not error
bus.On("test:invalid", func() int { return 0 }) bus.On("test:invalid", func() (int, string) { return 0, "" })
}) })
} }
@@ -316,3 +316,81 @@ func TestEventBusConcurrentAccess(t *testing.T) {
wg.Wait() wg.Wait()
assert.Greater(t, receivedCount.Load(), int64(0)) assert.Greater(t, receivedCount.Load(), int64(0))
} }
func TestEventBusWaterfall(t *testing.T) {
bus := core.NewEventBus()
// Handler 1: appends "-first"
bus.On("pipeline:transform", func(ctx context.Context, s string) string {
return s + "-first"
})
// Handler 2: appends "-second" with error return
bus.On("pipeline:transform", func(s string) (string, error) {
return s + "-second", nil
})
res, err := bus.Waterfall(context.Background(), "pipeline:transform", "init")
assert.NoError(t, err)
assert.Equal(t, "init-first-second", res)
// Test short-circuit on error
expectedErr := errors.New("waterfall step failed")
bus.On("pipeline:error", func(s string) (string, error) {
return s, expectedErr
})
bus.On("pipeline:error", func(s string) string {
return s + "-should-not-run"
})
res, err = bus.Waterfall(context.Background(), "pipeline:error", "start")
assert.ErrorIs(t, err, expectedErr)
assert.Nil(t, res)
}
func TestEventBusParallel(t *testing.T) {
bus := core.NewEventBus()
var counter atomic.Int64
err1 := errors.New("parallel err 1")
bus.On("test:parallel", func(ctx context.Context, val int) error {
counter.Add(int64(val))
return nil
})
bus.On("test:parallel", func(val int) error {
counter.Add(int64(val))
return err1
})
err := bus.Parallel(context.Background(), "test:parallel", 10)
assert.ErrorIs(t, err, err1)
assert.Equal(t, int64(20), counter.Load())
}
func TestEventBusSerial(t *testing.T) {
bus := core.NewEventBus()
var executed []int
errStop := errors.New("serial stop")
bus.On("test:serial", func() error {
executed = append(executed, 1)
return nil
})
bus.On("test:serial", func() error {
executed = append(executed, 2)
return errStop
})
bus.On("test:serial", func() error {
executed = append(executed, 3)
return nil
})
err := bus.Serial(context.Background(), "test:serial", nil)
assert.ErrorIs(t, err, errStop)
assert.Equal(t, []int{1, 2}, executed)
}
+41
View File
@@ -278,3 +278,44 @@ func TestContextExtensionPointsIntegration(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, eventReceived) assert.True(t, eventReceived)
} }
func TestExtensionPointsUnregister(t *testing.T) {
ctx := core.NewContext(context.Background())
// 1. Router unregister
rd := ctx.Router().GET("/temp", "temp_handler")
assert.Greater(t, rd.ID, uint64(0))
assert.Len(t, ctx.Router().Routes(), 1)
assert.True(t, ctx.Router().Unregister("GET", "/temp"))
assert.Len(t, ctx.Router().Routes(), 0)
rd2 := ctx.Router().POST("/temp2", "temp2_handler")
assert.Len(t, ctx.Router().Routes(), 1)
assert.True(t, ctx.Router().UnregisterByID(rd2.ID))
assert.Len(t, ctx.Router().Routes(), 0)
// 2. Task unregister
ctx.Task().Register("temp:task", "handler")
assert.Len(t, ctx.Task().Tasks(), 1)
assert.True(t, ctx.Task().Unregister("temp:task"))
assert.Len(t, ctx.Task().Tasks(), 0)
// 3. Schedule unregister
ctx.Schedule().RegisterCron("@hourly", "temp:cron", nil)
assert.Len(t, ctx.Schedule().Schedules(), 1)
assert.True(t, ctx.Schedule().Unregister("temp:cron"))
assert.Len(t, ctx.Schedule().Schedules(), 0)
// 4. Setting unregister
ctx.Settings().Register(extpoints.SettingSchema{Key: "temp.key", Default: 1})
assert.Len(t, ctx.Settings().Schemas(), 1)
assert.True(t, ctx.Settings().Unregister("temp.key"))
assert.Len(t, ctx.Settings().Schemas(), 0)
// 5. Migration unregister
fsys := fstest.MapFS{"001.sql": &fstest.MapFile{Data: []byte("-- migration")}}
ctx.Migrations().Register("temp_plugin", fsys)
assert.Len(t, ctx.Migrations().Entries(), 1)
assert.True(t, ctx.Migrations().Unregister("temp_plugin"))
assert.Len(t, ctx.Migrations().Entries(), 0)
}
+20
View File
@@ -21,6 +21,7 @@ type MigrationExtension interface {
Register(pluginID string, fsys fs.FS, dir ...string) Register(pluginID string, fsys fs.FS, dir ...string)
Entries() []MigrationEntry Entries() []MigrationEntry
Get(pluginID string) (MigrationEntry, bool) Get(pluginID string) (MigrationEntry, bool)
Unregister(pluginID string) bool
} }
// MigrationRegistry collects and stores migration entries from plugins. // MigrationRegistry collects and stores migration entries from plugins.
@@ -69,6 +70,25 @@ func (m *MigrationRegistry) Register(pluginID string, fsys fs.FS, dir ...string)
m.lookup[pluginID] = entry m.lookup[pluginID] = entry
} }
// Unregister removes a registered migration entry by plugin ID.
func (m *MigrationRegistry) Unregister(pluginID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.lookup[pluginID]; !exists {
return false
}
delete(m.lookup, pluginID)
for i, e := range m.entries {
if e.PluginID == pluginID {
m.entries = append(m.entries[:i], m.entries[i+1:]...)
break
}
}
return true
}
// Entries returns a copy of all registered migration entries in registration order. // Entries returns a copy of all registered migration entries in registration order.
func (m *MigrationRegistry) Entries() []MigrationEntry { func (m *MigrationRegistry) Entries() []MigrationEntry {
m.mu.RLock() m.mu.RLock()
+50
View File
@@ -10,6 +10,7 @@ import (
// RouteDefinition holds the metadata and handler list for a single HTTP route. // RouteDefinition holds the metadata and handler list for a single HTTP route.
type RouteDefinition struct { type RouteDefinition struct {
ID uint64
Method string Method string
Path string Path string
Handlers []any Handlers []any
@@ -31,11 +32,14 @@ type RouterExtension interface {
Any(path string, handlers ...any) []RouteDefinition Any(path string, handlers ...any) []RouteDefinition
Routes() []RouteDefinition Routes() []RouteDefinition
Middlewares() []any Middlewares() []any
Unregister(method, path string) bool
UnregisterByID(id uint64) bool
} }
// RouterRegistry implements RouterExtension as the root route and middleware collector. // RouterRegistry implements RouterExtension as the root route and middleware collector.
type RouterRegistry struct { type RouterRegistry struct {
mu sync.RWMutex mu sync.RWMutex
nextID uint64
routes []RouteDefinition routes []RouteDefinition
middlewares []any middlewares []any
} }
@@ -75,7 +79,9 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.nextID++
rd := RouteDefinition{ rd := RouteDefinition{
ID: r.nextID,
Method: strings.ToUpper(method), Method: strings.ToUpper(method),
Path: cleanPath(path), Path: cleanPath(path),
Handlers: handlers, Handlers: handlers,
@@ -85,6 +91,37 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin
return rd return rd
} }
// Unregister removes a route matching method and path from the registry.
func (r *RouterRegistry) Unregister(method, path string) bool {
r.mu.Lock()
defer r.mu.Unlock()
targetMethod := strings.ToUpper(method)
targetPath := cleanPath(path)
for i, rd := range r.routes {
if rd.Method == targetMethod && rd.Path == targetPath {
r.routes = append(r.routes[:i], r.routes[i+1:]...)
return true
}
}
return false
}
// UnregisterByID removes a route by its unique ID.
func (r *RouterRegistry) UnregisterByID(id uint64) bool {
r.mu.Lock()
defer r.mu.Unlock()
for i, rd := range r.routes {
if rd.ID == id {
r.routes = append(r.routes[:i], r.routes[i+1:]...)
return true
}
}
return false
}
// GET registers a GET route. // GET registers a GET route.
func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition { func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition {
return r.Handle("GET", path, handlers...) return r.Handle("GET", path, handlers...)
@@ -176,7 +213,9 @@ func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefiniti
allMiddlewares = append(allMiddlewares, g.registry.middlewares...) allMiddlewares = append(allMiddlewares, g.registry.middlewares...)
allMiddlewares = append(allMiddlewares, g.middlewares...) allMiddlewares = append(allMiddlewares, g.middlewares...)
g.registry.nextID++
rd := RouteDefinition{ rd := RouteDefinition{
ID: g.registry.nextID,
Method: strings.ToUpper(method), Method: strings.ToUpper(method),
Path: fullPath, Path: fullPath,
Handlers: handlers, Handlers: handlers,
@@ -186,6 +225,17 @@ func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefiniti
return rd return rd
} }
// Unregister removes a route under this group prefix matching method and path.
func (g *RouterGroup) Unregister(method, path string) bool {
fullPath := joinPaths(g.prefix, path)
return g.registry.Unregister(method, fullPath)
}
// UnregisterByID removes a route by its unique ID.
func (g *RouterGroup) UnregisterByID(id uint64) bool {
return g.registry.UnregisterByID(id)
}
// GET registers a GET route in this group. // GET registers a GET route in this group.
func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition { func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition {
return g.Handle("GET", path, handlers...) return g.Handle("GET", path, handlers...)
+20
View File
@@ -32,6 +32,7 @@ type ScheduleExtension interface {
RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption) RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption)
Schedules() []ScheduleDefinition Schedules() []ScheduleDefinition
Get(taskType string) (ScheduleDefinition, bool) Get(taskType string) (ScheduleDefinition, bool)
Unregister(taskType string) bool
} }
// ScheduleRegistry collects and manages schedule registrations. // ScheduleRegistry collects and manages schedule registrations.
@@ -85,6 +86,25 @@ func (s *ScheduleRegistry) RegisterCron(spec string, taskType string, payload an
s.Register(spec, taskType, payload, opts...) s.Register(spec, taskType, payload, opts...)
} }
// Unregister removes a registered schedule definition by its task type.
func (s *ScheduleRegistry) Unregister(taskType string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.lookup[taskType]; !exists {
return false
}
delete(s.lookup, taskType)
for i, item := range s.schedules {
if item.TaskType == taskType {
s.schedules = append(s.schedules[:i], s.schedules[i+1:]...)
break
}
}
return true
}
// Schedules returns a copy of all registered ScheduleDefinitions. // Schedules returns a copy of all registered ScheduleDefinitions.
func (s *ScheduleRegistry) Schedules() []ScheduleDefinition { func (s *ScheduleRegistry) Schedules() []ScheduleDefinition {
s.mu.RLock() s.mu.RLock()
+20
View File
@@ -22,6 +22,7 @@ type SettingExtension interface {
Register(schema SettingSchema) Register(schema SettingSchema)
Schemas() []SettingSchema Schemas() []SettingSchema
Get(key string) (SettingSchema, bool) Get(key string) (SettingSchema, bool)
Unregister(key string) bool
} }
// SettingRegistry collects and manages setting configuration schemas. // SettingRegistry collects and manages setting configuration schemas.
@@ -62,6 +63,25 @@ func (s *SettingRegistry) Register(schema SettingSchema) {
s.lookup[schema.Key] = schema s.lookup[schema.Key] = schema
} }
// Unregister removes a registered SettingSchema by its key.
func (s *SettingRegistry) Unregister(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.lookup[key]; !exists {
return false
}
delete(s.lookup, key)
for i, item := range s.schemas {
if item.Key == key {
s.schemas = append(s.schemas[:i], s.schemas[i+1:]...)
break
}
}
return true
}
// Schemas returns a copy of all registered SettingSchemas. // Schemas returns a copy of all registered SettingSchemas.
func (s *SettingRegistry) Schemas() []SettingSchema { func (s *SettingRegistry) Schemas() []SettingSchema {
s.mu.RLock() s.mu.RLock()
+20
View File
@@ -57,6 +57,7 @@ type TaskExtension interface {
Register(pattern string, handler any, opts ...TaskOption) Register(pattern string, handler any, opts ...TaskOption)
Tasks() []TaskDefinition Tasks() []TaskDefinition
Get(pattern string) (TaskDefinition, bool) Get(pattern string) (TaskDefinition, bool)
Unregister(pattern string) bool
} }
// TaskRegistry collects and manages task registrations. // TaskRegistry collects and manages task registrations.
@@ -104,6 +105,25 @@ func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption)
t.lookup[pattern] = td t.lookup[pattern] = td
} }
// Unregister removes a registered task definition by its pattern.
func (t *TaskRegistry) Unregister(pattern string) bool {
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.lookup[pattern]; !exists {
return false
}
delete(t.lookup, pattern)
for i, item := range t.tasks {
if item.Pattern == pattern {
t.tasks = append(t.tasks[:i], t.tasks[i+1:]...)
break
}
}
return true
}
// Tasks returns a copy of all registered TaskDefinitions. // Tasks returns a copy of all registered TaskDefinitions.
func (t *TaskRegistry) Tasks() []TaskDefinition { func (t *TaskRegistry) Tasks() []TaskDefinition {
t.mu.RLock() t.mu.RLock()
@@ -7,32 +7,24 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/infra/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"Wavelet/core/contracts"
"Wavelet/pkg/response"
) )
// ListAuthSources lists all configured authentication sources. // ListAuthSources lists all configured authentication sources.
func ListAuthSources(c *gin.Context) { func ListAuthSources(c *gin.Context) {
var sources []auth.AuthSource authSvc := getAuthService(c.Request.Context())
gormDB := database.DB(c.Request.Context()) if authSvc == nil {
if err := gormDB.Order("id ASC").Find(&sources).Error; err != nil { response.AbortInternal(c, "认证服务未就绪")
response.AbortInternal(c, "获取认证源列表失败")
return return
} }
views := make([]auth.AuthSourceView, len(sources)) views, err := authSvc.ListAuthSources(c.Request.Context())
for i := range sources { if err != nil {
views[i] = auth.AuthSourceView{ response.AbortInternal(c, "获取认证源列表失败")
ID: sources[i].ID, return
Name: sources[i].Name,
Type: sources[i].Type,
DisplayName: sources[i].DisplayName,
IsActive: sources[i].IsActive,
IconURL: sources[i].IconURL,
ClientSecretConfigured: sources[i].ClientSecret != "",
}
} }
c.JSON(http.StatusOK, response.OK(views)) c.JSON(http.StatusOK, response.OK(views))
@@ -40,25 +32,25 @@ func ListAuthSources(c *gin.Context) {
// CreateAuthSource creates a new authentication source. // CreateAuthSource creates a new authentication source.
func CreateAuthSource(c *gin.Context) { func CreateAuthSource(c *gin.Context) {
var source auth.AuthSource var source contracts.AuthSourceDTO
if err := c.ShouldBindJSON(&source); err != nil { if err := c.ShouldBindJSON(&source); err != nil {
response.AbortBadRequest(c, "无效的参数") response.AbortBadRequest(c, "无效的参数")
return return
} }
if err := source.Validate(); err != nil { authSvc := getAuthService(c.Request.Context())
response.AbortBadRequest(c, err.Error()) if authSvc == nil {
response.AbortInternal(c, "认证服务未就绪")
return return
} }
gormDB := database.DB(c.Request.Context()) created, err := authSvc.CreateAuthSource(c.Request.Context(), source)
if err := gormDB.Create(&source).Error; err != nil { if err != nil {
response.AbortBadRequest(c, "创建认证源失败: "+err.Error()) response.AbortBadRequest(c, "创建认证源失败: "+err.Error())
return return
} }
source.Sanitize() c.JSON(http.StatusOK, response.OK(created))
c.JSON(http.StatusOK, response.OK(source))
} }
// UpdateAuthSource updates an authentication source. // UpdateAuthSource updates an authentication source.
@@ -70,40 +62,25 @@ func UpdateAuthSource(c *gin.Context) {
return return
} }
gormDB := database.DB(c.Request.Context()) var req contracts.AuthSourceDTO
var existing auth.AuthSource
if err := gormDB.First(&existing, id).Error; err != nil {
response.AbortNotFound(c, "认证源不存在")
return
}
var req auth.AuthSource
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, "无效的参数") response.AbortBadRequest(c, "无效的参数")
return return
} }
existing.DisplayName = req.DisplayName authSvc := getAuthService(c.Request.Context())
existing.ClientID = req.ClientID if authSvc == nil {
if req.ClientSecret != "" { response.AbortInternal(c, "认证服务未就绪")
existing.ClientSecret = req.ClientSecret return
} }
existing.OpenIDDiscoveryURL = req.OpenIDDiscoveryURL
existing.Scopes = req.Scopes
existing.IconURL = req.IconURL
if err := existing.Validate(); err != nil { updated, err := authSvc.UpdateAuthSource(c.Request.Context(), id, req)
if err != nil {
response.AbortBadRequest(c, err.Error()) response.AbortBadRequest(c, err.Error())
return return
} }
if err := gormDB.Save(&existing).Error; err != nil { c.JSON(http.StatusOK, response.OK(updated))
response.AbortInternal(c, "更新认证源失败")
return
}
existing.Sanitize()
c.JSON(http.StatusOK, response.OK(existing))
} }
// ToggleAuthSource toggles the active state of an auth source. // ToggleAuthSource toggles the active state of an auth source.
@@ -115,27 +92,19 @@ func ToggleAuthSource(c *gin.Context) {
return return
} }
gormDB := database.DB(c.Request.Context()) authSvc := getAuthService(c.Request.Context())
var existing auth.AuthSource if authSvc == nil {
if err := gormDB.First(&existing, id).Error; err != nil { response.AbortInternal(c, "认证服务未就绪")
response.AbortNotFound(c, "认证源不存在")
return return
} }
existing.IsActive = !existing.IsActive toggled, err := authSvc.ToggleAuthSource(c.Request.Context(), id)
if existing.IsActive { if err != nil {
if err := existing.Validate(); err != nil { response.AbortInternal(c, "切换认证源状态失败: "+err.Error())
response.AbortBadRequest(c, err.Error())
return
}
}
if err := gormDB.Model(&existing).Update("is_active", existing.IsActive).Error; err != nil {
response.AbortInternal(c, "切换认证源状态失败")
return return
} }
c.JSON(http.StatusOK, response.OK(gin.H{"is_active": existing.IsActive})) c.JSON(http.StatusOK, response.OK(gin.H{"is_active": toggled.IsActive}))
} }
// DeleteAuthSource deletes an authentication source. // DeleteAuthSource deletes an authentication source.
@@ -147,9 +116,14 @@ func DeleteAuthSource(c *gin.Context) {
return return
} }
gormDB := database.DB(c.Request.Context()) authSvc := getAuthService(c.Request.Context())
if err := gormDB.Delete(&auth.AuthSource{}, id).Error; err != nil { if authSvc == nil {
response.AbortInternal(c, "删除认证源失败") response.AbortInternal(c, "认证服务未就绪")
return
}
if err := authSvc.DeleteAuthSource(c.Request.Context(), id); err != nil {
response.AbortInternal(c, "删除认证源失败: "+err.Error())
return return
} }
@@ -12,11 +12,10 @@ import (
"strings" "strings"
"time" "time"
"Wavelet/core/contracts"
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
mail "Wavelet/pkg/mail" mail "Wavelet/pkg/mail"
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/plugins/domain/cap"
cachepkg "Wavelet/plugins/infra/cache"
db "Wavelet/plugins/infra/database" db "Wavelet/plugins/infra/database"
"Wavelet/plugins/infra/storage/objectstore" "Wavelet/plugins/infra/storage/objectstore"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -341,8 +340,8 @@ func invalidateSystemConfigCaches(ctx context.Context, key string) {
if err := InvalidateSystemConfigCache(ctx, key); err != nil { if err := InvalidateSystemConfigCache(ctx, key); err != nil {
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
} }
if cap.IsRuntimeConfigKey(key) { if globalCoreCtx != nil {
cap.InvalidateRuntimeSettings() _ = globalCoreCtx.Events().Emit(ctx, contracts.EventTopicConfigChanged, contracts.ConfigChangedEvent{Key: key})
} }
} }
@@ -350,17 +349,9 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
invalidateSystemConfigCaches(ctx, key) invalidateSystemConfigCaches(ctx, key)
if key == ConfigKeyStorageConfig { if key == ConfigKeyStorageConfig {
if cachepkg.Redis != nil {
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
}
objectstore.ResetCache() objectstore.ResetCache()
objectstore.PublishCacheInvalidation(ctx) objectstore.PublishCacheInvalidation(ctx)
} }
if key == ConfigKeyFileAccessWhitelist {
if cachepkg.Redis != nil {
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
}
}
if err := InvalidateVisibleSystemConfigsCache(ctx); err != nil { if err := InvalidateVisibleSystemConfigsCache(ctx); err != nil {
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
+57 -265
View File
@@ -4,27 +4,20 @@
package admin package admin
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/auth"
db "Wavelet/plugins/infra/database"
) )
const minPasswordLength = 8
// listUsersRequest 用户列表查询请求 // listUsersRequest 用户列表查询请求
type listUsersRequest struct { type listUsersRequest struct {
Page int `form:"page" binding:"min=1"` Page int `form:"page" binding:"min=1"`
@@ -136,7 +129,19 @@ func ListUsers(c *gin.Context) {
return return
} }
total, dtos, err := listUsers(c.Request.Context(), req) userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
total, dtos, err := userSvc.AdminListUsers(c.Request.Context(), contracts.AdminListUsersFilter{
Page: req.Page,
PageSize: req.PageSize,
UserID: req.UserID,
Username: req.Username,
Email: req.Email,
})
if err != nil { if err != nil {
logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err) logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err)
response.AbortInternal(c, "获取用户列表失败") response.AbortInternal(c, "获取用户列表失败")
@@ -174,7 +179,13 @@ func GetUser(c *gin.Context) {
return return
} }
targetUser, err := getUserDetail(c.Request.Context(), id) userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
targetUser, err := userSvc.AdminGetUser(c.Request.Context(), id)
if abortUserLogicError(c, err, userNotFound, nil, nil) { if abortUserLogicError(c, err, userNotFound, nil, nil) {
return return
} }
@@ -215,7 +226,13 @@ func UpdateUserStatus(c *gin.Context) {
return return
} }
if err := updateUserStatus(c.Request.Context(), id, req.IsActive); err != nil { userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
if err := userSvc.AdminUpdateUserStatus(c.Request.Context(), id, req.IsActive); err != nil {
if abortUserLogicError(c, err, userNotFound, []string{cannotDisable}, nil) { if abortUserLogicError(c, err, userNotFound, []string{cannotDisable}, nil) {
return return
} }
@@ -251,7 +268,14 @@ func DeleteUser(c *gin.Context) {
response.AbortUnauthorized(c, AdminRequired) response.AbortUnauthorized(c, AdminRequired)
return return
} }
if err := deleteUser(c.Request.Context(), currUser.ID, id); err != nil {
userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
if err := userSvc.AdminDeleteUser(c.Request.Context(), currUser.ID, id); err != nil {
if abortUserLogicError(c, err, userNotFound, []string{cannotDelete, cannotDeleteSelf}, nil) { if abortUserLogicError(c, err, userNotFound, []string{cannotDelete, cannotDeleteSelf}, nil) {
return return
} }
@@ -293,7 +317,20 @@ func CreateUser(c *gin.Context) {
return return
} }
newUser, err := createUser(c.Request.Context(), req) userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
newUser, err := userSvc.AdminCreateUser(c.Request.Context(), contracts.AdminCreateUserRequest{
Username: req.Username,
Password: req.Password,
Nickname: req.Nickname,
Email: req.Email,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
})
if abortUserLogicError(c, err, "", nil, []string{usernameRequired, emailRequired, passwordTooShort, usernameExists, emailExists}) { if abortUserLogicError(c, err, "", nil, []string{usernameRequired, emailRequired, passwordTooShort, usernameExists, emailExists}) {
return return
} }
@@ -342,7 +379,14 @@ func UpdateUser(c *gin.Context) {
response.AbortUnauthorized(c, AdminRequired) response.AbortUnauthorized(c, AdminRequired)
return return
} }
err := updateUser(c.Request.Context(), currUser.ID, updateUserParam{
userSvc := getUserService(c.Request.Context())
if userSvc == nil {
response.AbortInternal(c, "用户服务未就绪")
return
}
err := userSvc.AdminUpdateUser(c.Request.Context(), currUser.ID, contracts.AdminUpdateUserRequest{
ID: id, ID: id,
Nickname: req.Nickname, Nickname: req.Nickname,
Email: req.Email, Email: req.Email,
@@ -360,255 +404,3 @@ func UpdateUser(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil()) c.JSON(http.StatusOK, response.OKNil())
} }
func listUsers(ctx context.Context, req listUsersRequest) (int64, []*contracts.UserDTO, error) {
query := db.DB(ctx).Table("w_users")
if req.UserID != nil {
query = query.Where("id = ?", *req.UserID)
}
if req.Username != "" {
query = query.Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(req.Username)+"%")
}
if req.Email != "" {
query = query.Where("email LIKE ? ESCAPE '\\'", util.EscapeLike(req.Email)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var users []*contracts.UserDTO
offset := (req.Page - 1) * req.PageSize
if err := query.
Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
Order("id ASC").
Offset(offset).
Limit(req.PageSize).
Find(&users).Error; err != nil {
return 0, nil, err
}
return total, users, nil
}
func getUserDetail(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := db.DB(ctx).Table("w_users").
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
Where("id = ?", id).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func updateUserStatus(ctx context.Context, id uint64, active bool) error {
var flags struct {
ID uint64
IsAdmin bool
}
if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil {
return err
}
if !active && flags.IsAdmin {
return errors.New(cannotDisable)
}
var tokenHashes []string
if !active {
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", id).Pluck("token_hash", &tokenHashes).Error
}
err := db.DB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error
if err == nil {
auth.InvalidateCachedUser(ctx, id)
if !active {
for _, hash := range tokenHashes {
auth.InvalidateCachedToken(ctx, hash)
}
}
}
return err
}
func deleteUser(ctx context.Context, currentUserID, targetID uint64) error {
if currentUserID == targetID {
return errors.New(cannotDeleteSelf)
}
var flags struct {
ID uint64
IsAdmin bool
}
if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", targetID).First(&flags).Error; err != nil {
return err
}
if flags.IsAdmin {
return errors.New(cannotDelete)
}
var tokenHashes []string
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", targetID).Pluck("token_hash", &tokenHashes).Error
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Table("w_access_tokens").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
return err
}
if err := tx.Table("w_external_accounts").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
return err
}
return tx.Table("w_users").Where("id = ?", targetID).Delete(map[string]any{}).Error
})
if err == nil {
auth.InvalidateCachedUser(ctx, targetID)
for _, hash := range tokenHashes {
auth.InvalidateCachedToken(ctx, hash)
}
}
return err
}
func createUser(ctx context.Context, req createUserRequest) (*contracts.UserDTO, error) {
req.Username = strings.TrimSpace(req.Username)
req.Nickname = strings.TrimSpace(req.Nickname)
req.Password = strings.TrimSpace(req.Password)
req.Email = strings.TrimSpace(req.Email)
if req.Username == "" {
return nil, errors.New(usernameRequired)
}
if req.Email == "" {
return nil, errors.New(emailRequired)
}
if len(req.Password) < minPasswordLength {
return nil, errors.New(passwordTooShort)
}
var count int64
if err := db.DB(ctx).Table("w_users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, errors.New(usernameExists)
}
var emailCount int64
if err := db.DB(ctx).Table("w_users").Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
return nil, err
}
if emailCount > 0 {
return nil, errors.New(emailExists)
}
hash, err := util.HashPassword(req.Password)
if err != nil {
return nil, err
}
if req.Nickname == "" {
req.Nickname = req.Username
}
now := time.Now()
newUser := contracts.UserDTO{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
CreatedAt: now,
UpdatedAt: now,
}
row := map[string]any{
"id": newUser.ID,
"username": newUser.Username,
"password": hash,
"nickname": newUser.Nickname,
"email": newUser.Email,
"is_active": newUser.IsActive,
"is_admin": newUser.IsAdmin,
"created_at": now,
"updated_at": now,
}
if err := db.DB(ctx).Table("w_users").Create(row).Error; err != nil {
return nil, err
}
return &newUser, nil
}
type updateUserParam struct {
ID uint64
Nickname string
Email string
IsAdmin bool
Password string
}
func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam) error {
param.Nickname = strings.TrimSpace(param.Nickname)
param.Email = strings.TrimSpace(param.Email)
param.Password = strings.TrimSpace(param.Password)
if param.Email == "" {
return errors.New(emailRequired)
}
var targetUser contracts.UserDTO
if err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).First(&targetUser).Error; err != nil {
return err
}
if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin {
return errors.New(cannotRevokeSelfAdmin)
}
if targetUser.Email != param.Email {
var count int64
if err := db.DB(ctx).Table("w_users").Where("email = ? AND id != ?", param.Email, param.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New(emailExists)
}
}
if param.Password != "" && len(param.Password) < minPasswordLength {
return errors.New(passwordTooShort)
}
needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin)
var tokenHashes []string
if needRevokeTokens {
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", param.ID).Pluck("token_hash", &tokenHashes).Error
}
if param.Nickname == "" {
param.Nickname = targetUser.Username
}
updates := map[string]any{
"nickname": param.Nickname,
"email": param.Email,
"is_admin": param.IsAdmin,
"updated_at": time.Now(),
}
if param.Password != "" {
hash, err := util.HashPassword(param.Password)
if err != nil {
return err
}
updates["password"] = hash
}
err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).Updates(updates).Error
if err == nil {
auth.InvalidateCachedUser(ctx, param.ID)
if needRevokeTokens {
for _, hash := range tokenHashes {
auth.InvalidateCachedToken(ctx, hash)
}
}
}
return err
}
+48 -1
View File
@@ -50,18 +50,65 @@ func (p *Plugin) Manifest() core.Manifest {
} }
} }
var (
globalUserSvc contracts.UserService
globalAuthSvc contracts.AuthService
globalCoreCtx *core.Context
)
func getUserService(_ context.Context) contracts.UserService {
if globalUserSvc != nil {
return globalUserSvc
}
if globalCoreCtx != nil {
if svc, err := core.Inject[contracts.UserService](globalCoreCtx); err == nil {
globalUserSvc = svc
return svc
}
}
return nil
}
func getAuthService(_ context.Context) contracts.AuthService {
if globalAuthSvc != nil {
return globalAuthSvc
}
if globalCoreCtx != nil {
if svc, err := core.Inject[contracts.AuthService](globalCoreCtx); err == nil {
globalAuthSvc = svc
return svc
}
}
return nil
}
// Apply registers admin routes, tasks, schedules, and settings into the Context. // Apply registers admin routes, tasks, schedules, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error { func (p *Plugin) Apply(ctx *core.Context) error {
// 0. Resolve auth service for middleware (via IoC, not direct import) globalCoreCtx = ctx
// 0. Resolve auth and user services reactively via IoC
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() } var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
var adminMW gin.HandlerFunc = func(c *gin.Context) { c.Next() } var adminMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil { if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
globalAuthSvc = authSvc
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok { if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
loginMW = mw loginMW = mw
} }
if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok { if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok {
adminMW = mw adminMW = mw
} }
} else {
core.When[contracts.AuthService](ctx, func(svc contracts.AuthService) {
globalAuthSvc = svc
})
}
if userSvc, err := core.Inject[contracts.UserService](ctx); err == nil && userSvc != nil {
globalUserSvc = userSvc
} else {
core.When[contracts.UserService](ctx, func(svc contracts.UserService) {
globalUserSvc = svc
})
} }
// 0a. Register migrations // 0a. Register migrations
+12
View File
@@ -5,6 +5,7 @@
package auth package auth
import ( import (
"context"
"embed" "embed"
"Wavelet/core" "Wavelet/core"
@@ -110,5 +111,16 @@ func (p *Plugin) Apply(ctx *core.Context) error {
Category: "security", Category: "security",
}) })
// 5. Register Event Listeners for domain events
ctx.Events().On(contracts.EventTopicUserStatusChanged, func(c context.Context, e contracts.UserStatusChangedEvent) error {
InvalidateCachedUser(c, e.UserID)
return nil
})
ctx.Events().On(contracts.EventTopicUserDeleted, func(c context.Context, e contracts.UserDeletedEvent) error {
InvalidateCachedUser(c, e.TargetUserID)
return nil
})
return nil return nil
} }
+126
View File
@@ -110,6 +110,132 @@ func (s *authServiceImpl) DisallowTokenAuthMiddleware() any {
return DisallowTokenAuth() return DisallowTokenAuth()
} }
func (s *authServiceImpl) InvalidateCachedUser(ctx context.Context, userID uint64) {
InvalidateCachedUser(ctx, userID)
}
func (s *authServiceImpl) InvalidateCachedToken(ctx context.Context, tokenHash string) {
InvalidateCachedToken(ctx, tokenHash)
}
func (s *authServiceImpl) ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) {
var sources []AuthSource
if err := db.DB(ctx).Order("id ASC").Find(&sources).Error; err != nil {
return nil, err
}
views := make([]contracts.AuthSourceViewDTO, len(sources))
for i := range sources {
views[i] = contracts.AuthSourceViewDTO{
ID: sources[i].ID,
Name: sources[i].Name,
Type: sources[i].Type,
DisplayName: sources[i].DisplayName,
IsActive: sources[i].IsActive,
IconURL: sources[i].IconURL,
ClientSecretConfigured: sources[i].ClientSecret != "",
}
}
return views, nil
}
func (s *authServiceImpl) CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
model := AuthSource{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
Scopes: source.Scopes,
IconURL: source.IconURL,
IsActive: source.IsActive,
}
if err := model.Validate(); err != nil {
return nil, err
}
if err := db.DB(ctx).Create(&model).Error; err != nil {
return nil, err
}
model.Sanitize()
return toAuthSourceDTO(&model), nil
}
func (s *authServiceImpl) UpdateAuthSource(ctx context.Context, id uint64, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
var existing AuthSource
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
return nil, err
}
existing.DisplayName = source.DisplayName
existing.ClientID = source.ClientID
if source.ClientSecret != "" {
existing.ClientSecret = source.ClientSecret
}
existing.OpenIDDiscoveryURL = source.OpenIDDiscoveryURL
existing.Scopes = source.Scopes
existing.IconURL = source.IconURL
if err := existing.Validate(); err != nil {
return nil, err
}
if err := db.DB(ctx).Save(&existing).Error; err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(&existing), nil
}
func (s *authServiceImpl) DeleteAuthSource(ctx context.Context, id uint64) error {
var existing AuthSource
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
return err
}
return db.DB(ctx).Delete(&existing).Error
}
func (s *authServiceImpl) ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) {
var existing AuthSource
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
return nil, err
}
existing.IsActive = !existing.IsActive
if err := db.DB(ctx).Save(&existing).Error; err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(&existing), nil
}
func toAuthSourceDTO(s *AuthSource) *contracts.AuthSourceDTO {
if s == nil {
return nil
}
return &contracts.AuthSourceDTO{
ID: s.ID,
Name: s.Name,
Type: s.Type,
DisplayName: s.DisplayName,
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
OpenIDDiscoveryURL: s.OpenIDDiscoveryURL,
Scopes: s.Scopes,
IconURL: s.IconURL,
IsActive: s.IsActive,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}
type authRegistryImpl struct { type authRegistryImpl struct {
mu sync.RWMutex mu sync.RWMutex
providers map[string]contracts.OAuthProvider providers map[string]contracts.OAuthProvider
+1 -1
View File
@@ -81,7 +81,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
// 2. Initialize and provide UserService // 2. Initialize and provide UserService
if p.userSvc == nil { if p.userSvc == nil {
p.userSvc = newUserService() p.userSvc = newUserService(ctx.Events())
} }
core.Provide[contracts.UserService](ctx, p.userSvc) core.Provide[contracts.UserService](ctx, p.userSvc)
+257 -7
View File
@@ -11,13 +11,17 @@ import (
"strings" "strings"
"time" "time"
"Wavelet/core"
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/idgen" "Wavelet/pkg/idgen"
database "Wavelet/plugins/infra/database" database "Wavelet/plugins/infra/database"
"gorm.io/gorm"
pkgu "Wavelet/pkg/util" pkgu "Wavelet/pkg/util"
) )
const columnUpdatedAt = "updated_at"
func toUserDTO(u *User) *contracts.UserDTO { func toUserDTO(u *User) *contracts.UserDTO {
if u == nil { if u == nil {
return nil return nil
@@ -41,10 +45,16 @@ func toUserDTO(u *User) *contracts.UserDTO {
} }
} }
type userServiceImpl struct{} type userServiceImpl struct {
events *core.EventBus
}
func newUserService() contracts.UserService { func newUserService(events ...*core.EventBus) contracts.UserService {
return &userServiceImpl{} var bus *core.EventBus
if len(events) > 0 {
bus = events[0]
}
return &userServiceImpl{events: bus}
} }
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) { func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
@@ -131,7 +141,7 @@ func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req cont
if req.Location != nil { if req.Location != nil {
updates["location"] = *req.Location updates["location"] = *req.Location
} }
updates["updated_at"] = time.Now() updates[columnUpdatedAt] = time.Now()
if err := database.DB(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil { if err := database.DB(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err return nil, err
@@ -156,8 +166,8 @@ func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPass
return database.DB(ctx).Model(&User{}).Where("id = ?", id). return database.DB(ctx).Model(&User{}).Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"password": user.Password, "password": user.Password,
"updated_at": time.Now(), columnUpdatedAt: time.Now(),
}).Error }).Error
} }
@@ -174,7 +184,7 @@ func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, _ stri
return database.DB(ctx).Model(&User{}).Where("id = ?", id). return database.DB(ctx).Model(&User{}).Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"last_login_at": time.Now(), "last_login_at": time.Now(),
"updated_at": time.Now(), columnUpdatedAt: time.Now(),
}).Error }).Error
} }
@@ -280,3 +290,243 @@ func (s *userServiceImpl) UniqueUsername(ctx context.Context, base string) (stri
return "", errors.New("failed to generate unique username") return "", errors.New("failed to generate unique username")
} }
func (s *userServiceImpl) AdminListUsers(ctx context.Context, filter contracts.AdminListUsersFilter) (int64, []*contracts.UserDTO, error) {
query := database.DB(ctx).Table("w_users")
if filter.UserID != nil {
query = query.Where("id = ?", *filter.UserID)
}
if filter.Username != "" {
query = query.Where("username LIKE ? ESCAPE '\\'", pkgu.EscapeLike(filter.Username)+"%")
}
if filter.Email != "" {
query = query.Where("email LIKE ? ESCAPE '\\'", pkgu.EscapeLike(filter.Email)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = 20
}
var users []*contracts.UserDTO
offset := (filter.Page - 1) * filter.PageSize
if err := query.
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
Order("id ASC").
Offset(offset).
Limit(filter.PageSize).
Find(&users).Error; err != nil {
return 0, nil, err
}
return total, users, nil
}
func (s *userServiceImpl) AdminGetUser(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := database.DB(ctx).Table("w_users").
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
Where("id = ?", id).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (s *userServiceImpl) AdminCreateUser(ctx context.Context, req contracts.AdminCreateUserRequest) (*contracts.UserDTO, error) {
req.Username = strings.TrimSpace(req.Username)
req.Nickname = strings.TrimSpace(req.Nickname)
req.Password = strings.TrimSpace(req.Password)
req.Email = strings.TrimSpace(req.Email)
if req.Username == "" {
return nil, errors.New("用户名不能为空")
}
if req.Email == "" {
return nil, errors.New("邮箱不能为空")
}
const minPasswordLen = 8
if len(req.Password) < minPasswordLen {
return nil, errors.New("密码长度至少为 8 位")
}
var count int64
if err := database.DB(ctx).Table("w_users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, errors.New("用户名已被使用")
}
var emailCount int64
if err := database.DB(ctx).Table("w_users").Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
return nil, err
}
if emailCount > 0 {
return nil, errors.New("邮箱已被使用")
}
hash, err := pkgu.HashPassword(req.Password)
if err != nil {
return nil, err
}
if req.Nickname == "" {
req.Nickname = req.Username
}
now := time.Now()
newUser := contracts.UserDTO{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
CreatedAt: now,
UpdatedAt: now,
}
row := map[string]any{
"id": newUser.ID,
"username": newUser.Username,
"password": hash,
"nickname": newUser.Nickname,
"email": newUser.Email,
"is_active": newUser.IsActive,
"is_admin": newUser.IsAdmin,
"created_at": now,
columnUpdatedAt: now,
}
if err := database.DB(ctx).Table("w_users").Create(row).Error; err != nil {
return nil, err
}
if s.events != nil {
_ = s.events.Emit(ctx, contracts.EventTopicUserCreated, contracts.UserCreatedEvent{
User: &newUser,
Password: req.Password,
})
}
return &newUser, nil
}
func (s *userServiceImpl) AdminUpdateUser(ctx context.Context, currentUserID uint64, req contracts.AdminUpdateUserRequest) error {
req.Nickname = strings.TrimSpace(req.Nickname)
req.Email = strings.TrimSpace(req.Email)
req.Password = strings.TrimSpace(req.Password)
if req.Email == "" {
return errors.New("邮箱不能为空")
}
var targetUser contracts.UserDTO
if err := database.DB(ctx).Table("w_users").Where("id = ?", req.ID).First(&targetUser).Error; err != nil {
return err
}
if currentUserID == req.ID && !req.IsAdmin && targetUser.IsAdmin {
return errors.New("不能取消自己的管理员权限")
}
if targetUser.Email != req.Email {
var count int64
if err := database.DB(ctx).Table("w_users").Where("email = ? AND id != ?", req.Email, req.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("邮箱已被使用")
}
}
const minPasswordLen = 8
if req.Password != "" && len(req.Password) < minPasswordLen {
return errors.New("密码长度至少为 8 位")
}
if req.Nickname == "" {
req.Nickname = targetUser.Username
}
updates := map[string]any{
"nickname": req.Nickname,
"email": req.Email,
"is_admin": req.IsAdmin,
columnUpdatedAt: time.Now(),
}
if req.Password != "" {
hash, err := pkgu.HashPassword(req.Password)
if err != nil {
return err
}
updates["password"] = hash
}
err := database.DB(ctx).Table("w_users").Where("id = ?", req.ID).Updates(updates).Error
if err == nil && s.events != nil {
_ = s.events.Emit(ctx, contracts.EventTopicUserUpdated, &targetUser)
}
return err
}
func (s *userServiceImpl) AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error {
var flags struct {
ID uint64
IsAdmin bool
}
if err := database.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil {
return err
}
if !active && flags.IsAdmin {
return errors.New("管理员账号无法被禁用")
}
err := database.DB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error
if err == nil && s.events != nil {
_ = s.events.Emit(ctx, contracts.EventTopicUserStatusChanged, contracts.UserStatusChangedEvent{
UserID: id,
IsActive: active,
})
}
return err
}
func (s *userServiceImpl) AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error {
if currentUserID == targetID {
return errors.New("不能删除当前登录用户")
}
var flags struct {
ID uint64
IsAdmin bool
}
if err := database.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", targetID).First(&flags).Error; err != nil {
return err
}
if flags.IsAdmin {
return errors.New("管理员账号无法被删除")
}
err := database.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Table("w_access_tokens").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
return err
}
if err := tx.Table("w_external_accounts").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
return err
}
return tx.Table("w_users").Where("id = ?", targetID).Delete(map[string]any{}).Error
})
if err == nil && s.events != nil {
_ = s.events.Emit(ctx, contracts.EventTopicUserDeleted, contracts.UserDeletedEvent{
CurrentUserID: currentUserID,
TargetUserID: targetID,
})
}
return err
}
@@ -0,0 +1,171 @@
# Cordis Architecture Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor Wavelet backend to strictly conform to Cordis meta-framework principles: full revertible effects, space composability via per-plugin scoped context fork, 4-semantic typed event bus, elimination of package-level infra globals, and strict single-owner principle across domain plugins.
**Architecture:**
1. Microkernel core (`core/`): Implement `Waterfall`, `Parallel`, `Serial` event dispatch, per-plugin Scoped Context (`ctx.Fork()`), revertible extension points (`extpoints`), and `ctx.DB()` / `ctx.Cache()` contract helpers.
2. Contracts (`core/contracts/`): Expand `UserService` & `AuthService` with administration and token revocation interfaces; define typed domain events.
3. Domain Plugins (`plugins/domain/`): Implement user/auth contract additions; refactor `admin` plugin to completely remove cross-plugin internal package imports and direct SQL operations on other plugins' tables.
**Tech Stack:** Go 1.23+, GORM, Gin, Goose, Cordis Paradigm.
## Global Constraints
- No direct cross-package imports between plugins (`plugins/domain/A` must NEVER import `plugins/domain/B` or `plugins/drivers/*`).
- Single Owner Principle: Every database table is owned and operated exclusively by its owner plugin.
- Microkernel purity: `core/` and `core/contracts/` must never import `gin`, `gorm`, `asynq`.
- Tests must pass with `-race` enabled; temporary directories must use `t.TempDir()`.
- Quality gates: `make code-check`, `make format`, `make swagger`.
---
### Task 1: Core EventBus 4 Dispatch Semantics
**Files:**
- Modify: `backend/core/events.go`
- Test: `backend/core/events_test.go`
**Interfaces:**
- Produces:
- `(b *EventBus) Emit(ctx context.Context, topic string, payload any) error`
- `(b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error)`
- `(b *EventBus) Parallel(ctx context.Context, topic string, payload any) error`
- `(b *EventBus) Serial(ctx context.Context, topic string, payload any) error`
- [ ] **Step 1: Write tests for Waterfall, Parallel, and Serial dispatch semantics**
- [ ] **Step 2: Run tests to verify they fail**
- [ ] **Step 3: Implement Waterfall, Parallel, Serial methods on EventBus**
- [ ] **Step 4: Run tests to verify they pass**
- [ ] **Step 5: Commit**
---
### Task 2: Core Scoped Context, Revertible ExtPoints & Contract Helpers
**Files:**
- Modify: `backend/core/context.go`
- Modify: `backend/core/app.go`
- Modify: `backend/core/extpoints/router.go`
- Modify: `backend/core/extpoints/task.go`
- Modify: `backend/core/extpoints/schedule.go`
- Modify: `backend/core/extpoints/setting.go`
- Modify: `backend/core/extpoints/migration.go`
- Test: `backend/core/context_test.go`
- Test: `backend/core/app_test.go`
- Test: `backend/core/extpoints/extpoints_test.go`
**Interfaces:**
- Produces:
- `(c *Context) DB() contracts.DBService`
- `(c *Context) Cache() contracts.CacheService`
- `(r *RouterRegistry) Unregister(id uint64) bool`
- `RouterExtension.Handle(...) Disposer` / `RouteDefinition` with disposer tracking
- `App.ApplyPlugins()` forks scoped context per plugin: `p.Apply(a.ctx.Fork())`
- [ ] **Step 1: Write tests for Scoped Context Fork, LIFO Disposer, and Router Unregister**
- [ ] **Step 2: Run tests to verify failure**
- [ ] **Step 3: Implement Scoped Fork, Disposers, and Context DB/Cache helpers**
- [ ] **Step 4: Update App.ApplyPlugins to fork a context for each plugin**
- [ ] **Step 5: Run tests and verify all core tests pass**
- [ ] **Step 6: Commit**
---
### Task 3: Expand Service Contracts & Domain Events
**Files:**
- Modify: `backend/core/contracts/user.go`
- Modify: `backend/core/contracts/auth.go`
- Modify: `backend/core/contracts/events.go`
**Interfaces:**
- Produces:
- `AdminListUsersRequest`, `AdminCreateUserRequest`, `AdminUpdateUserRequest`
- `UserService` admin methods (`AdminListUsers`, `AdminGetUser`, `AdminCreateUser`, `AdminUpdateUser`, `AdminUpdateUserStatus`, `AdminDeleteUser`)
- `AuthService` token management methods (`RevokeToken`, `RevokeUserTokens`, `InvalidateCachedUser`, `InvalidateCachedToken`)
- Standard event definitions (`EventUserUpdated`, `EventUserDeleted`, `EventUserStatusChanged`, `EventTokenRevoked`)
- [ ] **Step 1: Declare extended contracts and DTO types in core/contracts/**
- [ ] **Step 2: Declare typed event constants and structs in core/contracts/events.go**
- [ ] **Step 3: Verify core and core/contracts compile cleanly**
- [ ] **Step 4: Commit**
---
### Task 4: Implement Expanded Contracts in User & Auth Domain Plugins
**Files:**
- Modify: `backend/plugins/domain/user/service.go`
- Modify: `backend/plugins/domain/user/plugin.go`
- Modify: `backend/plugins/domain/user/user_test.go`
- Modify: `backend/plugins/domain/auth/service.go`
- Modify: `backend/plugins/domain/auth/plugin.go`
- Modify: `backend/plugins/domain/auth/plugin_test.go`
**Interfaces:**
- Implements: `contracts.UserService` full methods in `user` plugin.
- Implements: `contracts.AuthService` full methods in `auth` plugin.
- Subscribes: `auth` plugin subscribes to `EventUserStatusChanged` / `EventUserDeleted` to invalidate cache and revoke tokens.
- [ ] **Step 1: Write unit tests for new UserService admin methods and AuthService revocation methods**
- [ ] **Step 2: Run tests to verify failure**
- [ ] **Step 3: Implement the methods in user and auth domain packages**
- [ ] **Step 4: Run user and auth plugin tests and verify they pass**
- [ ] **Step 5: Commit**
---
### Task 5: Refactor Admin Plugin (Eliminate Cross-Plugin Direct Imports & Table Ownership Violations)
**Files:**
- Modify: `backend/plugins/domain/admin/handlers_user.go`
- Modify: `backend/plugins/domain/admin/handlers_auth_source.go`
- Modify: `backend/plugins/domain/admin/handlers_config.go`
- Modify: `backend/plugins/domain/admin/handlers_logs.go`
- Modify: `backend/plugins/domain/admin/handlers_status.go`
- Modify: `backend/plugins/domain/admin/handlers_tasks.go`
- Modify: `backend/plugins/domain/admin/repository.go`
- Modify: `backend/plugins/domain/admin/system_config_cache.go`
- Modify: `backend/plugins/domain/admin/plugin.go`
- Modify: `backend/plugins/domain/admin/plugin_test.go`
**Interfaces:**
- Consumes: `contracts.UserService`, `contracts.AuthService`, `contracts.DBService`, `contracts.CacheService`, `ctx.DB()`, `ctx.Cache()`
- Zero imports of `plugins/domain/auth`, `plugins/domain/risk_control`, `plugins/domain/cap`, `plugins/drivers/*`, `plugins/infra/database`
- [ ] **Step 1: Write integration tests for Admin handlers using mocked/injected contracts**
- [ ] **Step 2: Refactor admin handlers to delegate user/auth operations to contracts**
- [ ] **Step 3: Remove all cross-plugin direct package imports and illegal SQL DML**
- [ ] **Step 4: Run admin plugin tests to verify passing**
- [ ] **Step 5: Commit**
---
### Task 6: Clean up Domain & Infra Plugins Database / Cache Injections
**Files:**
- Modify: `backend/plugins/domain/cap/...`
- Modify: `backend/plugins/domain/message_gateway/...`
- Modify: `backend/plugins/domain/risk_control/...`
- Modify: `backend/plugins/domain/upload/...`
- Modify: `backend/plugins/domain/system/...`
- [ ] **Step 1: Audit and replace direct `database.DB(ctx)` calls with `ctx.DB()` / injected `contracts.DBService`**
- [ ] **Step 2: Audit and replace direct `cache.Client()` calls with `ctx.Cache()` / injected `contracts.CacheService`**
- [ ] **Step 3: Run domain plugins test suite**
- [ ] **Step 4: Commit**
---
### Task 7: Full Verification & Quality Gates
**Files:**
- All backend files
- [ ] **Step 1: Run full test suite with race detector: `go test -v -race ./backend/...`**
- [ ] **Step 2: Run `make code-check`**
- [ ] **Step 3: Run `make format`**
- [ ] **Step 4: Run `make swagger`**
- [ ] **Step 5: Final commit**
@@ -0,0 +1,140 @@
# Cordis 架构对齐与系统重构设计规范 (Design Spec)
- **Date:** 2026-08-28
- **Topic:** Cordis Architecture Alignment & Full System Refactor
- **Status:** Approved
---
## 1. 目标与背景 (Goal & Background)
本项目遵循 **Cordis(时空可组合性元框架)** 的核心设计哲学:
- **时间可组合性(Time Composability)**:所有对运行时环境的修改(扩展点挂载、事件订阅、服务注册、状态配置)均具备显式可逆操作(Revertible Effects),卸载时按 LIFO(后进先出)严格回收。
- **空间可组合性(Space Composability)**:插件运行在独立的 Scoped Context 分支中,通过面向契约(`contracts`)与事件(`EventBus`)解耦,消除人工硬编码启动顺序与跨插件内部实现耦合。
- **表单一所有者原则(Single Owner Principle)**:每张数据表由且仅由一个所有者插件维护,严禁旁路 DML 读写。
本规范定义微内核层、服务契约层、基础设施层与业务域插件的全量重构设计。
---
## 2. 系统架构与分层设计 (Architecture & Layers)
```
┌────────────────────────────────────────────────────────────────────────┐
│ Micro-Kernel (core/) │
│ Context Bus (Scoped Fork & LIFO Disposer) | Container | EventBus │
│ Extpoints (Router, Tasks, Schedules, Settings, Migrations) │
├────────────────────────────────────────────────────────────────────────┤
│ Service Contracts (contracts/) │
│ DBService, CacheService, StorageService, UserService, AuthService... │
├─────────────────────────┬──────────────────────────────────────────────┤
│ Runtime Drivers │ Platform Infra Plugins │
│ (plugins/drivers/) │ (plugins/infra/) │
│ - driver_http (Gin) │ - database (contracts.DBService) │
│ - driver_asynq_worker │ - cache (contracts.CacheService) │
│ - driver_asynq_cron │ - storage, logger │
├─────────────────────────┴──────────────────────────────────────────────┤
│ Self-Contained Domain Plugins (plugins/domain/) │
│ - user, auth, admin, cap, message_gateway, risk_control, system, upload│
└────────────────────────────────────────────────────────────────────────┘
```
---
## 3. 详细设计与核心组件规范 (Detailed Specifications)
### 3.1 微内核层 (`backend/core/`)
#### 1. 事件总线四大分发语义 (`core/events.go`)
- `Emit(ctx context.Context, topic string, payload any) error`:通知型广播,不短路,收集所有 Handler 产生的 error(`errors.Join`)。
- `Waterfall(ctx context.Context, topic string, initialPayload any) (any, error)`:链式流水线改写,上一个 handler 的返回值作为下一个 handler 的输入;一旦 handler 返回 error 立即短路中断并返回。
- `Parallel(ctx context.Context, topic string, payload any) error`:并发扇出执行所有 handler,通过 goroutine + WaitGroup 并发执行,收集所有 error。
- `Serial(ctx context.Context, topic string, payload any) error`:严格按序流水线执行所有 handler,遇到第一个 error 立即短路中断。
#### 2. 插件作用域上下文 (`core/context.go` & `core/app.go`)
- `App.ApplyPlugins()` 为每个插件生成专属的 `pluginCtx := app.ctx.Fork()`,并在 `Apply(pluginCtx)` 中挂载。
- Scoped Context 拥有独立的 `disposers`、`values` 与子 container,当插件被卸载或 context 被 dispose 时,仅回收该插件范围内的资源。
- 在 `Context` 上扩展 `ctx.DB()` 与 `ctx.Cache()` 辅助方法,内部通过 `core.Inject[contracts.DBService](c)` 与 `core.Inject[contracts.CacheService](c)` 解析。
#### 3. 扩展点可逆化与注销 (`core/extpoints/`)
- `RouterExtension`:注册路由时返回 `Disposer`;`RouterRegistry` 内部维护带 ID 的路由列表,支持动态移除路由。
- `TaskExtension` / `ScheduleExtension` / `SettingExtension` / `MigrationExtension`:提供与 Scoped Context 关联的注销机制与 Disposer 回收。
---
### 3.2 服务契约层 (`backend/core/contracts/`)
#### 1. `contracts.UserService` 扩展
收拢所有用户管理操作:
```go
type UserService interface {
GetByID(ctx context.Context, id uint64) (*UserDTO, error)
GetByUsername(ctx context.Context, username string) (*UserDTO, error)
GetByEmail(ctx context.Context, email string) (*UserDTO, error)
Create(ctx context.Context, user *UserDTO, password string) (*UserDTO, error)
Update(ctx context.Context, user *UserDTO) error
Delete(ctx context.Context, id uint64) error
// Admin 扩展方法
AdminListUsers(ctx context.Context, req AdminListUsersRequest) (int64, []*UserDTO, error)
AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error)
AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error)
AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error
AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error
AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error
}
```
#### 2. `contracts.AuthService` 扩展
收拢令牌管理与认证源查询:
```go
type AuthService interface {
Authenticate(ctx context.Context, username, password string) (*UserDTO, error)
GenerateToken(ctx context.Context, userID uint64, opts ...TokenOption) (string, error)
ValidateToken(ctx context.Context, token string) (*TokenClaims, error)
RevokeToken(ctx context.Context, tokenHash string) error
RevokeUserTokens(ctx context.Context, userID uint64) error
InvalidateCachedUser(ctx context.Context, userID uint64)
InvalidateCachedToken(ctx context.Context, tokenHash string)
}
```
#### 3. 强类型领域事件 (`core/contracts/events.go`)
- `EventUserUpdated`: `{ UserID, UpdatedFields }`
- `EventUserDeleted`: `{ CurrentUserID, TargetUserID }`
- `EventUserStatusChanged`: `{ UserID, IsActive }`
- `EventTokenRevoked`: `{ UserID, TokenHash }`
---
### 3.3 基础设施去全局化 (`backend/plugins/infra/`)
1. **`plugins/infra/database`**:
- 彻底去全局化:弃用全局静态变量直读,统一提供 `contracts.DBService` 实例并在 `Apply` 中 `core.Provide[contracts.DBService](ctx, svc)`。
2. **`plugins/infra/cache`**:
- 弃用全局 `cache.Client()` 包级直连,统一通过 `contracts.CacheService` 接口与 `ctx.Cache()` 操作。
---
### 3.4 业务域插件边界治理 (`backend/plugins/domain/`)
1. **`domain/admin` 治理**:
- 移除全部跨插件直接导入(`domain/auth`、`domain/risk_control`、`domain/cap`、`drivers/driver_asynq_*`、`infra/database`)。
- 用户管理委派给 `contracts.UserService`。
- 认证与令牌操作委派给 `contracts.AuthService`。
- 配置管理通过 `ctx.Settings()` / `contracts.SettingService`。
2. **表单一所有者原则(Single Owner Principle)**:
- `w_users` 表有且仅由 `domain/user` 插件读写。
- `w_access_tokens` / `w_auth_sources` / `w_external_accounts` 表有且仅由 `domain/auth` 插件读写。
- `w_system_configs` 表由 `domain/admin` 维护。
---
## 4. 实施与验证流程 (Verification & Quality Gates)
1. **内核与契约单测**:
- `core/events_test.go`:覆盖 `Emit`、`Waterfall`、`Parallel`、`Serial`。
- `core/context_test.go`:覆盖 Scoped Fork、Disposer LIFO、扩展点注销。
2. **全局代码检查与格式化**:
- `make code-check`
- `make format`
- `go test -v -race ./backend/...`