Files
smartmate/backend/active_scheduler/service/preview_confirm.go
Losita 0a014f7472 Version: 0.9.60.dev.260430
后端:
1.接入主动调度 worker 与飞书通知链路
- 新增 due job scanner 与 active_schedule.triggered workflow
- 接入 notification.feishu.requested handler、飞书 webhook provider 和用户通知配置接口
- 支持 notification_records 去重、重试、skipped/dead 状态流转
- 完成 api / worker / all 启动模式装配与主动调度验收记录
2.后续要做的就是补全从异常发生到给用户推送消息之间的逻辑缺口
2026-04-30 23:45:27 +08:00

367 lines
13 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"
"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"
"gorm.io/gorm"
)
// 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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, activeapply.NewApplyError(activeapply.ErrorCodeTargetNotFound, "预览不存在或已被删除", err)
}
return nil, err
}
if previewRow.UserID != req.UserID {
return nil, activeapply.NewApplyError(activeapply.ErrorCodeForbidden, "预览不属于当前用户", nil)
}
if previewRow.ApplyStatus == model.ActiveScheduleApplyStatusApplied {
if previewRow.ApplyIdempotencyKey == req.IdempotencyKey {
return alreadyAppliedResult(*previewRow), nil
}
return nil, activeapply.NewApplyError(activeapply.ErrorCodeAlreadyApplied, "预览已经应用,不能使用新的幂等键重复确认", nil)
}
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 s.markNoopApplied(ctx, *applyReq)
}
if err = s.markApplying(ctx, *applyReq); err != nil {
return nil, err
}
adapterReq := toAdapterRequest(*applyReq)
adapterResult, err := s.applyAdapter.ApplyActiveScheduleChanges(ctx, adapterReq)
if err != nil {
classifiedErr := classifyAdapterApplyError(err)
_ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, classifiedErr)
return nil, classifiedErr
}
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,
})
}
// markNoopApplied 处理 notify_only / ask_user / close 这类“确认成功但不写正式日程”的候选。
//
// 职责边界:
// 1. 只把 preview 标记为已处理,并保留幂等字段,便于同 key 重试直接命中历史结果;
// 2. 不调用 apply adapter因为这些 change 在转换阶段已经被归类为 skipped_changes
// 3. 失败时直接返回数据库错误,调用方应按系统错误处理,避免前端误以为确认成功。
func (s *PreviewConfirmService) markNoopApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest) (*activeapply.ConfirmResult, error) {
result := activeapply.ApplyActiveScheduleResult{
ApplyID: req.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedChanges: []activeapply.ApplyChange{},
SkippedChanges: req.SkippedChanges,
RequestHash: req.RequestHash,
NormalizedChangeHash: req.NormalizedChangesHash,
}
if err := s.markApplied(ctx, req, result); err != nil {
return nil, err
}
return &activeapply.ConfirmResult{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: req.CandidateID,
RequestHash: req.RequestHash,
RequestBodyHash: req.RequestBodyHash,
ApplyRequest: &req,
ApplyResult: &result,
SkippedChanges: req.SkippedChanges,
}, nil
}
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_id": req.ApplyID,
"apply_status": model.ActiveScheduleApplyStatusApplied,
"apply_candidate_id": req.CandidateID,
"apply_idempotency_key": req.IdempotencyKey,
"apply_request_hash": req.RequestHash,
"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()
}
status := model.ActiveScheduleApplyStatusFailed
if applyErr, ok := activeapply.AsApplyError(err); ok {
switch applyErr.Code {
case activeapply.ErrorCodeExpired:
status = model.ActiveScheduleApplyStatusExpired
case activeapply.ErrorCodeDBError:
status = model.ActiveScheduleApplyStatusFailed
default:
status = model.ActiveScheduleApplyStatusRejected
}
}
updates := map[string]any{
"apply_status": status,
"apply_error": &message,
}
if applyID != "" {
updates["apply_id"] = applyID
}
return s.activeDAO.UpdatePreviewFields(ctx, previewID, updates)
}
// classifyAdapterApplyError 把正式写库 adapter 的错误转换为 confirm 层统一错误码。
//
// 职责边界:
// 1. 只处理 applyadapter 已声明的业务错误码,保持 API 层只理解 active_scheduler/apply 包;
// 2. 未知错误统一归为 db_error避免把真实系统故障错误映射为用户可修正的 4xx
// 3. 原始错误作为 cause 保留,日志和 apply_error 仍能追到 adapter 返回的完整信息。
func classifyAdapterApplyError(err error) error {
if err == nil {
return nil
}
var adapterErr *applyadapter.ApplyError
if !errors.As(err, &adapterErr) {
return activeapply.NewApplyError(activeapply.ErrorCodeDBError, "主动调度正式写库失败", err)
}
switch adapterErr.Code {
case applyadapter.ErrorCodeInvalidRequest:
return activeapply.NewApplyError(activeapply.ErrorCodeInvalidRequest, adapterErr.Message, err)
case applyadapter.ErrorCodeUnsupportedChangeType:
return activeapply.NewApplyError(activeapply.ErrorCodeUnsupportedChangeType, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetNotFound:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetNotFound, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetCompleted:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetCompleted, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetAlreadyScheduled:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetAlreadySchedule, adapterErr.Message, err)
case applyadapter.ErrorCodeSlotConflict:
return activeapply.NewApplyError(activeapply.ErrorCodeSlotConflict, adapterErr.Message, err)
case applyadapter.ErrorCodeInvalidEditedChanges:
return activeapply.NewApplyError(activeapply.ErrorCodeInvalidEditedChanges, adapterErr.Message, err)
default:
return activeapply.NewApplyError(activeapply.ErrorCodeDBError, adapterErr.Message, err)
}
}
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
}