All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
190 lines
8.3 KiB
Go
190 lines
8.3 KiB
Go
package wecom
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bytedance/sonic"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
submissionOutcomeSuccess = "success"
|
|
submissionOutcomeFailed = "failed"
|
|
submissionOutcomeUnknown = "unknown"
|
|
)
|
|
|
|
// ApprovalSubmitRequest 是调用企微 applyevent 的安全请求。
|
|
type ApprovalSubmitRequest struct {
|
|
InstanceID uint
|
|
ApplicationID uint
|
|
TemplateID string
|
|
CreatorUserID string
|
|
CorrelationID string
|
|
Contents []map[string]any
|
|
}
|
|
|
|
// ApprovalSubmitResult 描述企微是否明确创建审批单。
|
|
type ApprovalSubmitResult struct {
|
|
Outcome string
|
|
SPNo string
|
|
Message string
|
|
SafeToRetry bool
|
|
}
|
|
|
|
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
|
|
type ApprovalSubmissionClient struct {
|
|
tokens DirectoryTokenProvider
|
|
integration TokenIntegrationLog
|
|
httpClient *http.Client
|
|
baseURL string
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewApprovalSubmissionClient 创建企业微信审批提交客户端。
|
|
func NewApprovalSubmissionClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalSubmissionClient {
|
|
if timeout <= 0 {
|
|
timeout = constants.WeComDefaultHTTPTimeout
|
|
}
|
|
return &ApprovalSubmissionClient{
|
|
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
|
|
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
|
|
}
|
|
}
|
|
|
|
// Submit 使用企微后台模板流程提交审批申请,绝不在结果未知时自动重发。
|
|
func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSubmitRequest) (ApprovalSubmitResult, error) {
|
|
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "企业微信审批提交客户端未配置", SafeToRetry: true}, nil
|
|
}
|
|
token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID)
|
|
if err != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "取得企业微信 access_token 失败", SafeToRetry: true}, err
|
|
}
|
|
request, err := c.newSubmitRequest(ctx, token, input)
|
|
if err != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "创建企业微信审批提交请求失败", SafeToRetry: true}, err
|
|
}
|
|
resourceID := strconv.FormatUint(uint64(input.InstanceID), 10)
|
|
correlationID := strings.TrimSpace(input.CorrelationID)
|
|
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
|
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
|
Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType,
|
|
ResourceID: &resourceID, RequestSummary: map[string]any{
|
|
"application_id": input.ApplicationID, "template_id": input.TemplateID,
|
|
"creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents),
|
|
}, CorrelationID: optionalIntegrationString(correlationID),
|
|
})
|
|
if err != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err
|
|
}
|
|
startedAt := c.now()
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
message := "企业微信审批提交请求结果未知"
|
|
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
|
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: message,
|
|
ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
|
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
|
|
})
|
|
if completeErr != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
|
|
}
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
|
|
}
|
|
defer response.Body.Close()
|
|
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
|
|
if readErr != nil || int64(len(responseBody)) > constants.WeComMaxResponseBodyBytes {
|
|
message := "企业微信审批提交响应无法确认"
|
|
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
|
Result: constants.IntegrationResultUnknown, HTTPStatus: response.StatusCode, ProviderCode: "invalid_response",
|
|
ProviderMessage: message, ResponseSummary: map[string]any{"success": false},
|
|
DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
|
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
|
|
})
|
|
if completeErr != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
|
|
}
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
|
|
}
|
|
var result struct {
|
|
ErrCode int64 `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
SPNo string `json:"sp_no"`
|
|
}
|
|
if err := sonic.Unmarshal(responseBody, &result); err != nil {
|
|
result.ErrMsg = "企业微信审批提交响应格式无效"
|
|
result.ErrCode = int64(response.StatusCode)
|
|
}
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.SPNo) == "" {
|
|
providerCode := strconv.FormatInt(result.ErrCode, 10)
|
|
if result.ErrCode == 0 {
|
|
providerCode = strconv.Itoa(response.StatusCode)
|
|
}
|
|
message := strings.TrimSpace(result.ErrMsg)
|
|
if message == "" {
|
|
message = "企业微信明确拒绝审批提交"
|
|
}
|
|
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
|
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode, ProviderCode: providerCode,
|
|
ProviderMessage: message, ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode},
|
|
DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
|
})
|
|
if completeErr != nil {
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
|
|
}
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
|
|
}
|
|
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
|
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
|
|
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
|
|
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
|
|
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
|
|
})
|
|
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
|
|
}
|
|
|
|
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。
|
|
func (c *ApprovalSubmissionClient) newSubmitRequest(ctx context.Context, token string, input ApprovalSubmitRequest) (*http.Request, error) {
|
|
payload := map[string]any{
|
|
"creator_userid": input.CreatorUserID,
|
|
"template_id": input.TemplateID,
|
|
"use_template_approver": 1,
|
|
"apply_data": map[string]any{"contents": input.Contents},
|
|
"summary_list": []map[string]any{{"summary_info": []map[string]string{{"text": "业务审批申请", "lang": "zh_CN"}}}},
|
|
}
|
|
body, err := sonic.Marshal(payload)
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批提交请求失败")
|
|
}
|
|
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/applyevent")
|
|
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
|
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
|
|
}
|
|
query := endpoint.Query()
|
|
query.Set("access_token", token)
|
|
endpoint.RawQuery = query.Encode()
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批提交请求失败")
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
return request, nil
|
|
}
|
|
|
|
func optionalIntegrationString(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|