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 }