feat(core): bind request services and implement registered tasks

Wire plugin services through Bind/InjectFrom and AppContext so HTTP and
workers resolve dependencies after Apply. Register TaskHandler objects
with persisted results, and implement send_email_code, mail:send,
cleanup_inactive_users, and dispatch_bot_msg.
This commit is contained in:
ryan
2026-09-02 16:59:00 +08:00
parent 30bbe965bf
commit 4f50f6a8f9
49 changed files with 1406 additions and 499 deletions
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import "context"
type appContextKey struct{}
// WithAppContext attaches the micro-kernel Context to a standard context.Context
// so request and worker handlers can Inject services without package-level setters.
func WithAppContext(ctx context.Context, app *Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if app == nil {
return ctx
}
return context.WithValue(ctx, appContextKey{}, app.Root())
}
// AppContext extracts the micro-kernel Context from ctx, if present.
func AppContext(ctx context.Context) *Context {
if ctx == nil {
return nil
}
if c, ok := ctx.(*Context); ok {
return c
}
app, _ := ctx.Value(appContextKey{}).(*Context)
return app
}
// InjectFrom resolves T from ctx when it carries a micro-kernel Context
// (*Context itself, or a value attached by WithAppContext).
func InjectFrom[T any](ctx context.Context) (T, error) {
var zero T
app := AppContext(ctx)
if app == nil {
return zero, ErrNilContext
}
return Inject[T](app)
}
+11 -1
View File
@@ -201,13 +201,17 @@ func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
// When registers a reactive hook that is called immediately if T is already provided,
// or called as soon as T is provided in the future.
//
// Listeners are stored on the root container so they observe core.Provide, which
// always writes to the root. Registering on a Fiber child container would miss
// services provided by plugins that load later.
func When[T any](ctx *Context, fn func(s T)) {
if ctx == nil {
panic("core: nil context provided to When")
}
targetType := reflect.TypeFor[T]()
c := ctx.Container()
c := ctx.Root().Container()
// If already ready, execute immediately
if s, err := Inject[T](ctx); err == nil {
@@ -223,3 +227,9 @@ func When[T any](ctx *Context, fn func(s T)) {
}
})
}
// Bind is When with a name that matches plugin wiring: fill a dependency as
// soon as the root container provides it.
func Bind[T any](ctx *Context, fn func(s T)) {
When(ctx, fn)
}
+40
View File
@@ -313,6 +313,46 @@ func TestContextReactiveWhen(t *testing.T) {
assert.True(t, immediateCalled)
}
func TestWhenObservesProvideFromForkedFiberContext(t *testing.T) {
root := core.NewContext(context.Background())
adminFiber := root.Fork()
lateFiber := root.Fork()
var got atomic.Bool
core.When[SampleService](adminFiber, func(s SampleService) {
if s != nil {
got.Store(true)
}
})
assert.False(t, got.Load())
core.Provide[SampleService](lateFiber, &sampleServiceImpl{})
assert.True(t, got.Load(), "When on a Fiber child must observe Provide on the root")
}
func TestBindIsWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
var called atomic.Bool
core.Bind[SampleService](ctx, func(s SampleService) {
called.Store(true)
})
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, called.Load())
}
func TestInjectFromAppContext(t *testing.T) {
app := core.NewContext(context.Background())
core.Provide[SampleService](app, &sampleServiceImpl{prefix: "Hi:"})
req := core.WithAppContext(context.Background(), app)
svc, err := core.InjectFrom[SampleService](req)
require.NoError(t, err)
assert.Equal(t, "Hi: Ada", svc.Greet("Ada"))
_, err = core.InjectFrom[SampleService](context.Background())
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextDisposerLifecycle(t *testing.T) {
parent := core.NewContext(context.Background())
child := parent.Fork()
+6
View File
@@ -43,6 +43,12 @@ type TaskResultDTO struct {
Detail any `json:"detail,omitempty"`
}
// TaskHandler is the preferred background task handler. Drivers invoke Execute
// and persist Message/Detail onto the execution record.
type TaskHandler interface {
Execute(ctx context.Context, payload []byte) (*TaskResultDTO, error)
}
// TaskExecutionDTO represents a single task execution record.
type TaskExecutionDTO struct {
ID uint64 `json:"id,string"`
+16
View File
@@ -229,6 +229,22 @@ func TestTaskExtension(t *testing.T) {
assert.False(t, ok)
}
func TestTaskRegisterRejectsNilHandler(t *testing.T) {
tr := extpoints.NewTaskRegistry()
assert.Panics(t, func() {
tr.Register("broken:task", nil)
})
}
func TestTaskRegisterRejectsDuplicateType(t *testing.T) {
tr := extpoints.NewTaskRegistry()
handler := func(ctx context.Context, payload []byte) error { return nil }
tr.Register("system:cleanup", handler, extpoints.WithTaskType("system_cleanup"))
assert.Panics(t, func() {
tr.Register("admin:system_cleanup", handler, extpoints.WithTaskType("system_cleanup"))
})
}
func TestScheduleExtension(t *testing.T) {
sr := extpoints.NewScheduleRegistry()
require.NotNil(t, sr)
+59 -13
View File
@@ -5,6 +5,8 @@ package extpoints
import (
"Wavelet/core/contracts"
"fmt"
"reflect"
"sync"
"time"
)
@@ -230,10 +232,15 @@ func NewTaskRegistry() *TaskRegistry {
}
// Register registers a task pattern and its handler with optional configuration.
// A nil handler panics. A non-empty Type that is already used by another pattern panics.
func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) {
t.mu.Lock()
defer t.mu.Unlock()
if isNilTaskHandler(handler) {
panic(fmt.Sprintf("extpoints: nil handler for task pattern %q", pattern))
}
td := TaskDefinition{
Pattern: pattern,
Handler: handler,
@@ -245,8 +252,23 @@ func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption)
opt(&td)
}
}
if td.Type == "" {
td.Type = pattern
}
if _, exists := t.lookup[pattern]; exists {
for _, item := range t.tasks {
if item.Pattern == pattern {
continue
}
if item.Type == td.Type {
panic(fmt.Sprintf("extpoints: duplicate task type %q (patterns %q and %q)", td.Type, item.Pattern, pattern))
}
}
if existing, exists := t.lookup[pattern]; exists {
if existing.Type != "" && existing.Type != pattern {
delete(t.lookup, existing.Type)
}
for i, item := range t.tasks {
if item.Pattern == pattern {
t.tasks[i] = td
@@ -258,13 +280,44 @@ func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption)
}
t.lookup[pattern] = td
if td.Type != pattern {
t.lookup[td.Type] = td
}
}
func isNilTaskHandler(handler any) bool {
if handler == nil {
return true
}
v := reflect.ValueOf(handler)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Unregister removes a registered task definition by its pattern.
func (t *TaskRegistry) Unregister(pattern string) bool {
return unregisterEntry(&t.mu, t.lookup, &t.tasks, pattern, func(item TaskDefinition) bool {
return item.Pattern == pattern
})
t.mu.Lock()
defer t.mu.Unlock()
td, ok := t.lookup[pattern]
if !ok {
return false
}
delete(t.lookup, td.Pattern)
if td.Type != "" && td.Type != td.Pattern {
delete(t.lookup, td.Type)
}
filtered := t.tasks[:0]
for _, item := range t.tasks {
if item.Pattern != td.Pattern {
filtered = append(filtered, item)
}
}
t.tasks = filtered
return true
}
// Tasks returns a copy of all registered TaskDefinitions.
@@ -280,13 +333,6 @@ func (t *TaskRegistry) Tasks() []TaskDefinition {
func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
if td, ok := t.lookup[pattern]; ok {
return td, true
}
for _, td := range t.tasks {
if td.Type == pattern {
return td, true
}
}
return TaskDefinition{}, false
td, ok := t.lookup[pattern]
return td, ok
}