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
@@ -120,23 +120,8 @@ func (p *Plugin) Apply(ctx *core.Context) error {
}
p.mu.Unlock()
// Bind DBService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
})
}
// Bind TaskService
if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil {
setTaskService(taskSvc)
} else {
core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) {
setTaskService(taskSvc)
})
}
core.Bind[contracts.DBService](ctx, setDBService)
core.Bind[contracts.TaskService](ctx, setTaskService)
ctx.OnDispose(func() error {
setDBService(nil)
@@ -34,10 +34,8 @@ func SetRedisClient(c redis.UniversalClient) {
}
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)
}
if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil {
return s.DB(ctx)
}
dbMu.RLock()
s := dbSvc
@@ -8,6 +8,7 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"encoding/json"
"errors"
"fmt"
"sync"
@@ -144,14 +145,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
ResetAsynqClient()
p.mu.Unlock()
// 0. Bind DBService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
})
}
core.Bind[contracts.DBService](ctx, setDBService)
ctx.OnDispose(func() error {
setDBService(nil)
return nil
@@ -191,12 +185,15 @@ func (p *Plugin) Start(_ context.Context) error {
mux := asynq.NewServeMux()
if p.coreCtx != nil && p.coreCtx.Tasks() != nil {
appCtx := p.coreCtx.Root()
for _, td := range p.coreCtx.Tasks().Tasks() {
handler, err := toAsynqHandler(td.Pattern, td.Handler)
if err != nil {
return fmt.Errorf("driver_asynq_worker: invalid handler for task pattern %q: %w", td.Pattern, err)
}
mux.Handle(td.Pattern, handler)
mux.Handle(td.Pattern, asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error {
return handler.ProcessTask(core.WithAppContext(c, appCtx), t)
}))
}
}
@@ -285,6 +282,14 @@ func toAsynqHandler(pattern string, h any) (asynq.Handler, error) {
RegisterHandler(pattern, th)
return asynq.HandlerFunc(ProcessTask), nil
}
if th, ok := h.(contracts.TaskHandler); ok {
RegisterHandler(pattern, contractTaskAdapter{inner: th})
return asynq.HandlerFunc(ProcessTask), nil
}
if fn, ok := h.(func(context.Context, []byte) (*contracts.TaskResultDTO, error)); ok {
RegisterHandler(pattern, contractFuncAdapter{fn: fn})
return asynq.HandlerFunc(ProcessTask), nil
}
inner, err := toRawAsynqHandler(h)
if err != nil {
@@ -294,6 +299,58 @@ func toAsynqHandler(pattern string, h any) (asynq.Handler, error) {
return asynq.HandlerFunc(ProcessTask), nil
}
type contractTaskAdapter struct {
inner contracts.TaskHandler
}
func (a contractTaskAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) {
res, err := a.inner.Execute(ctx, payload)
if err != nil {
return nil, err
}
return dtoToTaskResult(res), nil
}
func (a contractTaskAdapter) ValidatePayload(payload []byte) ([]byte, error) {
if v, ok := a.inner.(PayloadValidator); ok {
return v.ValidatePayload(payload)
}
return payload, nil
}
type contractFuncAdapter struct {
fn func(context.Context, []byte) (*contracts.TaskResultDTO, error)
}
func (a contractFuncAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) {
res, err := a.fn(ctx, payload)
if err != nil {
return nil, err
}
return dtoToTaskResult(res), nil
}
func dtoToTaskResult(res *contracts.TaskResultDTO) *TaskResult {
if res == nil {
return &TaskResult{Message: "ok"}
}
out := &TaskResult{Message: res.Message}
if res.Detail == nil {
return out
}
if s, ok := res.Detail.(string); ok {
out.Detail = s
return out
}
b, err := json.Marshal(res.Detail)
if err != nil {
out.Detail = fmt.Sprint(res.Detail)
return out
}
out.Detail = string(b)
return out
}
func toRawAsynqHandler(h any) (asynq.Handler, error) {
switch fn := h.(type) {
case asynq.HandlerFunc:
+39 -40
View File
@@ -121,27 +121,13 @@ func (p *Plugin) Apply(ctx *core.Context) error {
}
p.mu.Unlock()
// Bind DBService from Context
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
})
}
core.Bind[contracts.DBService](ctx, setDBService)
ctx.OnDispose(func() error {
setDBService(nil)
return nil
})
// Bind CacheService from Context
if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil {
setCacheService(cache)
} else {
core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) {
setCacheService(cache)
})
}
core.Bind[contracts.CacheService](ctx, setCacheService)
ctx.OnDispose(func() error {
setCacheService(nil)
return nil
@@ -184,30 +170,8 @@ func (p *Plugin) Start(ctx context.Context) error {
}
}
// Mount routes collected in Context RouterExtension
if p.coreCtx != nil && p.coreCtx.Router() != nil {
SetWhitelist(p.coreCtx.Router().Whitelist())
for _, rd := range p.coreCtx.Router().Routes() {
allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers))
for _, m := range rd.Middlewares {
gh, err := toGinHandler(m)
if err != nil {
return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err)
}
allHandlers = append(allHandlers, gh)
}
for _, h := range rd.Handlers {
gh, err := toGinHandler(h)
if err != nil {
return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err)
}
allHandlers = append(allHandlers, gh)
}
p.engine.Handle(rd.Method, rd.Path, allHandlers...)
}
if err := p.mountContextRoutes(ctx); err != nil {
return err
}
// Mount Swagger in non-production environments
@@ -282,6 +246,41 @@ func (p *Plugin) Stop(ctx context.Context) error {
return err
}
func (p *Plugin) mountContextRoutes(ctx context.Context) error {
if p.coreCtx == nil || p.coreCtx.Router() == nil || p.engine == nil {
return nil
}
p.engine.Use(appContextMiddleware(ctx, p.coreCtx.Root()))
SetWhitelist(p.coreCtx.Router().Whitelist())
for _, rd := range p.coreCtx.Router().Routes() {
allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers))
for _, m := range rd.Middlewares {
gh, err := toGinHandler(m)
if err != nil {
return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err)
}
allHandlers = append(allHandlers, gh)
}
for _, h := range rd.Handlers {
gh, err := toGinHandler(h)
if err != nil {
return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err)
}
allHandlers = append(allHandlers, gh)
}
p.engine.Handle(rd.Method, rd.Path, allHandlers...)
}
return nil
}
//nolint:contextcheck // middleware must wrap the gin request context, not Start's ctx
func appContextMiddleware(_ context.Context, appCtx *core.Context) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), appCtx))
c.Next()
}
}
// Addr returns the current listening address (or configured address if not yet started).
func (p *Plugin) Addr() string {
p.mu.RLock()
@@ -83,7 +83,11 @@ func (p *Plugin) Start(ctx context.Context) error {
p.scheduler = newInprocScheduler(p.coreCtx.Schedules(), p.coreCtx.Tasks(), taskSvc)
}
return p.scheduler.Start(ctx)
runCtx := ctx
if p.coreCtx != nil {
runCtx = core.WithAppContext(ctx, p.coreCtx.Root())
}
return p.scheduler.Start(runCtx)
}
// Stop terminates the in-process cron scheduler.
@@ -24,10 +24,8 @@ func setDBService(s contracts.DBService) {
}
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)
}
if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil {
return s.DB(ctx)
}
dbMu.RLock()
s := dbSvc
@@ -4,11 +4,14 @@
package driver_inproc_worker
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"context"
"encoding/json"
"errors"
"fmt"
"sync"
@@ -40,6 +43,7 @@ type InprocQueue struct {
// baseCtx is the app-lifetime context captured at Start; task handlers
// derive their timeouts from it so shutdown cancellation propagates.
baseCtx context.Context
appCtx *core.Context
}
// NewInprocQueue creates a new InprocQueue with a given concurrency and queue capacity.
@@ -182,10 +186,14 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) {
taskCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if q.appCtx != nil {
taskCtx = core.WithAppContext(taskCtx, q.appCtx)
ctx = core.WithAppContext(ctx, q.appCtx)
}
q.markRunning(ctx, msg)
start := time.Now()
err := invokeHandler(taskCtx, td.Handler, msg.Payload)
result, err := invokeHandler(taskCtx, td.Handler, msg.Payload)
duration := time.Since(start)
if err != nil {
@@ -210,25 +218,29 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) {
}
return
}
q.succeedExecution(ctx, msg, duration)
q.succeedExecution(ctx, msg, duration, result)
}
func invokeHandler(ctx context.Context, handler any, payload []byte) error {
func invokeHandler(ctx context.Context, handler any, payload []byte) (*contracts.TaskResultDTO, error) {
if handler == nil {
return errors.New("nil task handler")
return nil, errors.New("nil task handler")
}
switch fn := handler.(type) {
case func(context.Context, []byte) error:
case contracts.TaskHandler:
return fn.Execute(ctx, payload)
case func(context.Context, []byte) (*contracts.TaskResultDTO, error):
return fn(ctx, payload)
case func(context.Context, []byte) error:
return nil, fn(ctx, payload)
case func(context.Context) error:
return fn(ctx)
return nil, fn(ctx)
case func([]byte) error:
return fn(payload)
return nil, fn(payload)
case func() error:
return fn()
return nil, fn()
default:
return fmt.Errorf("unsupported handler type: %T", handler)
return nil, fmt.Errorf("unsupported handler type: %T", handler)
}
}
@@ -280,16 +292,27 @@ func (q *InprocQueue) markRunning(ctx context.Context, msg TaskMessage) {
q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 开始执行异步任务 [类型: %s]", msg.TaskType))
}
func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration) {
func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration, result *contracts.TaskResultDTO) {
db := getDB(ctx)
if db == nil {
return
}
now := time.Now()
resultText := "ok"
if result != nil {
resultText = result.Message
if result.Detail != nil {
if s, ok := result.Detail.(string); ok && s != "" {
resultText = result.Message + "\n" + s
} else if b, err := json.Marshal(result.Detail); err == nil && len(b) > 0 && string(b) != "null" {
resultText = result.Message + "\n" + string(b)
}
}
}
updates := map[string]any{
taskExecutionColStatus: taskExecutionStatusSucceeded,
"error_message": "",
"result": "ok",
"result": resultText,
"finished_at": now,
"duration": duration.Milliseconds(),
}
@@ -119,13 +119,7 @@ func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
func (p *Plugin) Apply(ctx *core.Context) error {
p.coreCtx = ctx
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
})
}
core.Bind[contracts.DBService](ctx, setDBService)
taskSvc := newInprocTaskService(ctx.Tasks())
core.Provide[contracts.TaskService](ctx, taskSvc)
@@ -151,6 +145,9 @@ func (p *Plugin) Start(ctx context.Context) error {
if p.queue == nil {
p.queue = NewInprocQueue(p.concurrency, p.queueCapacity, p.coreCtx.Tasks())
}
if p.coreCtx != nil {
p.queue.appCtx = p.coreCtx.Root()
}
globalMu.Lock()
globalQueue = p.queue
@@ -80,9 +80,9 @@ func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) {
require.NoError(t, p.Apply(ctx))
var executedCount atomic.Int32
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error {
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
executedCount.Add(1)
return nil
return &contracts.TaskResultDTO{Message: "cleaned 3 files"}, nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
@@ -111,6 +111,6 @@ func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) {
if listErr != nil || total == 0 || len(execs) == 0 {
return false
}
return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理"
return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理" && execs[0].Result == "cleaned 3 files"
}, 2*time.Second, 20*time.Millisecond, "inproc worker should persist a succeeded execution record")
}
+3 -3
View File
@@ -239,9 +239,9 @@ func TestAsynqWorkerDispatchTracksExecution(t *testing.T) {
core.Provide[contracts.DBService](ctx, &testDBService{db: testDB})
var processed atomic.Bool
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error {
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
processed.Store(true)
return nil
return &contracts.TaskResultDTO{Message: "cleaned 3 files"}, nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
@@ -277,7 +277,7 @@ func TestAsynqWorkerDispatchTracksExecution(t *testing.T) {
if listErr != nil || len(execs) == 0 {
return false
}
return execs[0].TaskID == taskID && execs[0].Status == "succeeded"
return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].Result == "cleaned 3 files"
}, 5*time.Second, 50*time.Millisecond, "task execution should become succeeded after worker runs")
}