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:
136
backend/shared/events/active_schedule.go
Normal file
136
backend/shared/events/active_schedule.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ActiveScheduleTriggeredEventType = "active_schedule.triggered"
|
||||
ActiveScheduleTriggeredEventVersion = "1"
|
||||
)
|
||||
|
||||
const (
|
||||
ActiveScheduleTriggerTypeImportantUrgentTask = "important_urgent_task"
|
||||
ActiveScheduleTriggerTypeUnfinishedFeedback = "unfinished_feedback"
|
||||
|
||||
ActiveScheduleSourceWorkerDueJob = "worker_due_job"
|
||||
ActiveScheduleSourceAPITrigger = "api_trigger"
|
||||
ActiveScheduleSourceAPIDryRun = "api_dry_run"
|
||||
ActiveScheduleSourceUserFeedback = "user_feedback"
|
||||
|
||||
ActiveScheduleTargetTypeTaskPool = "task_pool"
|
||||
ActiveScheduleTargetTypeScheduleEvent = "schedule_event"
|
||||
ActiveScheduleTargetTypeTaskItem = "task_item"
|
||||
)
|
||||
|
||||
// ActiveScheduleTriggeredPayload 是 active_schedule.triggered 的事件载荷。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述“主动调度链路需要处理一个触发信号”这一事件事实;
|
||||
// 2. 不复用 GORM model,也不承载候选生成、预览写入或通知投递逻辑;
|
||||
// 3. Payload 只保留触发源补充 JSON,消费方需要按自身 DTO 再解析。
|
||||
type ActiveScheduleTriggeredPayload struct {
|
||||
TriggerID string `json:"trigger_id"`
|
||||
UserID int `json:"user_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Source string `json:"source"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
FeedbackID string `json:"feedback_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
DedupeKey string `json:"dedupe_key,omitempty"`
|
||||
MockNow *time.Time `json:"mock_now,omitempty"`
|
||||
IsMockTime bool `json:"is_mock_time"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
// Validate 校验事件契约必填字段与第一版枚举范围。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做协议级基础校验,避免无效事件进入 worker;
|
||||
// 2. 不检查 target 是否存在、是否归属用户,这些属于业务读模型责任;
|
||||
// 3. dry-run 不应发布该事件,因此 source=api_dry_run 会被拒绝。
|
||||
func (p ActiveScheduleTriggeredPayload) Validate() error {
|
||||
if strings.TrimSpace(p.TriggerID) == "" {
|
||||
return errors.New("trigger_id 不能为空")
|
||||
}
|
||||
if p.UserID <= 0 {
|
||||
return errors.New("user_id 必须大于 0")
|
||||
}
|
||||
if !isAllowedActiveScheduleTriggerType(p.TriggerType) {
|
||||
return errors.New("trigger_type 不在主动调度第一版允许范围内")
|
||||
}
|
||||
if !isAllowedActiveScheduleSource(p.Source) {
|
||||
return errors.New("source 不在主动调度第一版允许范围内")
|
||||
}
|
||||
if p.Source == ActiveScheduleSourceAPIDryRun {
|
||||
return errors.New("api_dry_run 不允许发布 active_schedule.triggered")
|
||||
}
|
||||
if !isAllowedActiveScheduleTargetType(p.TargetType) {
|
||||
return errors.New("target_type 不在主动调度第一版允许范围内")
|
||||
}
|
||||
if p.TargetID <= 0 {
|
||||
return errors.New("target_id 必须大于 0")
|
||||
}
|
||||
if p.RequestedAt.IsZero() {
|
||||
return errors.New("requested_at 不能为空")
|
||||
}
|
||||
if p.MockNow != nil && !p.IsMockTime {
|
||||
return errors.New("mock_now 非空时必须标记 is_mock_time=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageKey 返回 outbox/Kafka 消息键。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 按文档约定使用 user_id,便于同一用户事件在消费侧保持局部有序;
|
||||
// 2. 只做字符串构造,不访问数据库。
|
||||
func (p ActiveScheduleTriggeredPayload) MessageKey() string {
|
||||
if p.UserID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(p.UserID)
|
||||
}
|
||||
|
||||
// AggregateID 返回事件聚合 ID。
|
||||
//
|
||||
// 说明:
|
||||
// 1. active_schedule.triggered 的聚合主键是 trigger_id;
|
||||
// 2. 若 trigger_id 为空,返回空字符串,由发布方在 Validate 前发现问题。
|
||||
func (p ActiveScheduleTriggeredPayload) AggregateID() string {
|
||||
return strings.TrimSpace(p.TriggerID)
|
||||
}
|
||||
|
||||
func isAllowedActiveScheduleTriggerType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case ActiveScheduleTriggerTypeImportantUrgentTask, ActiveScheduleTriggerTypeUnfinishedFeedback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedActiveScheduleSource(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case ActiveScheduleSourceWorkerDueJob, ActiveScheduleSourceAPITrigger, ActiveScheduleSourceAPIDryRun, ActiveScheduleSourceUserFeedback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedActiveScheduleTargetType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case ActiveScheduleTargetTypeTaskPool, ActiveScheduleTargetTypeScheduleEvent, ActiveScheduleTargetTypeTaskItem:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
82
backend/shared/events/notification.go
Normal file
82
backend/shared/events/notification.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NotificationFeishuRequestedEventType = "notification.feishu.requested"
|
||||
NotificationFeishuRequestedEventVersion = "1"
|
||||
)
|
||||
|
||||
// FeishuNotificationRequestedPayload 是飞书通知请求事件载荷。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述“需要尝试发送一条飞书提醒”的跨模块协议;
|
||||
// 2. 不包含 provider SDK 参数,也不复用 notification_records 的 GORM model;
|
||||
// 3. 不决定是否真正投递,去重、配置关闭和重试由 notification 模块处理。
|
||||
type FeishuNotificationRequestedPayload struct {
|
||||
NotificationID int64 `json:"notification_id,omitempty"`
|
||||
UserID int `json:"user_id"`
|
||||
TriggerID string `json:"trigger_id"`
|
||||
PreviewID string `json:"preview_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
DedupeKey string `json:"dedupe_key"`
|
||||
TargetURL string `json:"target_url"`
|
||||
SummaryText string `json:"summary_text,omitempty"`
|
||||
FallbackText string `json:"fallback_text,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
}
|
||||
|
||||
// Validate 校验飞书通知事件的协议级必填字段。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保证通知 handler 能定位用户、预览和去重键;
|
||||
// 2. 不校验用户是否绑定飞书,也不调用 provider;
|
||||
// 3. target_url 必须是站内相对路径,避免事件载荷携带任意外部跳转。
|
||||
func (p FeishuNotificationRequestedPayload) Validate() error {
|
||||
if p.UserID <= 0 {
|
||||
return errors.New("user_id 必须大于 0")
|
||||
}
|
||||
if strings.TrimSpace(p.PreviewID) == "" {
|
||||
return errors.New("preview_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(p.DedupeKey) == "" {
|
||||
return errors.New("dedupe_key 不能为空")
|
||||
}
|
||||
targetURL := strings.TrimSpace(p.TargetURL)
|
||||
if targetURL == "" {
|
||||
return errors.New("target_url 不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(targetURL, "/schedule-adjust/") {
|
||||
return errors.New("target_url 必须是 /schedule-adjust/{preview_id} 站内相对路径")
|
||||
}
|
||||
if strings.Contains(targetURL, "://") || strings.HasPrefix(targetURL, "//") {
|
||||
return errors.New("target_url 不允许携带外部链接")
|
||||
}
|
||||
if p.RequestedAt.IsZero() {
|
||||
return errors.New("requested_at 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageKey 返回 outbox/Kafka 消息键。
|
||||
func (p FeishuNotificationRequestedPayload) MessageKey() string {
|
||||
if p.UserID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(p.UserID)
|
||||
}
|
||||
|
||||
// AggregateID 返回事件聚合 ID。
|
||||
//
|
||||
// 说明:notification.feishu.requested 使用 preview_id 串联通知与预览。
|
||||
func (p FeishuNotificationRequestedPayload) AggregateID() string {
|
||||
return strings.TrimSpace(p.PreviewID)
|
||||
}
|
||||
76
backend/shared/events/schedule_apply.go
Normal file
76
backend/shared/events/schedule_apply.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ScheduleApplySucceededEventType = "schedule.apply.succeeded"
|
||||
ScheduleApplyFailedEventType = "schedule.apply.failed"
|
||||
ScheduleApplyResultEventVersion = "1"
|
||||
)
|
||||
|
||||
// ScheduleApplyResultPayload 是主动调度确认应用的结果事件载荷。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 apply 成功或失败的结果事实,不表达 apply 请求;
|
||||
// 2. 不复用 preview DB model,也不携带完整变更明细;
|
||||
// 3. MVP 第一版 confirm 同步执行,是否发布该事件由后续接入点决定。
|
||||
type ScheduleApplyResultPayload struct {
|
||||
PreviewID string `json:"preview_id"`
|
||||
ApplyID string `json:"apply_id"`
|
||||
UserID int `json:"user_id"`
|
||||
TriggerID string `json:"trigger_id,omitempty"`
|
||||
CandidateID string `json:"candidate_id,omitempty"`
|
||||
AppliedEventIDs []int `json:"applied_event_ids,omitempty"`
|
||||
ApplyStatus string `json:"apply_status"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateForEvent 按具体事件类型校验 apply 结果载荷。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. succeeded 要求至少包含一个正式日程 event id;
|
||||
// 2. failed 要求 error_code,方便排障和前端提示映射;
|
||||
// 3. 不校验 event id 是否存在,正式落库事务负责保证。
|
||||
func (p ScheduleApplyResultPayload) ValidateForEvent(eventType string) error {
|
||||
if strings.TrimSpace(p.PreviewID) == "" {
|
||||
return errors.New("preview_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(p.ApplyID) == "" {
|
||||
return errors.New("apply_id 不能为空")
|
||||
}
|
||||
if p.UserID <= 0 {
|
||||
return errors.New("user_id 必须大于 0")
|
||||
}
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case ScheduleApplySucceededEventType:
|
||||
if len(p.AppliedEventIDs) == 0 {
|
||||
return errors.New("schedule.apply.succeeded 必须包含 applied_event_ids")
|
||||
}
|
||||
case ScheduleApplyFailedEventType:
|
||||
if strings.TrimSpace(p.ErrorCode) == "" {
|
||||
return errors.New("schedule.apply.failed 必须包含 error_code")
|
||||
}
|
||||
default:
|
||||
return errors.New("未知的 schedule apply 结果事件类型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ScheduleApplyResultPayload) MessageKey() string {
|
||||
if p.UserID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(p.UserID)
|
||||
}
|
||||
|
||||
func (p ScheduleApplyResultPayload) AggregateID() string {
|
||||
if strings.TrimSpace(p.ApplyID) != "" {
|
||||
return strings.TrimSpace(p.ApplyID)
|
||||
}
|
||||
return strings.TrimSpace(p.PreviewID)
|
||||
}
|
||||
Reference in New Issue
Block a user