package services import ( "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" errorcode "ai_scheduler/internal/data/error" "ai_scheduler/internal/entitys" "ai_scheduler/internal/gateway" "ai_scheduler/internal/pkg" "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/util" "ai_scheduler/internal/tools_bot" "context" "encoding/json" "strings" "time" "github.com/gofiber/fiber/v2" ) // CallbackService 统一回调入口 type CallbackService struct { cfg *config.Config gateway *gateway.Gateway dingtalkOldClient *dingtalk.OldClient dingtalkContactClient *dingtalk.ContactClient dingtalkNotableClient *dingtalk.NotableClient botTool *tools_bot.BotTool } func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, botTool *tools_bot.BotTool) *CallbackService { return &CallbackService{ cfg: cfg, gateway: gateway, dingtalkOldClient: dingtalkOldClient, dingtalkContactClient: dingtalkContactClient, dingtalkNotableClient: dingtalkNotableClient, botTool: botTool, } } // Envelope 回调统一请求体 type Envelope struct { Action string `json:"action"` TaskID string `json:"task_id"` Data json.RawMessage `json:"data"` } // bug_optimization_submit 工单回调 const ( ActionBugOptimizationSubmitProcess = "bug_optimization_submit_process" // 工单过程回调 ActionBugOptimizationSubmitDone = "bug_optimization_submit_done" // 工单完成回调 ActionBugOptimizationSubmitUpdate = "bug_optimization_submit_update" // 工单更新回调 ) // BugOptimizationSubmitDoneData 工单完成回调数据 type BugOptimizationSubmitDoneData struct { Receivers []string `json:"receivers"` DetailPage string `json:"detail_page"` Msg string `json:"msg"` } // BugOptimizationSubmitUpdateData 工单更新回调数据 type BugOptimizationSubmitUpdateData struct { BaseId string `json:"base_id"` // 表格ID SheetId string `json:"sheet_id"` // 表单ID RecordId string `json:"record_id"` // 记录ID UnionId string `json:"union_id"` // 钉钉用户 UnionID Creator string `json:"creator"` // 钉钉用户名称 } // Callback 统一回调处理 // 头部:X-Source-Key / X-Timestamp func (s *CallbackService) Callback(c *fiber.Ctx) error { // 读取头 sourceKey := strings.TrimSpace(c.Get("X-Source-Key")) ts := strings.TrimSpace(c.Get("X-Timestamp")) // 时间窗口(如果提供了 ts 则校验,否则跳过),窗口 5 分钟 if ts != "" && !validateTimestamp(ts, 5*time.Minute) { return errorcode.AuthNotFound } // 解析 Envelope var env Envelope if err := json.Unmarshal(c.Body(), &env); err != nil { return errorcode.ParamErr("invalid json: %v", err) } if env.Action == "" || env.TaskID == "" { return errorcode.ParamErr("missing action/task_id") } if env.Data == nil { return errorcode.ParamErr("missing data") } switch sourceKey { case "dingtalk": return s.handleDingTalkCallback(c, env) default: return errorcode.AuthNotFound } } func validateTimestamp(ts string, window time.Duration) bool { // 期望毫秒时间戳或秒级,简单容错 // 尝试解析为整数 var n int64 for _, base := range []int64{1, 1000} { // 秒或毫秒 if v, ok := parseInt64(ts); ok { n = v // 归一为毫秒 if base == 1 && len(ts) <= 10 { n = n * 1000 } now := time.Now().UnixMilli() diff := now - n if diff < 0 { diff = -diff } if diff <= window.Milliseconds() { return true } } } return false } func parseInt64(s string) (int64, bool) { var n int64 for _, ch := range s { if ch < '0' || ch > '9' { return 0, false } n = n*10 + int64(ch-'0') } return n, true } func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) error { // 校验taskId sessionID, ok := s.botTool.GetSessionByTaskID(env.TaskID) if !ok { return errorcode.ParamErr("missing session_id for task_id: %s", env.TaskID) } ctx := c.Context() switch env.Action { case ActionBugOptimizationSubmitUpdate: // 业务处理 msg, businessErr := s.handleBugOptimizationSubmitUpdate(ctx, env.Data) if businessErr != nil { return businessErr } s.sendStreamLog(sessionID, msg) return c.JSON(fiber.Map{"code": 0, "message": "ok"}) case ActionBugOptimizationSubmitDone: // 业务处理 msg, businessErr := s.handleBugOptimizationSubmitDone(ctx, env.Data) if businessErr != nil { return businessErr } // 发送日志 s.sendStreamTxt(sessionID, msg) // 删除映射 s.botTool.DelTaskMapping(env.TaskID) return c.JSON(fiber.Map{"code": 0, "message": "ok"}) case ActionBugOptimizationSubmitProcess: type processData struct { Process string `json:"process"` } var data processData if err := json.Unmarshal(env.Data, &data); err != nil { return errorcode.ParamErr("invalid json: %v", err) } s.sendStreamLoading(sessionID, data.Process) return c.JSON(fiber.Map{"code": 0, "message": "ok"}) default: return errorcode.ParamErr("unknown action: %s", env.Action) } } // getDingtalkReceivers 解析接收者字符串为 DingTalk 用户 ID 列表 func (s *CallbackService) getDingtalkReceivers(ctx context.Context, receiverIds []string) string { var receiverNames []string for _, receiverId := range receiverIds { userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, receiverId) if err != nil { return "" } if userDetails == nil { return "" } receiverNames = append(receiverNames, "@"+userDetails.Name) } receivers := strings.Join(receiverNames, " ") return receivers } // sendStreamLog 发送流式日志 func (s *CallbackService) sendStreamLog(sessionID string, content string) { if content == "" { return } streamLog := entitys.Response{ Index: constants.BotToolsBugOptimizationSubmit, Content: content, Type: entitys.ResponseLog, } streamLogBytes := pkg.JsonByteIgonErr(streamLog) s.gateway.SendToUid(sessionID, streamLogBytes) } // sendStreamTxt 发送流式文本 func (s *CallbackService) sendStreamTxt(sessionID string, content string) { if content == "" { return } streamLog := entitys.Response{ Index: constants.BotToolsBugOptimizationSubmit, Content: content, Type: entitys.ResponseText, } streamLogBytes := pkg.JsonByteIgonErr(streamLog) s.gateway.SendToUid(sessionID, streamLogBytes) } // sendStreamLoading 发送流式加载过程 func (s *CallbackService) sendStreamLoading(sessionID string, content string) { if content == "" { return } streamLog := entitys.Response{ Index: constants.BotToolsBugOptimizationSubmit, Content: content, Type: entitys.ResponseLoading, } streamLogBytes := pkg.JsonByteIgonErr(streamLog) s.gateway.SendToUid(sessionID, streamLogBytes) } // handleBugOptimizationSubmitUpdate 处理 bug 优化提交更新回调 func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) { var data BugOptimizationSubmitUpdateData if err := json.Unmarshal(taskData, &data); err != nil { return "", errorcode.ParamErr("invalid data type: %v", err) } if data.Creator == "" { return "", errorcode.ParamErr("empty creator") } // 获取创建者uid accessToken, _ := s.dingtalkOldClient.GetAccessToken() creatorId, err := s.dingtalkContactClient.SearchUserOne(accessToken, data.Creator) if err != nil { return "", errorcode.ParamErr("invalid data type: %v", err) } // 获取用户详情 userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, creatorId) if err != nil { return "", errorcode.ParamErr("invalid data type: %v", err) } if userDetails == nil { return "", errorcode.ParamErr("user details not found") } unionId := userDetails.UnionID // 更新记录 ok, err := s.dingtalkNotableClient.UpdateRecord(accessToken, &dingtalk.UpdateRecordReq{ BaseId: data.BaseId, SheetId: data.SheetId, RecordId: data.RecordId, OperatorId: tools_bot.BotBugOptimizationSubmitAdminUnionId, CreatorUnionId: unionId, }) if err != nil { return "", errorcode.ParamErr("invalid data type: %v", err) } if !ok { return "", errorcode.ParamErr("update record failed") } return "问题记录即将完成", nil } // handleBugOptimizationSubmitDone 处理 bug 优化提交完成回调 func (s *CallbackService) handleBugOptimizationSubmitDone(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) { var data BugOptimizationSubmitDoneData if err := json.Unmarshal(taskData, &data); err != nil { return "", errorcode.ParamErr("invalid data type: %v", err) } if len(data.Receivers) == 0 { return "", errorcode.ParamErr("empty receivers") } // 构建接收者 receivers := s.getDingtalkReceivers(ctx, data.Receivers) if receivers == "" { return "", errorcode.ParamErr("invalid receivers") } // 构建跳转链接 var detailPage string if data.DetailPage != "" { detailPage = util.BuildJumpLink(data.DetailPage, "去查看") } msg := data.Msg msg = util.ReplacePlaceholder(msg, "receivers", receivers) msg = util.ReplacePlaceholder(msg, "detail_page", detailPage) return msg, nil }