Version: 0.9.70.dev.260504

后端:
1. 阶段 5 schedule 首刀服务化落地,新增 `cmd/schedule`、`services/schedule/{dao,rpc,sv,core}`、`gateway/client/schedule`、`shared/contracts/schedule` 和 schedule port
2. gateway `/api/v1/schedule/*` 切到 schedule zrpc client,HTTP 门面只保留鉴权、参数绑定、超时和轻量转发
3. active-scheduler 的 schedule facts、feedback 和 confirm apply 改为调用 schedule RPC adapter,减少对 `schedule_events`、`schedules`、`task_classes`、`task_items` 的跨域 DB 依赖
4. 单体聊天主动调度 rerun 的 schedule 读写链路切到 schedule RPC,迁移期仅保留 task facts 直读 Gorm
5. 为 schedule zrpc 补充 `Ping` 启动健康检查,并在 gateway client 与 active-scheduler adapter 初始化时校验服务可用
6. `cmd/schedule` 独立初始化 DB / Redis,只 AutoMigrate schedule 自有表,并显式检查迁移期 task / task-class 依赖表
7. 更新 active-scheduler 依赖表检查和 preview confirm apply 抽象,保留旧 Gorm 实现作为迁移期回退路径
8. 补充 `schedule.rpc` 示例配置和 schedule HTTP RPC 超时配置

文档:
1. 更新微服务迁移计划,将阶段 5 schedule 首刀进展、当前切流点、旧实现保留范围和 active-scheduler DB 依赖收缩情况写入基线
This commit is contained in:
Losita
2026-05-04 22:33:38 +08:00
parent 4d9a5c4d30
commit 29b8cf0ada
27 changed files with 4456 additions and 51 deletions

View File

