后端: 1.接入主动调度 worker 与飞书通知链路 - 新增 due job scanner 与 active_schedule.triggered workflow - 接入 notification.feishu.requested handler、飞书 webhook provider 和用户通知配置接口 - 支持 notification_records 去重、重试、skipped/dead 状态流转 - 完成 api / worker / all 启动模式装配与主动调度验收记录 2.后续要做的就是补全从异常发生到给用户推送消息之间的逻辑缺口
367 lines
13 KiB
Go
367 lines
13 KiB
Go
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
|
||
}
|