后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package sv
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
agentconv "github.com/LoveLosita/smartflow/backend/services/agent/conv"
|
||
scheduletool "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
|
||
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||
)
|
||
|
||
const scheduleProviderRPCTimeout = 6 * time.Second
|
||
|
||
// ScheduleAgentRPCClient 描述 agent schedule provider 读取 schedule 服务所需的最小能力。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只读取按周原始日程槽位事实;
|
||
// 2. 不暴露 schedule DAO、缓存或写入状态机;
|
||
// 3. 返回 JSON 契约后由 provider 复用既有 LoadScheduleState 建模逻辑。
|
||
type ScheduleAgentRPCClient interface {
|
||
GetAgentWeekSchedule(ctx context.Context, req schedulecontracts.AgentScheduleWeekRequest) (json.RawMessage, error)
|
||
}
|
||
|
||
// TaskClassAgentReadRPCClient 描述 agent schedule provider 读取 task-class 服务所需的最小能力。
|
||
type TaskClassAgentReadRPCClient interface {
|
||
GetAgentTaskClasses(ctx context.Context, req taskclasscontracts.AgentTaskClassesRequest) (json.RawMessage, error)
|
||
}
|
||
|
||
// TaskClassAgentRPCClient 聚合 agent 当前依赖的 task-class RPC 写入与读取能力。
|
||
type TaskClassAgentRPCClient interface {
|
||
TaskClassUpsertRPCClient
|
||
TaskClassAgentReadRPCClient
|
||
}
|
||
|
||
// ScheduleRPCProvider 通过 schedule/task-class zrpc 构建 agent ScheduleState。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只替换 agent schedule provider 的 DAO 读取路径;
|
||
// 2. 窗口推导、extra category 与 ScheduleState 建模继续复用 agent/conv 老逻辑;
|
||
// 3. 不负责持久化 Diff,不改变 confirm/apply 链路。
|
||
type ScheduleRPCProvider struct {
|
||
scheduleClient ScheduleAgentRPCClient
|
||
taskClassClient TaskClassAgentReadRPCClient
|
||
}
|
||
|
||
func NewScheduleRPCProvider(scheduleClient ScheduleAgentRPCClient, taskClassClient TaskClassAgentReadRPCClient) *ScheduleRPCProvider {
|
||
return &ScheduleRPCProvider{
|
||
scheduleClient: scheduleClient,
|
||
taskClassClient: taskClassClient,
|
||
}
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) LoadScheduleState(ctx context.Context, userID int) (*scheduletool.ScheduleState, error) {
|
||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*scheduletool.ScheduleState, error) {
|
||
if len(taskClassIDs) == 0 {
|
||
return p.LoadScheduleState(ctx, userID)
|
||
}
|
||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, taskClassIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]scheduletool.TaskClassMeta, error) {
|
||
if len(taskClassIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, taskClassIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return agentconv.TaskClassesToScheduleMetas(taskClasses), nil
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) loadScheduleStateWithTaskClasses(ctx context.Context, userID int, taskClasses []model.TaskClass, allowCurrentWeekFallback bool) (*scheduletool.ScheduleState, error) {
|
||
windowDays, weeks := agentconv.BuildWindowFromTaskClasses(taskClasses)
|
||
if len(windowDays) == 0 {
|
||
if !allowCurrentWeekFallback {
|
||
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
|
||
}
|
||
var err error
|
||
windowDays, weeks, err = agentconv.BuildCurrentWeekWindow()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
allSchedules := make([]model.Schedule, 0)
|
||
for _, week := range weeks {
|
||
weekSchedules, err := p.loadWeekSchedules(ctx, userID, week)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("通过 schedule RPC 加载用户周日程失败 week=%d: %w", week, err)
|
||
}
|
||
allSchedules = append(allSchedules, weekSchedules...)
|
||
}
|
||
|
||
extraItemCategories := agentconv.BuildExtraItemCategories(allSchedules, taskClasses)
|
||
return agentconv.LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) loadCompleteTaskClasses(ctx context.Context, userID int, taskClassIDs []int) ([]model.TaskClass, error) {
|
||
if p == nil || p.taskClassClient == nil {
|
||
return nil, errors.New("task-class rpc reader is nil")
|
||
}
|
||
callCtx, cancel := context.WithTimeout(ctx, scheduleProviderRPCTimeout)
|
||
defer cancel()
|
||
|
||
raw, err := p.taskClassClient.GetAgentTaskClasses(callCtx, taskclasscontracts.AgentTaskClassesRequest{
|
||
UserID: userID,
|
||
TaskClassIDs: append([]int(nil), taskClassIDs...),
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var resp taskclasscontracts.AgentTaskClassesResponse
|
||
if len(raw) > 0 && string(raw) != "null" {
|
||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
taskClasses := make([]model.TaskClass, 0, len(resp.TaskClasses))
|
||
for _, item := range resp.TaskClasses {
|
||
taskClass, err := agentTaskClassToModel(item)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
taskClasses = append(taskClasses, taskClass)
|
||
}
|
||
return taskClasses, nil
|
||
}
|
||
|
||
func (p *ScheduleRPCProvider) loadWeekSchedules(ctx context.Context, userID int, week int) ([]model.Schedule, error) {
|
||
if p == nil || p.scheduleClient == nil {
|
||
return nil, errors.New("schedule rpc reader is nil")
|
||
}
|
||
callCtx, cancel := context.WithTimeout(ctx, scheduleProviderRPCTimeout)
|
||
defer cancel()
|
||
|
||
raw, err := p.scheduleClient.GetAgentWeekSchedule(callCtx, schedulecontracts.AgentScheduleWeekRequest{
|
||
UserID: userID,
|
||
Week: week,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var resp schedulecontracts.AgentScheduleWeekResponse
|
||
if len(raw) > 0 && string(raw) != "null" {
|
||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
schedules := make([]model.Schedule, 0, len(resp.Schedules))
|
||
for _, item := range resp.Schedules {
|
||
schedules = append(schedules, agentScheduleSlotToModel(item))
|
||
}
|
||
return schedules, nil
|
||
}
|
||
|
||
func agentTaskClassToModel(in taskclasscontracts.AgentTaskClass) (model.TaskClass, error) {
|
||
startDate, err := parseAgentDate(in.StartDate)
|
||
if err != nil {
|
||
return model.TaskClass{}, err
|
||
}
|
||
endDate, err := parseAgentDate(in.EndDate)
|
||
if err != nil {
|
||
return model.TaskClass{}, err
|
||
}
|
||
items := make([]model.TaskClassItem, 0, len(in.Items))
|
||
for _, item := range in.Items {
|
||
content := item.Content
|
||
items = append(items, model.TaskClassItem{
|
||
ID: item.ID,
|
||
CategoryID: cloneIntPtr(item.CategoryID),
|
||
Order: cloneIntPtr(item.Order),
|
||
Content: &content,
|
||
EmbeddedTime: taskClassContractTargetTimeToModel(item.EmbeddedTime),
|
||
Status: cloneIntPtr(item.Status),
|
||
})
|
||
}
|
||
return model.TaskClass{
|
||
ID: in.ID,
|
||
UserID: intPtrOrNil(in.UserID),
|
||
Name: stringPtrOrNil(in.Name),
|
||
Mode: stringPtrOrNil(in.Mode),
|
||
StartDate: startDate,
|
||
EndDate: endDate,
|
||
SubjectType: stringPtrOrNil(in.SubjectType),
|
||
DifficultyLevel: stringPtrOrNil(in.DifficultyLevel),
|
||
CognitiveIntensity: stringPtrOrNil(in.CognitiveIntensity),
|
||
TotalSlots: intPtrOrNil(in.TotalSlots),
|
||
AllowFillerCourse: boolPtr(in.AllowFillerCourse),
|
||
Strategy: stringPtrOrNil(in.Strategy),
|
||
ExcludedSlots: model.IntSlice(append([]int(nil), in.ExcludedSlots...)),
|
||
ExcludedDaysOfWeek: model.IntSlice(append([]int(nil), in.ExcludedDaysOfWeek...)),
|
||
Items: items,
|
||
}, nil
|
||
}
|
||
|
||
func agentScheduleSlotToModel(in schedulecontracts.AgentScheduleSlot) model.Schedule {
|
||
return model.Schedule{
|
||
ID: in.ID,
|
||
EventID: in.EventID,
|
||
UserID: in.UserID,
|
||
Week: in.Week,
|
||
DayOfWeek: in.DayOfWeek,
|
||
Section: in.Section,
|
||
EmbeddedTaskID: cloneIntPtr(in.EmbeddedTaskID),
|
||
Status: in.Status,
|
||
Event: agentScheduleEventToModel(in.Event),
|
||
EmbeddedTask: agentScheduleTaskItemToModel(in.EmbeddedTask),
|
||
}
|
||
}
|
||
|
||
func agentScheduleEventToModel(in *schedulecontracts.AgentScheduleEvent) *model.ScheduleEvent {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
return &model.ScheduleEvent{
|
||
ID: in.ID,
|
||
UserID: in.UserID,
|
||
Name: in.Name,
|
||
Location: cloneStringPtr(in.Location),
|
||
Type: in.Type,
|
||
RelID: cloneIntPtr(in.RelID),
|
||
TaskSourceType: in.TaskSourceType,
|
||
CanBeEmbedded: in.CanBeEmbedded,
|
||
StartTime: in.StartTime,
|
||
EndTime: in.EndTime,
|
||
}
|
||
}
|
||
|
||
func agentScheduleTaskItemToModel(in *schedulecontracts.AgentScheduleTaskItem) *model.TaskClassItem {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
content := in.Content
|
||
return &model.TaskClassItem{
|
||
ID: in.ID,
|
||
CategoryID: cloneIntPtr(in.CategoryID),
|
||
Order: cloneIntPtr(in.Order),
|
||
Content: &content,
|
||
EmbeddedTime: scheduleContractTargetTimeToModel(in.EmbeddedTime),
|
||
Status: cloneIntPtr(in.Status),
|
||
}
|
||
}
|
||
|
||
func parseAgentDate(value string) (*time.Time, error) {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed == "" {
|
||
return nil, nil
|
||
}
|
||
parsed, err := time.ParseInLocation("2006-01-02", trimmed, time.Local)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &parsed, nil
|
||
}
|
||
|
||
func taskClassContractTargetTimeToModel(value *taskclasscontracts.TargetTime) *model.TargetTime {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
return &model.TargetTime{
|
||
Week: value.Week,
|
||
DayOfWeek: value.DayOfWeek,
|
||
SectionFrom: value.SectionFrom,
|
||
SectionTo: value.SectionTo,
|
||
}
|
||
}
|
||
|
||
func scheduleContractTargetTimeToModel(value *schedulecontracts.AgentScheduleTargetTime) *model.TargetTime {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
return &model.TargetTime{
|
||
Week: value.Week,
|
||
DayOfWeek: value.DayOfWeek,
|
||
SectionFrom: value.SectionFrom,
|
||
SectionTo: value.SectionTo,
|
||
}
|
||
}
|
||
|
||
func stringPtrOrNil(value string) *string {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed == "" {
|
||
return nil
|
||
}
|
||
return &trimmed
|
||
}
|
||
|
||
func intPtrOrNil(value int) *int {
|
||
if value == 0 {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
func boolPtr(value bool) *bool {
|
||
return &value
|
||
}
|
||
|
||
func cloneIntPtr(value *int) *int {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
copied := *value
|
||
return &copied
|
||
}
|
||
|
||
func cloneStringPtr(value *string) *string {
|
||
if value == nil {
|
||
return nil
|
||
}
|
||
copied := *value
|
||
return &copied
|
||
}
|