Version: 0.9.59.dev.260430

后端:
1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程
2. 同步更新主动调度实施文档的阶段状态与验收记录

前端:
3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
This commit is contained in:
LoveLosita
2026-04-30 12:05:15 +08:00
parent 1555042e80
commit e945578fbf
38 changed files with 10267 additions and 580 deletions

View File

@@ -0,0 +1,358 @@
package preview
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/model"
)
func candidateDTO(item candidate.Candidate) CandidateDTO {
return CandidateDTO{
CandidateID: item.CandidateID,
CandidateType: string(item.CandidateType),
Title: item.Title,
Summary: item.Summary,
Target: CandidateTargetDTO{
TargetType: item.Target.TargetType,
TargetID: item.Target.TargetID,
Title: item.Target.Title,
},
Changes: changeDTOs(item.CandidateID, item.Changes),
BeforeSummary: item.BeforeSummary,
AfterSummary: item.AfterSummary,
Risk: item.Risk,
Score: item.Score,
Validation: item.Validation,
Source: item.Source,
}
}
func changeDTOs(candidateID string, changes []candidate.ChangeItem) []ActiveScheduleChangeItem {
result := make([]ActiveScheduleChangeItem, 0, len(changes))
for index, change := range changes {
var fromSlot *SlotDTO
if change.FromSlot != nil {
value := slotDTO(*change.FromSlot)
fromSlot = &value
}
var toSlot *SlotSpanDTO
if change.ToSlot != nil {
value := slotSpanDTO(*change.ToSlot)
toSlot = &value
}
result = append(result, ActiveScheduleChangeItem{
ChangeID: fmt.Sprintf("%s:chg_%d", candidateID, index+1),
ChangeType: string(change.ChangeType),
TargetType: change.TargetType,
TargetID: change.TargetID,
FromSlot: fromSlot,
ToSlot: toSlot,
DurationSections: change.DurationSections,
AffectedEventIDs: append([]int(nil), change.AffectedEventIDs...),
EditedAllowed: change.EditedAllowed,
Metadata: copyStringMap(change.Metadata),
})
}
return result
}
func contextSummaryDTO(activeContext *schedulercontext.ActiveScheduleContext) ContextSummaryDTO {
if activeContext == nil {
return ContextSummaryDTO{}
}
return ContextSummaryDTO{
UserID: activeContext.User.UserID,
Timezone: activeContext.User.Timezone,
TriggerSource: string(activeContext.Trigger.Source),
RequestedAt: activeContext.Trigger.RequestedAt,
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
WindowReason: activeContext.Window.WindowReason,
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
TargetTitle: activeContext.Target.Title,
MissingInfo: append([]string(nil), activeContext.DerivedFacts.MissingInfo...),
TraceSteps: append([]string(nil), activeContext.Trace.BuildSteps...),
Warnings: append([]string(nil), activeContext.Trace.Warnings...),
}
}
func buildBeforeSummary(activeContext *schedulercontext.ActiveScheduleContext, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
version := SchedulePreviewVersion{
Title: "调整前",
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
SummaryLines: compactLines(selected.BeforeSummary),
}
affected := affectedEventSet(changes)
for _, event := range activeContext.ScheduleFacts.Events {
entry := entryFromEvent(event)
if affected[event.ID] || (selected.Target.TargetType == "schedule_event" && selected.Target.TargetID == event.ID) {
entry.Status = "affected"
}
version.Entries = append(version.Entries, entry)
}
return version
}
func buildAfterSummary(before SchedulePreviewVersion, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
after := SchedulePreviewVersion{
Title: "调整后",
WindowStart: before.WindowStart,
WindowEnd: before.WindowEnd,
Entries: append([]SchedulePreviewEntry(nil), before.Entries...),
SummaryLines: compactLines(selected.AfterSummary),
}
for _, change := range changes {
// 1. 只把会产生可视化新块的 change 追加到 afterask_user / none 不伪造正式日程。
// 2. 该 entry 仅用于展示和后续 confirm 校验输入,不代表已经写入 schedule_events / schedules。
if change.ToSlot == nil || (change.ChangeType != string(candidate.ChangeTypeAdd) && change.ChangeType != string(candidate.ChangeTypeCreateMakeup)) {
continue
}
after.Entries = append(after.Entries, SchedulePreviewEntry{
EntryID: "preview:" + change.ChangeID,
SourceType: change.TargetType,
SourceID: change.TargetID,
Title: selected.Target.Title,
StartAt: change.ToSlot.Start.StartAt,
EndAt: change.ToSlot.End.EndAt,
Week: change.ToSlot.Start.Week,
DayOfWeek: change.ToSlot.Start.DayOfWeek,
SectionFrom: change.ToSlot.Start.Section,
SectionTo: change.ToSlot.End.Section,
Status: "added",
Editable: change.EditedAllowed,
})
}
return after
}
func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry {
slots := append([]ports.Slot(nil), event.Slots...)
sort.Slice(slots, func(i, j int) bool {
if !slots[i].StartAt.IsZero() && !slots[j].StartAt.IsZero() && !slots[i].StartAt.Equal(slots[j].StartAt) {
return slots[i].StartAt.Before(slots[j].StartAt)
}
if slots[i].Week != slots[j].Week {
return slots[i].Week < slots[j].Week
}
if slots[i].DayOfWeek != slots[j].DayOfWeek {
return slots[i].DayOfWeek < slots[j].DayOfWeek
}
return slots[i].Section < slots[j].Section
})
entry := SchedulePreviewEntry{
EntryID: fmt.Sprintf("%s:%d", event.SourceType, event.ID),
SourceType: event.SourceType,
SourceID: event.ID,
Title: event.Title,
Status: "unchanged",
Editable: event.IsDynamicTask,
}
if len(slots) == 0 {
return entry
}
first := slots[0]
last := slots[len(slots)-1]
entry.StartAt = first.StartAt
entry.EndAt = last.EndAt
entry.Week = first.Week
entry.DayOfWeek = first.DayOfWeek
entry.SectionFrom = first.Section
entry.SectionTo = last.Section
return entry
}
func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem) RiskDTO {
affectedIDs := make([]int, 0)
seen := make(map[int]bool)
for _, change := range changes {
for _, id := range change.AffectedEventIDs {
if !seen[id] {
seen[id] = true
affectedIDs = append(affectedIDs, id)
}
}
}
level := "low"
if !selected.Validation.Valid {
level = "high"
} else if observation.Metrics.Risk.RequiresReorder || len(affectedIDs) > 0 {
level = "medium"
}
return RiskDTO{
Level: level,
Summary: selected.Risk,
Validation: selected.Validation,
RiskMetrics: observation.Metrics.Risk,
AffectedIDs: affectedIDs,
RequiresLLM: observation.Decision.LLMSelectionRequired,
FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID,
}
}
func buildBaseVersion(activeContext *schedulercontext.ActiveScheduleContext, changes []ActiveScheduleChangeItem) string {
type eventVersion struct {
ID int `json:"id"`
Slots []SlotDTO `json:"slots"`
}
events := make([]eventVersion, 0, len(activeContext.ScheduleFacts.Events))
for _, event := range activeContext.ScheduleFacts.Events {
slots := make([]SlotDTO, 0, len(event.Slots))
for _, slot := range event.Slots {
slots = append(slots, slotDTO(slot))
}
events = append(events, eventVersion{ID: event.ID, Slots: slots})
}
payload := struct {
UserID int `json:"user_id"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Events []eventVersion `json:"events"`
Changes []ActiveScheduleChangeItem `json:"changes"`
}{
UserID: activeContext.User.UserID,
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
Events: events,
Changes: changes,
}
raw, _ := json.Marshal(payload)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
func detailFromModel(row *model.ActiveSchedulePreview, now time.Time) (ActiveSchedulePreviewDetail, error) {
selected, err := decodeJSONField(row.SelectedCandidateJSON, CandidateDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
candidates, err := decodeJSONField(row.CandidatesJSON, []CandidateDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
decision, err := decodeJSONField(row.DecisionJSON, observe.Decision{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
metrics, err := decodeJSONField(row.MetricsJSON, observe.Metrics{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
issues, err := decodeJSONField(row.IssuesJSON, []observe.Issue{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
contextSummary, err := decodeJSONField(row.ContextSummaryJSON, ContextSummaryDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
before, err := decodeJSONField(row.BeforeSummaryJSON, SchedulePreviewVersion{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
changes, err := decodeJSONField(row.PreviewChangesJSON, []ActiveScheduleChangeItem{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
after, err := decodeJSONField(row.AfterSummaryJSON, SchedulePreviewVersion{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
risk, err := decodeJSONField(row.RiskJSON, RiskDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
expired := !row.ExpiresAt.After(now)
canConfirm := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
canIgnore := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
return ActiveSchedulePreviewDetail{
PreviewID: row.ID,
Status: row.Status,
ApplyStatus: row.ApplyStatus,
ExpiresAt: row.ExpiresAt,
GeneratedAt: row.GeneratedAt,
Expired: expired,
Trigger: PreviewTriggerDTO{
TriggerID: row.TriggerID,
TriggerType: row.TriggerType,
Source: contextSummary.TriggerSource,
TargetType: row.TargetType,
TargetID: row.TargetID,
RequestedAt: contextSummary.RequestedAt,
},
Explanation: row.ExplanationText,
Notification: row.NotificationSummary,
SelectedCandidate: selected,
Candidates: candidates,
Decision: decision,
Metrics: metrics,
Issues: issues,
ContextSummary: contextSummary,
Before: before,
After: after,
Changes: changes,
Risk: risk,
BaseVersion: row.BaseVersion,
CanConfirm: canConfirm,
CanIgnore: canIgnore,
TraceID: row.TraceID,
}, nil
}
func jsonString(value any) (string, error) {
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}
func compactLines(lines ...string) []string {
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func affectedEventSet(changes []ActiveScheduleChangeItem) map[int]bool {
result := make(map[int]bool)
for _, change := range changes {
for _, id := range change.AffectedEventIDs {
result[id] = true
}
}
return result
}
func copyStringMap(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

@@ -0,0 +1,232 @@
package preview
import (
"encoding/json"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
)
// CreatePreviewRequest 是把 dry-run 结果固化成主动调度预览的请求 DTO。
//
// 职责边界:
// 1. 负责承载 preview 写库所需的 dry-run 结果与可选覆盖字段;
// 2. 不承载 confirm/apply 请求,也不允许调用方传入正式日程写入参数;
// 3. GeneratedAt 为空时由 Service 时钟生成ExpiresAt 固定由 generated_at + 1h 推导。
type CreatePreviewRequest struct {
ActiveContext *schedulercontext.ActiveScheduleContext `json:"-"`
Observation observe.Result `json:"-"`
Candidates []candidate.Candidate `json:"-"`
PreviewID string `json:"preview_id,omitempty"`
TriggerID string `json:"trigger_id,omitempty"`
BaseVersion string `json:"base_version,omitempty"`
GeneratedAt time.Time `json:"generated_at,omitempty"`
ExplanationText string `json:"explanation_text,omitempty"`
NotificationSummary string `json:"notification_summary,omitempty"`
}
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。
type CreatePreviewResponse struct {
Detail ActiveSchedulePreviewDetail `json:"detail"`
}
// GetPreviewRequest 是查询 preview 详情的请求 DTO。
//
// 职责边界:
// 1. UserID 来自鉴权上下文,不能信任前端透传;
// 2. PreviewID 来自路由参数;
// 3. 查询只返回预览快照,不执行过期状态回写、不触发 apply。
type GetPreviewRequest struct {
UserID int `json:"user_id"`
PreviewID string `json:"preview_id"`
}
// ActiveSchedulePreviewDetail 是主动调度预览详情页响应 DTO。
//
// 职责边界:
// 1. 负责把 active_schedule_previews 中的 JSON 快照还原成前端可展示结构;
// 2. 不包含正式写日程能力,不代表 confirm 请求已通过校验;
// 3. CanConfirm 只表达当前快照状态可发起确认,最终是否能应用仍由 confirm/apply 链路重校验。
type ActiveSchedulePreviewDetail struct {
PreviewID string `json:"preview_id"`
Status string `json:"status"`
ApplyStatus string `json:"apply_status"`
ExpiresAt time.Time `json:"expires_at"`
GeneratedAt time.Time `json:"generated_at"`
Expired bool `json:"expired"`
Trigger PreviewTriggerDTO `json:"trigger"`
Explanation string `json:"explanation"`
Notification string `json:"notification_summary"`
SelectedCandidate CandidateDTO `json:"selected_candidate"`
Candidates []CandidateDTO `json:"candidates"`
Decision observe.Decision `json:"decision"`
Metrics observe.Metrics `json:"metrics"`
Issues []observe.Issue `json:"issues"`
ContextSummary ContextSummaryDTO `json:"context_summary"`
Before SchedulePreviewVersion `json:"before"`
After SchedulePreviewVersion `json:"after"`
Changes []ActiveScheduleChangeItem `json:"changes"`
Risk RiskDTO `json:"risk"`
BaseVersion string `json:"base_version"`
CanConfirm bool `json:"can_confirm"`
CanIgnore bool `json:"can_ignore"`
TraceID string `json:"trace_id"`
}
type PreviewTriggerDTO struct {
TriggerID string `json:"trigger_id"`
TriggerType string `json:"trigger_type"`
Source string `json:"source"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
RequestedAt time.Time `json:"requested_at"`
}
type CandidateDTO struct {
CandidateID string `json:"candidate_id"`
CandidateType string `json:"candidate_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Target CandidateTargetDTO `json:"target"`
Changes []ActiveScheduleChangeItem `json:"changes"`
BeforeSummary string `json:"before_summary"`
AfterSummary string `json:"after_summary"`
Risk string `json:"risk"`
Score int `json:"score"`
Validation candidate.Validation `json:"validation"`
Source string `json:"source"`
}
type CandidateTargetDTO struct {
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
Title string `json:"title"`
}
type ContextSummaryDTO struct {
UserID int `json:"user_id"`
Timezone string `json:"timezone"`
TriggerSource string `json:"trigger_source"`
RequestedAt time.Time `json:"requested_at"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
WindowReason string `json:"window_reason"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
TargetTitle string `json:"target_title"`
MissingInfo []string `json:"missing_info"`
TraceSteps []string `json:"trace_steps"`
Warnings []string `json:"warnings"`
}
type SchedulePreviewVersion struct {
Title string `json:"title"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Entries []SchedulePreviewEntry `json:"entries"`
SummaryLines []string `json:"summary_lines"`
}
type SchedulePreviewEntry struct {
EntryID string `json:"entry_id"`
SourceType string `json:"source_type"`
SourceID int `json:"source_id"`
Title string `json:"title"`
StartAt time.Time `json:"start_at,omitempty"`
EndAt time.Time `json:"end_at,omitempty"`
Week int `json:"week,omitempty"`
DayOfWeek int `json:"day_of_week,omitempty"`
SectionFrom int `json:"section_from,omitempty"`
SectionTo int `json:"section_to,omitempty"`
Status string `json:"status"`
Editable bool `json:"editable"`
}
type ActiveScheduleChangeItem struct {
ChangeID string `json:"change_id"`
ChangeType string `json:"change_type"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
FromSlot *SlotDTO `json:"from_slot,omitempty"`
ToSlot *SlotSpanDTO `json:"to_slot,omitempty"`
DurationSections int `json:"duration_sections"`
AffectedEventIDs []int `json:"affected_event_ids"`
EditedAllowed bool `json:"edited_allowed"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type SlotDTO struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
Section int `json:"section"`
StartAt time.Time `json:"start_at,omitempty"`
EndAt time.Time `json:"end_at,omitempty"`
}
type SlotSpanDTO struct {
Start SlotDTO `json:"start"`
End SlotDTO `json:"end"`
DurationSections int `json:"duration_sections"`
}
type RiskDTO struct {
Level string `json:"level"`
Summary string `json:"summary"`
Validation candidate.Validation `json:"validation"`
RiskMetrics observe.RiskMetrics `json:"risk_metrics"`
AffectedIDs []int `json:"affected_event_ids"`
RequiresLLM bool `json:"requires_llm"`
FallbackUsed bool `json:"fallback_used"`
}
// rawPreviewSnapshot 聚合需要写入 active_schedule_previews JSON 字段的快照。
type rawPreviewSnapshot struct {
selectedCandidate CandidateDTO
candidates []CandidateDTO
decision observe.Decision
metrics observe.Metrics
issues []observe.Issue
contextSummary ContextSummaryDTO
before SchedulePreviewVersion
changes []ActiveScheduleChangeItem
after SchedulePreviewVersion
risk RiskDTO
}
func slotDTO(slot ports.Slot) SlotDTO {
return SlotDTO{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
Section: slot.Section,
StartAt: slot.StartAt,
EndAt: slot.EndAt,
}
}
func slotSpanDTO(span ports.SlotSpan) SlotSpanDTO {
return SlotSpanDTO{
Start: slotDTO(span.Start),
End: slotDTO(span.End),
DurationSections: span.DurationSections,
}
}
// decodeJSONField 只负责 preview 包内部 DTO 解码。
//
// 说明:
// 1. 当前任务限制只允许修改 preview 目录,不能把 JSON helper 下沉到公共层;
// 2. 因此这里暂时保留包内小函数,后续若第二个 active_scheduler 子包也需要同类能力,再按 AGENTS 规则抽公共层;
// 3. 解码失败返回原始错误,避免把损坏快照静默展示给用户。
func decodeJSONField[T any](raw *string, fallback T) (T, error) {
if raw == nil || *raw == "" {
return fallback, nil
}
var value T
if err := json.Unmarshal([]byte(*raw), &value); err != nil {
return fallback, err
}
return value, nil
}

View File

@@ -0,0 +1,281 @@
package preview
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrInvalidPreviewRequest = errors.New("主动调度预览请求不合法")
ErrPreviewNotFound = errors.New("主动调度预览不存在")
)
// Repository 是 preview service 依赖的最小持久化端口。
//
// 职责边界:
// 1. 只覆盖本轮 preview 写入和详情查询需要的方法;
// 2. 不暴露正式日程写入、通知投递或 confirm apply 能力;
// 3. 现有 dao.ActiveScheduleDAO 已满足该接口,后续迁移独立 repo 时可并行替换实现。
type Repository interface {
CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error
GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error)
}
// Service 负责主动调度 preview 的写入和查询。
//
// 职责边界:
// 1. 将 dry-run 结果固化为 active_schedule_previews 中的 ready 快照;
// 2. 查询时校验 user_id并返回 API 可直接透传的详情 DTO
// 3. 不正式写日程、不发通知、不处理 confirm/apply也不修改 trigger 状态。
type Service struct {
repo Repository
clock func() time.Time
}
func NewService(repo Repository) (*Service, error) {
if repo == nil {
return nil, fmt.Errorf("%w: preview repository 不能为空", ErrInvalidPreviewRequest)
}
return &Service{repo: repo, clock: time.Now}, nil
}
// SetClock 注入测试时钟。
//
// 职责边界:
// 1. 只影响 generated_at / expires_at 和查询时的 expired 计算;
// 2. 不改写 dry-run 上下文中的业务当前时间;
// 3. clock 为空时保持原时钟,避免运行期误注入导致 panic。
func (s *Service) SetClock(clock func() time.Time) {
if s == nil || clock == nil {
return
}
s.clock = clock
}
// CreatePreview 把 dry-run 结果保存为 ready preview。
//
// 职责边界:
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate
// 3. 写库后只返回详情 DTO不发布通知、不正式应用候选、不回写 trigger。
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if req.ActiveContext == nil {
return nil, fmt.Errorf("%w: dry-run 结果不能为空", ErrInvalidPreviewRequest)
}
if len(req.Candidates) == 0 {
return nil, fmt.Errorf("%w: dry-run 未生成可保存候选", ErrInvalidPreviewRequest)
}
activeContext := req.ActiveContext
triggerID := strings.TrimSpace(req.TriggerID)
if triggerID == "" {
triggerID = strings.TrimSpace(activeContext.Trigger.TriggerID)
}
if triggerID == "" {
return nil, fmt.Errorf("%w: trigger_id 不能为空", ErrInvalidPreviewRequest)
}
generatedAt := req.GeneratedAt
if generatedAt.IsZero() {
generatedAt = s.now()
}
previewID := strings.TrimSpace(req.PreviewID)
if previewID == "" {
previewID = "asp_" + uuid.NewString()
}
// 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
selected := req.Candidates[0]
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected)
baseVersion := strings.TrimSpace(req.BaseVersion)
if baseVersion == "" {
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
}
explanation := strings.TrimSpace(req.ExplanationText)
if explanation == "" {
explanation = selected.Summary
}
notificationSummary := strings.TrimSpace(req.NotificationSummary)
if notificationSummary == "" {
notificationSummary = selected.Summary
}
row, err := buildPreviewModel(previewID, triggerID, generatedAt, baseVersion, explanation, notificationSummary, activeContext, snapshot)
if err != nil {
return nil, err
}
// 2. 写入 active_schedule_previews。这里不包事务写其它表因为本服务不负责 trigger/notification/apply 状态推进。
if err := s.repo.CreatePreview(ctx, row); err != nil {
return nil, err
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &CreatePreviewResponse{Detail: detail}, nil
}
// GetPreview 查询 preview 详情,并强制校验归属用户。
//
// 职责边界:
// 1. preview_id 不存在或不属于 user_id 时统一返回 ErrPreviewNotFound避免泄漏其它用户数据
// 2. 查询不会把过期 preview 回写为 expired过期状态仅在 DTO 中计算;
// 3. 不读取正式日程实时状态,因此不会触发 confirm 的 base_version 重校验。
func (s *Service) GetPreview(ctx context.Context, userID int, previewID string) (*ActiveSchedulePreviewDetail, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if userID <= 0 || strings.TrimSpace(previewID) == "" {
return nil, ErrPreviewNotFound
}
row, err := s.repo.GetPreviewByID(ctx, strings.TrimSpace(previewID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPreviewNotFound
}
return nil, err
}
if row == nil || row.UserID != userID {
return nil, ErrPreviewNotFound
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &detail, nil
}
func (s *Service) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
func buildPreviewModel(
previewID string,
triggerID string,
generatedAt time.Time,
baseVersion string,
explanation string,
notificationSummary string,
activeContext *schedulercontext.ActiveScheduleContext,
snapshot rawPreviewSnapshot,
) (*model.ActiveSchedulePreview, error) {
selectedJSON, err := jsonString(snapshot.selectedCandidate)
if err != nil {
return nil, err
}
candidatesJSON, err := jsonString(snapshot.candidates)
if err != nil {
return nil, err
}
decisionJSON, err := jsonString(snapshot.decision)
if err != nil {
return nil, err
}
metricsJSON, err := jsonString(snapshot.metrics)
if err != nil {
return nil, err
}
issuesJSON, err := jsonString(snapshot.issues)
if err != nil {
return nil, err
}
contextJSON, err := jsonString(snapshot.contextSummary)
if err != nil {
return nil, err
}
beforeJSON, err := jsonString(snapshot.before)
if err != nil {
return nil, err
}
changesJSON, err := jsonString(snapshot.changes)
if err != nil {
return nil, err
}
afterJSON, err := jsonString(snapshot.after)
if err != nil {
return nil, err
}
riskJSON, err := jsonString(snapshot.risk)
if err != nil {
return nil, err
}
return &model.ActiveSchedulePreview{
ID: previewID,
UserID: activeContext.User.UserID,
TriggerID: triggerID,
TriggerType: string(activeContext.Trigger.TriggerType),
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
Status: model.ActiveSchedulePreviewStatusReady,
SelectedCandidateID: snapshot.selectedCandidate.CandidateID,
CandidateCount: len(snapshot.candidates),
SelectedCandidateJSON: &selectedJSON,
CandidatesJSON: &candidatesJSON,
DecisionJSON: &decisionJSON,
MetricsJSON: &metricsJSON,
IssuesJSON: &issuesJSON,
ContextSummaryJSON: &contextJSON,
BeforeSummaryJSON: &beforeJSON,
PreviewChangesJSON: &changesJSON,
AfterSummaryJSON: &afterJSON,
RiskJSON: &riskJSON,
ExplanationText: explanation,
NotificationSummary: notificationSummary,
BaseVersion: baseVersion,
ExpiresAt: generatedAt.Add(time.Hour),
GeneratedAt: generatedAt,
ApplyStatus: model.ActiveScheduleApplyStatusNone,
TraceID: activeContext.Trace.TraceID,
}, nil
}
func buildSnapshot(
activeContext *schedulercontext.ActiveScheduleContext,
observation observe.Result,
candidates []candidate.Candidate,
selected candidate.Candidate,
) rawPreviewSnapshot {
selectedDTO := candidateDTO(selected)
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
for _, item := range candidates {
candidateDTOs = append(candidateDTOs, candidateDTO(item))
}
changes := changeDTOs(selected.CandidateID, selected.Changes)
before := buildBeforeSummary(activeContext, selected, changes)
after := buildAfterSummary(before, selected, changes)
return rawPreviewSnapshot{
selectedCandidate: selectedDTO,
candidates: candidateDTOs,
decision: observation.Decision,
metrics: observation.Metrics,
issues: observation.Issues,
contextSummary: contextSummaryDTO(activeContext),
before: before,
changes: changes,
after: after,
risk: riskDTO(selected, observation, changes),
}
}