@@ -0,0 +1,327 @@
package adapters
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
activeapplyadapter "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/applyadapter"
activeports "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/ports"
schedulepb "github.com/LoveLosita/smartflow/backend/services/schedule/rpc/pb"
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
"github.com/zeromicro/go-zero/zrpc"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
defaultScheduleRPCEndpoint = "127.0.0.1:9084"
defaultScheduleRPCTimeout = 6 * time.Second
scheduleApplyErrorDomain = "smartflow.schedule.apply"
)
type ScheduleRPCConfig struct {
Endpoints []string
Target string
Timeout time.Duration
}
// ScheduleRPCAdapter 是 active-scheduler 访问 schedule 服务的 RPC 适配器。
//
// 职责边界:
// 1. 只把 active-scheduler 内部端口 DTO 与 shared/contracts/schedule 互转;
// 2. 不读取数据库、不实现 schedule 写入状态机;
// 3. 让 active-scheduler 不再直接访问 schedule_events / schedules。
type ScheduleRPCAdapter struct {
rpc schedulepb.ScheduleClient
}
func NewScheduleRPCAdapter(cfg ScheduleRPCConfig) (*ScheduleRPCAdapter, error) {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultScheduleRPCTimeout
}
endpoints := normalizeScheduleRPCEndpoints(cfg.Endpoints)
target := strings.TrimSpace(cfg.Target)
if len(endpoints) == 0 && target == "" {
endpoints = []string{defaultScheduleRPCEndpoint}
}
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
Endpoints: endpoints,
Target: target,
NonBlock: true,
Timeout: int64(timeout / time.Millisecond),
})
if err != nil {
return nil, err
}
adapter := &ScheduleRPCAdapter{rpc: schedulepb.NewScheduleClient(zclient.Conn())}
if err := adapter.ping(timeout); err != nil {
return nil, err
}
return adapter, nil
}
func ReadersWithScheduleRPC(taskReader activeports.TaskReader, scheduleReader *ScheduleRPCAdapter) activeports.Readers {
return activeports.Readers{
TaskReader: taskReader,
ScheduleReader: scheduleReader,
FeedbackReader: scheduleReader,
}
}
func (a *ScheduleRPCAdapter) GetScheduleFactsByWindow(ctx context.Context, req activeports.ScheduleWindowRequest) (activeports.ScheduleWindowFacts, error) {
if err := a.ensureReady(); err != nil {
return activeports.ScheduleWindowFacts{}, err
}
payload, err := json.Marshal(toScheduleWindowContract(req))
if err != nil {
return activeports.ScheduleWindowFacts{}, err
}
resp, err := a.rpc.GetScheduleFactsByWindow(ctx, &schedulepb.JSONRequest{PayloadJson: payload})
if err != nil {
return activeports.ScheduleWindowFacts{}, scheduleRPCError(err)
}
var facts schedulecontracts.ScheduleWindowFacts
if err := json.Unmarshal(jsonBytes(resp), &facts); err != nil {
return activeports.ScheduleWindowFacts{}, err
}
return scheduleFactsToActive(facts), nil
}
func (a *ScheduleRPCAdapter) GetFeedbackSignal(ctx context.Context, req activeports.FeedbackRequest) (activeports.FeedbackFact, bool, error) {
if err := a.ensureReady(); err != nil {
return activeports.FeedbackFact{}, false, err
}
payload, err := json.Marshal(schedulecontracts.FeedbackRequest{
UserID: req.UserID,
FeedbackID: req.FeedbackID,
IdempotencyKey: req.IdempotencyKey,
TargetType: req.TargetType,
TargetID: req.TargetID,
})
if err != nil {
return activeports.FeedbackFact{}, false, err
}
resp, err := a.rpc.GetFeedbackSignal(ctx, &schedulepb.JSONRequest{PayloadJson: payload})
if err != nil {
return activeports.FeedbackFact{}, false, scheduleRPCError(err)
}
var contractResp schedulecontracts.FeedbackResponse
if err := json.Unmarshal(jsonBytes(resp), &contractResp); err != nil {
return activeports.FeedbackFact{}, false, err
}
return feedbackFactToActive(contractResp.Feedback), contractResp.Found, nil
}
func (a *ScheduleRPCAdapter) ApplyActiveScheduleChanges(ctx context.Context, req activeapplyadapter.ApplyActiveScheduleRequest) (activeapplyadapter.ApplyActiveScheduleResult, error) {
if err := a.ensureReady(); err != nil {
return activeapplyadapter.ApplyActiveScheduleResult{}, err
}
payload, err := json.Marshal(toScheduleApplyContract(req))
if err != nil {
return activeapplyadapter.ApplyActiveScheduleResult{}, err
}
resp, err := a.rpc.ApplyActiveScheduleChanges(ctx, &schedulepb.JSONRequest{PayloadJson: payload})
if err != nil {
return activeapplyadapter.ApplyActiveScheduleResult{}, scheduleRPCError(err)
}
var result schedulecontracts.ApplyActiveScheduleResult
if err := json.Unmarshal(jsonBytes(resp), &result); err != nil {
return activeapplyadapter.ApplyActiveScheduleResult{}, err
}
return activeapplyadapter.ApplyActiveScheduleResult{
ApplyID: result.ApplyID,
AppliedEventIDs: result.AppliedEventIDs,
AppliedScheduleIDs: result.AppliedScheduleIDs,
}, nil
}
func (a *ScheduleRPCAdapter) ensureReady() error {
if a == nil || a.rpc == nil {
return errors.New("schedule rpc adapter 未初始化")
}
return nil
}
func (a *ScheduleRPCAdapter) ping(timeout time.Duration) error {
if err := a.ensureReady(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err := a.rpc.Ping(ctx, &schedulepb.StatusResponse{})
return scheduleRPCError(err)
}
func scheduleRPCError(err error) error {
if err == nil {
return nil
}
st, ok := status.FromError(err)
if !ok {
return err
}
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok || info.Domain != scheduleApplyErrorDomain {
continue
}
message := strings.TrimSpace(st.Message())
if message == "" && info.Metadata != nil {
message = strings.TrimSpace(info.Metadata["info"])
}
return &activeapplyadapter.ApplyError{
Code: strings.TrimSpace(info.Reason),
Message: message,
Cause: err,
}
}
if st.Code() == codes.Internal || st.Code() == codes.Unavailable || st.Code() == codes.DeadlineExceeded {
return fmt.Errorf("调用 schedule zrpc 服务失败: %w", err)
}
return err
}
func toScheduleWindowContract(req activeports.ScheduleWindowRequest) schedulecontracts.ScheduleWindowRequest {
return schedulecontracts.ScheduleWindowRequest{
UserID: req.UserID,
TargetType: req.TargetType,
TargetID: req.TargetID,
WindowStart: req.WindowStart,
WindowEnd: req.WindowEnd,
Now: req.Now,
}
}
func scheduleFactsToActive(facts schedulecontracts.ScheduleWindowFacts) activeports.ScheduleWindowFacts {
events := make([]activeports.ScheduleEventFact, 0, len(facts.Events))
for _, event := range facts.Events {
events = append(events, scheduleEventFactToActive(event))
}
return activeports.ScheduleWindowFacts{
Events: events,
OccupiedSlots: scheduleSlotsToActive(facts.OccupiedSlots),
FreeSlots: scheduleSlotsToActive(facts.FreeSlots),
NextDynamicTask: scheduleEventFactPtrToActive(facts.NextDynamicTask),
TargetAlreadyScheduled: facts.TargetAlreadyScheduled,
}
}
func scheduleEventFactToActive(event schedulecontracts.ScheduleEventFact) activeports.ScheduleEventFact {
return activeports.ScheduleEventFact{
ID: event.ID,
UserID: event.UserID,
Title: event.Title,
SourceType: event.SourceType,
RelID: event.RelID,
IsDynamicTask: event.IsDynamicTask,
IsCompleted: event.IsCompleted,
Slots: scheduleSlotsToActive(event.Slots),
TaskClassID: event.TaskClassID,
TaskItemID: event.TaskItemID,
CanBeShortened: event.CanBeShortened,
}
}
func scheduleEventFactPtrToActive(event *schedulecontracts.ScheduleEventFact) *activeports.ScheduleEventFact {
if event == nil {
return nil
}
converted := scheduleEventFactToActive(*event)
return &converted
}
func scheduleSlotsToActive(slots []schedulecontracts.Slot) []activeports.Slot {
out := make([]activeports.Slot, 0, len(slots))
for _, slot := range slots {
out = append(out, activeports.Slot{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
Section: slot.Section,
StartAt: slot.StartAt,
EndAt: slot.EndAt,
})
}
return out
}
func feedbackFactToActive(feedback schedulecontracts.FeedbackFact) activeports.FeedbackFact {
return activeports.FeedbackFact{
FeedbackID: feedback.FeedbackID,
Text: feedback.Text,
TargetKnown: feedback.TargetKnown,
TargetEventID: feedback.TargetEventID,
TargetTaskItemID: feedback.TargetTaskItemID,
TargetTitle: feedback.TargetTitle,
SubmittedAt: feedback.SubmittedAt,
}
}
func toScheduleApplyContract(req activeapplyadapter.ApplyActiveScheduleRequest) schedulecontracts.ApplyActiveScheduleRequest {
changes := make([]schedulecontracts.ApplyChange, 0, len(req.Changes))
for _, change := range req.Changes {
changes = append(changes, schedulecontracts.ApplyChange{
ChangeID: change.ChangeID,
ChangeType: change.ChangeType,
TargetType: change.TargetType,
TargetID: change.TargetID,
ToSlot: toScheduleSlotSpan(change.ToSlot),
DurationSections: change.DurationSections,
Metadata: cloneStringMap(change.Metadata),
})
}
return schedulecontracts.ApplyActiveScheduleRequest{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
UserID: req.UserID,
CandidateID: req.CandidateID,
Changes: changes,
RequestedAt: req.RequestedAt,
TraceID: req.TraceID,
}
}
func toScheduleSlotSpan(span *activeapplyadapter.SlotSpan) *schedulecontracts.SlotSpan {
if span == nil {
return nil
}
return &schedulecontracts.SlotSpan{
Start: schedulecontracts.Slot{Week: span.Start.Week, DayOfWeek: span.Start.DayOfWeek, Section: span.Start.Section},
End: schedulecontracts.Slot{Week: span.End.Week, DayOfWeek: span.End.DayOfWeek, Section: span.End.Section},
DurationSections: span.DurationSections,
}
}
func jsonBytes(resp *schedulepb.JSONResponse) []byte {
if resp == nil || len(resp.DataJson) == 0 {
return []byte("null")
}
return resp.DataJson
}
func normalizeScheduleRPCEndpoints(values []string) []string {
endpoints := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
endpoints = append(endpoints, trimmed)
}
}
return endpoints
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}

