This commit is contained in:
huangjin
2026-06-29 17:34:59 +08:00
commit fe3ad20fe2
271 changed files with 51767 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
// 钉钉开放平台 API 封装
package dingtalk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
myconfig "com.sclktx/m/v2/internal/pkg/config"
"github.com/sirupsen/logrus"
)
// Client 钉钉客户端
type Client struct {
cfg *myconfig.DingTalkConfig
httpCl *http.Client
token string
tokenExp time.Time
tokenLock sync.RWMutex
}
// NewClient 创建钉钉客户端
func NewClient(cfg *myconfig.DingTalkConfig) *Client {
return &Client{
cfg: cfg,
httpCl: &http.Client{Timeout: 10 * time.Second},
}
}
// tokenResponse 获取 access_token 的响应
type tokenResponse struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"` // 秒
}
// getAccessToken 获取钉钉 access_token带缓存
func (c *Client) getAccessToken() (string, error) {
c.tokenLock.RLock()
if c.token != "" && time.Now().Before(c.tokenExp) {
c.tokenLock.RUnlock()
return c.token, nil
}
c.tokenLock.RUnlock()
c.tokenLock.Lock()
defer c.tokenLock.Unlock()
// 双重检查
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body := map[string]string{
"appKey": c.cfg.AppKey,
"appSecret": c.cfg.AppSecret,
}
data, _ := json.Marshal(body)
logrus.Debug("钉钉获取 access_token 开始")
resp, err := c.httpCl.Post("https://api.dingtalk.com/v1.0/oauth2/accessToken", "application/json", bytes.NewReader(data))
if err != nil {
logrus.WithError(err).Error("钉钉获取 access_token HTTP 请求失败")
return "", fmt.Errorf("dingtalk getAccessToken request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Debug("钉钉获取 access_token 响应")
var tr tokenResponse
if err := json.Unmarshal(respBody, &tr); err != nil {
logrus.WithError(err).WithField("response", string(respBody)).Error("钉钉获取 access_token 解析失败")
return "", fmt.Errorf("dingtalk getAccessToken parse failed: %s", string(respBody))
}
if tr.AccessToken == "" {
logrus.WithField("response", string(respBody)).Error("钉钉获取 access_token 返回空")
return "", fmt.Errorf("dingtalk getAccessToken empty token: %s", string(respBody))
}
c.token = tr.AccessToken
c.tokenExp = time.Now().Add(time.Duration(tr.ExpireIn-60) * time.Second) // 提前 60 秒刷新
logrus.WithFields(logrus.Fields{
"expire_in": tr.ExpireIn,
}).Info("钉钉 access_token 获取成功")
return c.token, nil
}
// RobotBatchSendReq 批量发送机器人消息请求
type RobotBatchSendReq struct {
RobotCode string `json:"robotCode"`
UserIDs []string `json:"userIds"`
MsgKey string `json:"msgKey"`
MsgParam string `json:"msgParam"`
}
// BatchSendRobotMessage 批量发送人与机器人会话中机器人消息
// userIDs: 钉钉用户 unionId 列表
// title: 消息标题
// markdown: 消息内容Markdown 格式)
func (c *Client) BatchSendRobotMessage(userIDs []string, title, markdown string) error {
if len(userIDs) == 0 {
logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 userIDs 为空")
return nil
}
if c.cfg.RobotCode == "" {
logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 RobotCode 为空")
return nil
}
logrus.WithFields(logrus.Fields{
"user_ids": userIDs,
"title": title,
"robot_code": c.cfg.RobotCode,
}).Info("钉钉 BatchSendRobotMessage 开始")
token, err := c.getAccessToken()
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 获取 token 失败")
return err
}
msgParam := map[string]string{
"title": title,
"text": markdown,
}
msgParamJSON, _ := json.Marshal(msgParam)
req := RobotBatchSendReq{
RobotCode: c.cfg.RobotCode,
UserIDs: userIDs,
MsgKey: "sampleMarkdown",
MsgParam: string(msgParamJSON),
}
reqBody, _ := json.Marshal(req)
logrus.WithFields(logrus.Fields{
"request_body": string(reqBody),
}).Debug("钉钉 BatchSendRobotMessage 请求体")
httpReq, err := http.NewRequest("POST",
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
bytes.NewReader(reqBody))
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 创建请求失败")
return fmt.Errorf("dingtalk BatchSendRobotMessage create request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.httpCl.Do(httpReq)
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage HTTP 请求失败")
return fmt.Errorf("dingtalk BatchSendRobotMessage request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Debug("钉钉 BatchSendRobotMessage 响应")
// 成功时钉钉返回 200 空 body 或 processQueryKey
if resp.StatusCode >= 400 {
errMsg := fmt.Errorf("dingtalk BatchSendRobotMessage failed: status=%d body=%s", resp.StatusCode, string(respBody))
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Error("钉钉 BatchSendRobotMessage 请求返回错误状态码")
return errMsg
}
logrus.WithFields(logrus.Fields{
"user_ids": userIDs,
"title": title,
"response": string(respBody),
}).Info("钉钉机器人消息发送成功")
return nil
}

View File

@@ -0,0 +1,119 @@
// 访客预约相关钉钉消息模板
package dingtalk
import (
"fmt"
"time"
)
// FormatCSTTime 格式化时间为 Asia/Shanghai 时区的 "01-02 15:04 ~ 15:04" 格式
func FormatCSTTime(start, end time.Time) string {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.UTC
}
start = start.In(loc)
end = end.In(loc)
if start.IsZero() {
return ""
}
return start.Format("01-02 15:04") + " ~ " + end.Format("15:04")
}
// AppointmentMessage 访客预约消息参数
type AppointmentMessage struct {
VisitorList string // 所有访客信息(已格式化,每行一条用<br>连接)
EmployeeName string
VisitTime string
VisitPurpose string
Remark string
VehicleInfo string // 车辆信息(已格式化,每行一条用<br>连接)
AppointmentID uint
}
// AppointmentResultMessage 预约审批结果消息参数
type AppointmentResultMessage struct {
ApproverName string
Action string // "通过" / "拒绝"
Comment string
AppointmentID uint
VisitTime string
VisitorName string
}
// BuildNewAppointmentMsg 构建新预约提醒消息(发送给被访员工)
func BuildNewAppointmentMsg(m *AppointmentMessage) (title, markdown string) {
title = "📋 新的访客预约"
text := "有新的访客预约<br><br>"
// 访客
text += "【访客】<br>"
if m.VisitorList != "" {
text += m.VisitorList + "<br>"
}
// 接待信息
text += "**接待员工:** " + m.EmployeeName + "<br>"
text += "**访问时间:** " + m.VisitTime + "<br>"
if m.VisitPurpose != "" {
text += "**来访目的:** " + m.VisitPurpose + "<br>"
}
text += "<br>"
// 车辆
if m.VehicleInfo != "" {
text += "**车辆信息:**<br>" + m.VehicleInfo
}
// 备注
if m.Remark != "" {
text += "**备注:** " + m.Remark
}
text += "<br><br>请及时前往微信小程序“四川凌空天行”处理预约申请。"
return title, text
}
// BuildApproveNotificationMsg 构建审批通知消息(发送给下一节点审批人)
func BuildApproveNotificationMsg(m *AppointmentResultMessage) (title, markdown string) {
title = fmt.Sprintf("✅ 审批通过 - %s", m.ApproverName)
text := "审批通过<br><br>"
text += "**审批人:** " + m.ApproverName + "<br>"
text += "**结果:** 通过<br>"
if m.Comment != "" {
text += "**审批意见:** " + m.Comment + "<br>"
}
text += "**访问时间:** " + m.VisitTime + "<br>"
text += "**访客:** " + m.VisitorName + "<br>"
text += "<br>请前往小程序处理您的审批任务。"
return title, text
}
// BuildRejectNotificationMsg 构建审批拒绝通知消息(发送给下一节点审批人)
func BuildRejectNotificationMsg(m *AppointmentResultMessage) (title, markdown string) {
title = fmt.Sprintf("❌ 审批已终止 - %s", m.ApproverName)
text := "审批已终止<br><br>"
text += "**拒绝人:** " + m.ApproverName + "<br>"
text += "**结果:** 拒绝<br>"
if m.Comment != "" {
text += "**拒绝原因:** " + m.Comment + "<br>"
}
text += "**访问时间:** " + m.VisitTime + "<br>"
text += "**访客:** " + m.VisitorName + "<br>"
text += "<br>该预约申请已被拒绝,无需继续处理。"
return title, text
}
// BuildSameNodeCancelledMsg 构建同节点已取消通知消息
// 当同一节点有多人审批(非会签),其中一人已审时,通知其他待审人
func BuildSameNodeCancelledMsg(approvedUserName, comment, visitTime string) (title, markdown string) {
title = fmt.Sprintf(" %s 已审批", approvedUserName)
text := "审批状态更新<br><br>"
text += "**已审批人:** " + approvedUserName + "<br>"
if comment != "" {
text += "**备注:** " + comment + "<br>"
}
text += "**访问时间:** " + visitTime + "<br>"
text += "<br>该节点已有其他审批人处理,此任务已自动取消。"
return title, text
}