Files
junhong_cmp_fiber/internal/infrastructure/wecom/approval_submission_client.go
break 73f5125d3d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
2026-07-25 17:06:58 +08:00

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
}