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

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

283 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply"
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
)
// PreviewConfirmService 编排第三阶段的预览生成、查询和确认应用。
//
// 职责边界:
// 1. 复用 dry-run 结果写 preview不重新实现候选生成
// 2. confirm 时只负责 preview 状态、幂等和 apply port 调用编排;
// 3. 正式 schedule 写入仍由 applyadapter 在事务中完成。
type PreviewConfirmService struct {
dryRun *DryRunService
preview *activepreview.Service
activeDAO *dao.ActiveScheduleDAO
applyAdapter *applyadapter.GormApplyAdapter
clock func() time.Time
}
func NewPreviewConfirmService(dryRun *DryRunService, previewService *activepreview.Service, activeDAO *dao.ActiveScheduleDAO, applyAdapter *applyadapter.GormApplyAdapter) (*PreviewConfirmService, error) {
if dryRun == nil {
return nil, errors.New("dry-run service 不能为空")
}
if previewService == nil {
return nil, errors.New("preview service 不能为空")
}
if activeDAO == nil {
return nil, errors.New("active schedule dao 不能为空")
}
if applyAdapter == nil {
return nil, errors.New("apply adapter 不能为空")
}
return &PreviewConfirmService{
dryRun: dryRun,
preview: previewService,
activeDAO: activeDAO,
applyAdapter: applyAdapter,
clock: time.Now,
}, nil
}
func (s *PreviewConfirmService) SetClock(clock func() time.Time) {
if s != nil && clock != nil {
s.clock = clock
}
}
func (s *PreviewConfirmService) CreatePreviewFromDryRun(ctx context.Context, req activepreview.CreatePreviewRequest) (*activepreview.CreatePreviewResponse, error) {
if s == nil || s.preview == nil {
return nil, errors.New("preview confirm service 未初始化")
}
return s.preview.CreatePreview(ctx, req)
}
func (s *PreviewConfirmService) GetPreview(ctx context.Context, userID int, previewID string) (*activepreview.ActiveSchedulePreviewDetail, error) {
if s == nil || s.preview == nil {
return nil, errors.New("preview confirm service 未初始化")
}
return s.preview.GetPreview(ctx, userID, previewID)
}
// ConfirmPreview 同步确认并应用主动调度预览。
//
// 步骤化说明:
// 1. 先读取 preview 并做同用户校验,避免跨用户确认;
// 2. 对已应用且命中同一幂等键的请求直接返回历史结果,避免重复写日程;
// 3. 转换 candidate/edited_changes 为 apply 请求;
// 4. 先把 preview 标记 applying再调用正式 apply adapter
// 5. 成功或失败都回写 preview保证接口返回后可排障。
func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeapply.ConfirmRequest) (*activeapply.ConfirmResult, error) {
if s == nil || s.activeDAO == nil || s.applyAdapter == nil {
return nil, errors.New("preview confirm service 未初始化")
}
now := s.now()
if req.RequestedAt.IsZero() {
req.RequestedAt = now
}
previewRow, err := s.activeDAO.GetPreviewByID(ctx, req.PreviewID)
if err != nil {
return nil, err
}
if previewRow.UserID != req.UserID {
return nil, fmt.Errorf("preview 不属于当前用户")
}
if previewRow.ApplyStatus == model.ActiveScheduleApplyStatusApplied {
if previewRow.ApplyIdempotencyKey == req.IdempotencyKey {
return alreadyAppliedResult(*previewRow), nil
}
return nil, fmt.Errorf("preview 已应用,不能使用新的幂等键重复确认")
}
applyReq, err := activeapply.ConvertConfirmToApplyRequest(*previewRow, req, now)
if err != nil {
_ = s.markApplyFailed(ctx, previewRow.ID, "", err)
return nil, err
}
if len(applyReq.Commands) == 0 {
return nil, fmt.Errorf("当前候选没有可正式应用的日程变更")
}
if err = s.markApplying(ctx, *applyReq); err != nil {
return nil, err
}
adapterReq := toAdapterRequest(*applyReq)
adapterResult, err := s.applyAdapter.ApplyActiveScheduleChanges(ctx, adapterReq)
if err != nil {
_ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, err)
return nil, err
}
result := activeapply.ApplyActiveScheduleResult{
ApplyID: applyReq.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedEventIDs: adapterResult.AppliedEventIDs,
AppliedScheduleIDs: adapterResult.AppliedScheduleIDs,
AppliedChanges: applyReq.Changes,
SkippedChanges: applyReq.SkippedChanges,
RequestHash: applyReq.RequestHash,
NormalizedChangeHash: applyReq.NormalizedChangesHash,
}
if err = s.markApplied(ctx, *applyReq, result); err != nil {
return nil, err
}
return &activeapply.ConfirmResult{
PreviewID: applyReq.PreviewID,
ApplyID: applyReq.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: applyReq.CandidateID,
RequestHash: applyReq.RequestHash,
RequestBodyHash: applyReq.RequestBodyHash,
ApplyRequest: applyReq,
ApplyResult: &result,
SkippedChanges: applyReq.SkippedChanges,
}, nil
}
func (s *PreviewConfirmService) markApplying(ctx context.Context, req activeapply.ApplyActiveScheduleRequest) error {
return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{
"apply_id": req.ApplyID,
"apply_status": model.ActiveScheduleApplyStatusApplying,
"apply_candidate_id": req.CandidateID,
"apply_idempotency_key": req.IdempotencyKey,
"apply_request_hash": req.RequestHash,
})
}
func (s *PreviewConfirmService) markApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest, result activeapply.ApplyActiveScheduleResult) error {
now := s.now()
appliedChangesJSON := mustJSON(result.AppliedChanges)
appliedEventIDsJSON := mustJSON(result.AppliedEventIDs)
return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{
"status": model.ActiveSchedulePreviewStatusApplied,
"apply_status": model.ActiveScheduleApplyStatusApplied,
"applied_changes_json": &appliedChangesJSON,
"applied_event_ids_json": &appliedEventIDsJSON,
"apply_error": nil,
"applied_at": &now,
})
}
func (s *PreviewConfirmService) markApplyFailed(ctx context.Context, previewID string, applyID string, err error) error {
if previewID == "" {
return nil
}
message := ""
if err != nil {
message = err.Error()
}
updates := map[string]any{
"apply_status": model.ActiveScheduleApplyStatusFailed,
"apply_error": &message,
}
if applyID != "" {
updates["apply_id"] = applyID
}
return s.activeDAO.UpdatePreviewFields(ctx, previewID, updates)
}
func (s *PreviewConfirmService) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
func toAdapterRequest(req activeapply.ApplyActiveScheduleRequest) applyadapter.ApplyActiveScheduleRequest {
changes := make([]applyadapter.ApplyChange, 0, len(req.Changes))
for _, change := range req.Changes {
changes = append(changes, toAdapterChange(change))
}
return applyadapter.ApplyActiveScheduleRequest{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
UserID: req.UserID,
CandidateID: req.CandidateID,
Changes: changes,
RequestedAt: req.RequestedAt,
TraceID: req.TraceID,
}
}
func toAdapterChange(change activeapply.ApplyChange) applyadapter.ApplyChange {
return applyadapter.ApplyChange{
ChangeID: change.ChangeID,
ChangeType: string(change.Type),
TargetType: change.TargetType,
TargetID: change.TargetID,
ToSlot: toAdapterSlotSpan(change),
DurationSections: change.DurationSections,
Metadata: cloneStringMap(change.Metadata),
}
}
func toAdapterSlotSpan(change activeapply.ApplyChange) *applyadapter.SlotSpan {
if len(change.Slots) == 0 {
return nil
}
start := change.Slots[0]
end := change.Slots[len(change.Slots)-1]
return &applyadapter.SlotSpan{
Start: applyadapter.Slot{Week: start.Week, DayOfWeek: start.DayOfWeek, Section: start.Section},
End: applyadapter.Slot{Week: end.Week, DayOfWeek: end.DayOfWeek, Section: end.Section},
DurationSections: len(change.Slots),
}
}
func alreadyAppliedResult(preview model.ActiveSchedulePreview) *activeapply.ConfirmResult {
appliedEventIDs := []int{}
if preview.AppliedEventIDsJSON != nil && *preview.AppliedEventIDsJSON != "" {
_ = json.Unmarshal([]byte(*preview.AppliedEventIDsJSON), &appliedEventIDs)
}
return &activeapply.ConfirmResult{
PreviewID: preview.ID,
ApplyID: stringValue(preview.ApplyID),
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: preview.ApplyCandidateID,
RequestHash: preview.ApplyRequestHash,
ApplyResult: &activeapply.ApplyActiveScheduleResult{
ApplyID: stringValue(preview.ApplyID),
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedEventIDs: appliedEventIDs,
RequestHash: preview.ApplyRequestHash,
},
}
}
func mustJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(raw)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
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
}