View File

@@ -24,11 +24,15 @@ type PreviewConfirmService struct {
dryRun *DryRunService
preview *activepreview.Service
activeDAO *dao.ActiveScheduleDAO
applyAdapter *applyadapter.GormApplyAdapter
applyAdapter scheduleApplyAdapter
clock func() time.Time
}
func NewPreviewConfirmService(dryRun *DryRunService, previewService *activepreview.Service, activeDAO *dao.ActiveScheduleDAO, applyAdapter *applyadapter.GormApplyAdapter) (*PreviewConfirmService, error) {
type scheduleApplyAdapter interface {
ApplyActiveScheduleChanges(ctx context.Context, req applyadapter.ApplyActiveScheduleRequest) (applyadapter.ApplyActiveScheduleResult, error)
}
func NewPreviewConfirmService(dryRun *DryRunService, previewService *activepreview.Service, activeDAO *dao.ActiveScheduleDAO, applyAdapter scheduleApplyAdapter) (*PreviewConfirmService, error) {
if dryRun == nil {
return nil, errors.New("dry-run service 不能为空")
}

View File

@@ -75,7 +75,7 @@ type runtimeDependencyTable struct {
//
// 职责边界:
// 1. 只检查表是否存在,不 AutoMigrate、不补列、不修改任何跨域表
// 2. 把 active-scheduler 运行时仍然需要的 task / schedule / agent / notification outbox 边界显式化;
// 2. 把 active-scheduler 运行时仍然需要的 task / agent / notification outbox 边界显式化;
// 3. 若部署顺序、库权限或表结构归属不满足,启动阶段直接 fail fast避免第一次 trigger 才反复重试。
func ensureRuntimeDependencyTables(db *gorm.DB) error {
if db == nil {
@@ -110,7 +110,7 @@ func ensureTableExists(db *gorm.DB, table runtimeDependencyTable) error {
// 说明:
// 1. active-scheduler 自有表在 OpenDBFromConfig 内迁移,这里只放跨域依赖;
// 2. notification outbox 表名来自 service catalog避免和 outbox 多表路由配置漂移;
// 3. 后续切到 task/schedule/agent/notification RPC 或 read model 后,应从这里移除对应表依赖。
// 3. schedule 读写已切到 schedule RPC后续切到 task/agent/notification RPC 或 read model 后,应继续移除对应表依赖。
func activeSchedulerRuntimeDependencyTables() []runtimeDependencyTable {
notificationOutboxTable := "notification_outbox_messages"
if cfg, ok := outboxinfra.ResolveServiceConfig(outboxinfra.ServiceNotification); ok && cfg.TableName != "" {
@@ -118,11 +118,7 @@ func activeSchedulerRuntimeDependencyTables() []runtimeDependencyTable {
}
return []runtimeDependencyTable{
{Name: "tasks", Reason: "dry-run 读取 task_pool 事实confirm 时锁定 task_pool 目标"},
{Name: "schedule_events", Reason: "dry-run 读取日程事实confirm 时写入正式日程事件"},
{Name: "schedules", Reason: "dry-run 读取节次占用confirm 时写入正式节次"},
{Name: "task_classes", Reason: "confirm create_makeup 时校验 task_item 归属"},
{Name: "task_items", Reason: "confirm create_makeup 时锁定 task_item 目标"},
{Name: "tasks", Reason: "迁移期 dry-run / due job scanner 仍读取 task_pool 事实,下一轮切 task RPC 后移除"},
{Name: "agent_chats", Reason: "trigger 生成 preview 后预建主动调度会话"},
{Name: "chat_histories", Reason: "trigger 生成 preview 后写入会话首屏消息"},
{Name: "agent_timeline_events", Reason: "trigger 生成 preview 后写入主动调度时间线卡片"},

View File

@@ -14,7 +14,7 @@ import (
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
activeadapters "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/adapters"
activeapply "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/apply"
"github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/applyadapter"
activeapplyadapter "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/applyadapter"
activegraph "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/graph"
activejob "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/job"
activepreview "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/preview"
@@ -39,6 +39,7 @@ type Options struct {
JobScanEvery time.Duration
JobScanLimit int
KafkaConfig kafkabus.Config
ScheduleRPC activeadapters.ScheduleRPCConfig
}
// Service 是 active-scheduler 独立进程内的服务门面。
@@ -69,12 +70,16 @@ func New(db *gorm.DB, llmService *llmservice.Service, opts Options) (*Service, e
activeDAO := rootdao.NewActiveScheduleDAO(db)
activeReaders := activeadapters.NewGormReaders(db)
readers := activeadapters.ReadersFromGorm(activeReaders)
scheduleRPCAdapter, err := activeadapters.NewScheduleRPCAdapter(opts.ScheduleRPC)
if err != nil {
return nil, fmt.Errorf("initialize schedule rpc adapter failed: %w", err)
}
readers := activeadapters.ReadersWithScheduleRPC(activeReaders, scheduleRPCAdapter)
dryRun, err := activesvc.NewDryRunService(readers)
if err != nil {
return nil, err
}
previewConfirm, err := buildPreviewConfirmService(db, activeDAO, dryRun)
previewConfirm, err := buildPreviewConfirmService(activeDAO, dryRun, scheduleRPCAdapter)
if err != nil {
return nil, err
}
@@ -259,12 +264,14 @@ func (s *Service) ConfirmPreview(ctx context.Context, req contracts.ConfirmPrevi
return marshalResponseJSON(result)
}
func buildPreviewConfirmService(db *gorm.DB, activeDAO *rootdao.ActiveScheduleDAO, dryRun *activesvc.DryRunService) (*activesvc.PreviewConfirmService, error) {
func buildPreviewConfirmService(activeDAO *rootdao.ActiveScheduleDAO, dryRun *activesvc.DryRunService, scheduleApplyAdapter interface {
ApplyActiveScheduleChanges(context.Context, activeapplyadapter.ApplyActiveScheduleRequest) (activeapplyadapter.ApplyActiveScheduleResult, error)
}) (*activesvc.PreviewConfirmService, error) {
previewService, err := activepreview.NewService(activeDAO)
if err != nil {
return nil, err
}
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db))
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, scheduleApplyAdapter)
}
func buildGraphRunner(dryRun *activesvc.DryRunService, llmService *llmservice.Service) (*activegraph.Runner, error) {