七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s

This commit is contained in:
2026-07-25 17:06:58 +08:00
parent ad9f613dd6
commit 73f5125d3d
249 changed files with 17137 additions and 877 deletions

View File

@@ -0,0 +1,109 @@
package wecom
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApplicationRepository 持久化企业微信应用配置。
type ApplicationRepository struct {
db *gorm.DB
}
// NewApplicationRepository 创建企业微信应用 Repository。
func NewApplicationRepository(db *gorm.DB) *ApplicationRepository {
return &ApplicationRepository{db: db}
}
// FindByIdentityForUpdate 在事务中锁定企业与应用唯一配置。
func (r *ApplicationRepository) FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error) {
var application model.WeComApplication
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("corp_id = ? AND agent_id = ?", corpID, agentID).First(&application).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败")
}
return &application, nil
}
// Create 创建企业微信应用配置。
func (r *ApplicationRepository) Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error {
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信应用配置失败")
}
return nil
}
// Update 更新企业微信应用配置及密文凭据。
func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error {
updates := map[string]any{
"name": application.Name, "secret_ciphertext": application.SecretCiphertext,
"callback_token_ciphertext": application.CallbackTokenCiphertext,
"encoding_aes_key_ciphertext": application.EncodingAESKeyCiphertext,
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
"status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt,
}
if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", application.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败")
}
return nil
}
// UpdateDefaultCreator 更新应用默认审批发起人快照。
func (r *ApplicationRepository) UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error {
if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ? AND deleted_at IS NULL", applicationID).Updates(map[string]any{
"default_creator_userid": userID,
"default_creator_name": name,
"updated_by": operatorID,
"updated_at": updatedAt,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信默认审批发起人失败")
}
return nil
}
// List 分页查询企业微信应用配置及密文凭据。
func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComApplication{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信应用配置失败")
}
var applications []model.WeComApplication
if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&applications).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用列表失败")
}
return applications, total, nil
}
// GetEnabled 查询启用的企业微信应用及密文凭据。
func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) {
var application model.WeComApplication
if err := r.db.WithContext(ctx).Where("id = ? AND status = ?", applicationID, constants.StatusEnabled).First(&application).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWeComApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败")
}
return &application, nil
}
// MarkConnected 记录最近一次成功取得 access_token 的时间。
func (r *ApplicationRepository) MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error {
if err := r.db.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", applicationID).
Update("last_connected_at", connectedAt.UTC()).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信最近连接时间失败")
}
return nil
}

View File

@@ -0,0 +1,166 @@
package wecom
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"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"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// ApprovalFileReference 是业务快照中可上传到企微的对象存储引用。
type ApprovalFileReference struct {
StorageKey string `json:"storage_key"`
FileName string `json:"file_name"`
}
// ApprovalAttachmentUploader 将对象存储文件上传为企微临时素材。
type ApprovalAttachmentUploader struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
storage *storage.Service
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalAttachmentUploader 创建企业微信审批附件上传客户端。
func NewApprovalAttachmentUploader(tokens DirectoryTokenProvider, integration TokenIntegrationLog, storageService *storage.Service, baseURL string, timeout time.Duration) *ApprovalAttachmentUploader {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalAttachmentUploader{
tokens: tokens, integration: integration, storage: storageService,
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// Upload 下载对象存储文件并上传为企微临时素材Integration Log 不记录文件正文或 media_id。
func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) {
if u == nil || u.tokens == nil || u.integration == nil || u.storage == nil || u.httpClient == nil {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置")
}
reference.StorageKey = strings.TrimSpace(reference.StorageKey)
if reference.StorageKey == "" {
return "", errors.New(errors.CodeInvalidParam, "审批附件对象存储 Key 不能为空")
}
localPath, cleanup, err := u.storage.DownloadToTemp(ctx, reference.StorageKey)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "下载审批附件失败")
}
defer cleanup()
file, err := os.Open(localPath)
if err != nil {
return "", errors.Wrap(errors.CodeServiceUnavailable, err, "打开审批附件失败")
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() <= 5 || info.Size() > constants.WeComApprovalMaxAttachmentBytes {
return "", errors.New(errors.CodeInvalidParam, "审批附件大小必须大于 5 字节且不超过 20MB")
}
fileName := filepath.Base(strings.TrimSpace(reference.FileName))
if fileName == "." || fileName == "" {
fileName = filepath.Base(reference.StorageKey)
}
token, err := u.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return "", err
}
endpoint, err := url.Parse(u.baseURL + "/cgi-bin/media/upload")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return "", errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
query.Set("type", "file")
endpoint.RawQuery = query.Encode()
request, err := newAttachmentUploadRequest(ctx, endpoint.String(), fileName, file)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(instanceID), 10)
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
},
})
if err != nil {
return "", err
}
startedAt := u.now()
response, requestErr := u.httpClient.Do(request)
if requestErr != nil {
message := "企业微信审批附件上传失败"
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: "request_failed", ProviderMessage: message,
ResponseSummary: map[string]any{"success": false}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.Wrap(errors.CodeServiceUnavailable, requestErr, message)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes || sonic.Unmarshal(body, &result) != nil ||
response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.MediaID) == "" {
message := strings.TrimSpace(result.ErrMsg)
if message == "" {
message = "企业微信审批附件上传响应无效"
}
_, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: message,
ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
return "", errors.New(errors.CodeServiceUnavailable, "上传企业微信审批附件失败")
}
_, err = u.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, "media_id_set": true}, DurationMS: u.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return "", err
}
return strings.TrimSpace(result.MediaID), nil
}
// newAttachmentUploadRequest 在内存上限受文件大小校验约束的前提下组装 multipart 请求,避免流式写入 goroutine 泄漏。
func newAttachmentUploadRequest(ctx context.Context, endpoint, fileName string, file *os.File) (*http.Request, error) {
var body bytes.Buffer
multipartWriter := multipart.NewWriter(&body)
part, err := multipartWriter.CreateFormFile("media", fileName)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件表单失败")
}
if _, err := io.Copy(part, file); err != nil {
return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "读取审批附件失败")
}
if err := multipartWriter.Close(); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "完成企业微信附件表单失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body.Bytes()))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件上传请求失败")
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
return request, nil
}

View File

@@ -0,0 +1,395 @@
package wecom
import (
"context"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalSubmissionRecord 聚合 Worker 提交企微审批所需的本地事实。
type ApprovalSubmissionRecord struct {
Instance model.ApprovalInstance
Context model.WeComApprovalContext
}
// ApprovalRecoveryRecord 是主动恢复扫描需要的本地最小事实。
type ApprovalRecoveryRecord struct {
InstanceID uint
ApplicationID uint
TemplateID string
CreatorUserID string
SPNo string
SubmissionStatus int
SubmissionAttemptedAt time.Time
}
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct {
db *gorm.DB
now func() time.Time
}
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository {
return &ApprovalContextRepository{db: db, now: time.Now}
}
// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) {
if r == nil || r.db == nil || instanceID == 0 {
return nil, false, errors.New(errors.CodeInvalidParam, "企业微信审批提交参数无效")
}
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusReady).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusSending, "submission_attempted_at": now,
"last_error": "", "updated_at": now,
})
if result.Error != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信审批提交任务失败")
}
record, err := r.Get(ctx, instanceID)
if err != nil {
return nil, false, err
}
return record, result.RowsAffected == 1, nil
}
// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。
func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error {
if r == nil || r.db == nil || cutoff.IsZero() {
return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stale []model.WeComApprovalContext
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("submission_status = ? AND updated_at <= ?", constants.WeComSubmissionStatusSending, cutoff.UTC()).
Order("id ASC").Limit(constants.WeComApprovalRecoveryBatchSize).Find(&stale).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询超时的企业微信审批提交失败")
}
if len(stale) == 0 {
return nil
}
instanceIDs := make([]uint, 0, len(stale))
for _, channelContext := range stale {
instanceIDs = append(instanceIDs, channelContext.ApprovalInstanceID)
}
result := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id IN ? AND submission_status = ?", instanceIDs, constants.WeComSubmissionStatusSending).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusUnknown,
"submission_attempted_at": gorm.Expr("COALESCE(submission_attempted_at, updated_at)"),
"last_error": "企业微信审批提交处理中断,已进入结果未知恢复",
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败")
}
return nil
})
}
// ListUnknownRecovery 查询到期且尚未关联审批单号的结果未知记录。
func (r *ApprovalContextRepository) ListUnknownRecovery(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) {
if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize {
return nil, errors.New(errors.CodeInvalidParam, "企业微信审批恢复批量大小无效")
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Where("wc.submission_status = ? AND wc.sp_no = '' AND (wc.last_recovery_at IS NULL OR wc.last_recovery_at <= ?)",
constants.WeComSubmissionStatusUnknown, cutoff.UTC()).
Order("wc.id ASC").Limit(limit).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信结果未知审批失败")
}
return records, nil
}
// ClaimUnknownRecovery 领取一次结果未知恢复尝试,阻止多个 Worker 同时查询同一提交。
func (r *ApprovalContextRepository) ClaimUnknownRecovery(ctx context.Context, instanceID uint, cutoff time.Time) (bool, error) {
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = '' AND (last_recovery_at IS NULL OR last_recovery_at <= ?)",
instanceID, constants.WeComSubmissionStatusUnknown, cutoff.UTC()).
UpdateColumn("last_recovery_at", now)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信结果未知恢复任务失败")
}
return result.RowsAffected == 1, nil
}
// ListPendingSync 查询已提交且仍未终态、需要再次拉取权威详情的审批。
func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) {
if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize {
return nil, errors.New(errors.CodeInvalidParam, "企业微信审批轮询批量大小无效")
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Joins("JOIN tb_approval_instance AS ai ON ai.id = wc.approval_instance_id").
Where("wc.submission_status = ? AND wc.sp_no <> '' AND ai.status = ? AND (wc.last_synced_at IS NULL OR wc.last_synced_at <= ?)",
constants.WeComSubmissionStatusSubmitted, constants.ApprovalStatusPending, cutoff.UTC()).
Order("COALESCE(wc.last_synced_at, wc.created_at) ASC, wc.id ASC").Limit(limit).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待轮询企业微信审批失败")
}
return records, nil
}
// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) {
resourceID := strconv.FormatUint(uint64(instanceID), 10)
var log model.IntegrationLog
err := r.db.WithContext(ctx).
Where("provider = ? AND operation = ? AND direction = ? AND resource_id = ? AND result = ?",
constants.IntegrationProviderWeCom, constants.IntegrationOperationWeComApprovalSubmit,
constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess).
Order("id DESC").First(&log).Error
if err == gorm.ErrRecordNotFound {
return "", nil
}
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
}
var summary struct {
SPNo string `json:"sp_no"`
}
if sonic.Unmarshal(log.ResponseSummary, &summary) != nil {
return "", nil
}
return strings.TrimSpace(summary.SPNo), nil
}
// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。
func (r *ApprovalContextRepository) ExistingSPNos(ctx context.Context, applicationID uint, spNos []string) (map[string]struct{}, error) {
result := make(map[string]struct{})
if len(spNos) == 0 {
return result, nil
}
var values []string
if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("application_id = ? AND sp_no IN ?", applicationID, spNos).Pluck("sp_no", &values).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询已关联企业微信审批单号失败")
}
for _, value := range values {
result[value] = struct{}{}
}
return result, nil
}
// FindUniqueUnknownByFingerprint 按应用、模板、发起人和提交时间唯一定位结果未知实例。
func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint(
ctx context.Context,
applicationID uint,
templateID string,
creatorUserID string,
submittedAt time.Time,
) (*ApprovalRecoveryRecord, error) {
if applicationID == 0 || strings.TrimSpace(templateID) == "" || strings.TrimSpace(creatorUserID) == "" || submittedAt.IsZero() {
return nil, nil
}
var records []ApprovalRecoveryRecord
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").
Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid,
wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`).
Where(`wc.application_id = ? AND wc.template_id = ? AND wc.creator_userid = ?
AND wc.submission_status = ? AND wc.sp_no = ''
AND COALESCE(wc.submission_attempted_at, wc.updated_at) BETWEEN ? AND ?`,
applicationID, strings.TrimSpace(templateID), strings.TrimSpace(creatorUserID), constants.WeComSubmissionStatusUnknown,
submittedAt.Add(-constants.WeComApprovalRecoveryWindow).UTC(), submittedAt.Add(constants.WeComApprovalRecoveryWindow).UTC()).
Order("wc.id ASC").Limit(2).Scan(&records).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配企业微信结果未知审批失败")
}
if len(records) != 1 {
return nil, nil
}
return &records[0], nil
}
// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。
func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) {
spNo = strings.TrimSpace(spNo)
if instanceID == 0 || spNo == "" {
return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效")
}
now := r.now().UTC()
recovered := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown).
Updates(map[string]any{
"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo,
"last_error": "", "updated_at": now,
})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "恢复企业微信审批单号失败")
}
if contextResult.RowsAffected == 0 {
return nil
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmissionUnknown).
Updates(map[string]any{
"external_ref": spNo, "status": constants.ApprovalStatusPending,
"status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now,
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步恢复后的通用审批状态失败")
}
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批恢复状态已变化")
}
recovered = true
return nil
})
return recovered, err
}
// Get 读取通用审批实例及对应企微渠道上下文。
func (r *ApprovalContextRepository) Get(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, error) {
var instance model.ApprovalInstance
if err := r.db.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败")
}
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "企业微信审批渠道上下文不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return &ApprovalSubmissionRecord{Instance: instance, Context: channelContext}, nil
}
// GetBySPNo 按企微审批单号读取本地通用实例和渠道上下文。
func (r *ApprovalContextRepository) GetBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) {
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return r.Get(ctx, channelContext.ApprovalInstanceID)
}
// FindBySPNo 按企微审批单号查找本地记录;无关联时返回空,供回调区分无关审批。
func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) {
var channelContext model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败")
}
return r.Get(ctx, channelContext.ApprovalInstanceID)
}
// SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。
func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error {
now := r.now().UTC()
if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).Where("approval_instance_id = ?", instanceID).Updates(map[string]any{
"latest_sp_status": spStatus, "latest_detail_snapshot": datatypes.JSON(snapshot), "last_synced_at": now, "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批详情快照失败")
}
return nil
}
// ReleaseForRetry 将尚未调用 applyevent 的安全失败恢复为待提交。
func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanceID uint, message string) error {
result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusReady, "last_error": message, "updated_at": r.now().UTC()})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "恢复企业微信审批待提交状态失败")
}
return nil
}
// MarkSubmitted 原子保存企微 sp_no并把通用审批实例置为审批中。
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error {
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, "last_error": "", "updated_at": now})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "保存企业微信审批单号失败")
}
if contextResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "企业微信审批提交状态已变化")
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"external_ref": spNo, "status": constants.ApprovalStatusPending,
"status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now,
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交状态失败")
}
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交状态已变化")
}
return nil
})
}
// MarkFailed 将企微明确拒绝的提交记录为提交失败。
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message)
}
// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message)
}
// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error {
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": approvalStatus, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败")
}
return nil
})
}

View File

@@ -0,0 +1,130 @@
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"
)
// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。
type ApprovalDetail struct {
SPNo string
SPStatus int
Snapshot []byte
}
// ApprovalDetailClient 获取企业微信审批申请详情。
type ApprovalDetailClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalDetailClient 创建企业微信审批详情客户端。
func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalDetailClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalDetailClient{tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now}
}
// Get 获取审批详情并记录一次真实外呼。
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) {
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return ApprovalDetail{}, err
}
request, err := c.newRequest(ctx, token, spNo)
if err != nil {
return ApprovalDetail{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
})
if err != nil {
return ApprovalDetail{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信审批详情请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt)
}
var payload map[string]any
if err := sonic.Unmarshal(body, &payload); err != nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt)
}
errCode := numberToInt64(payload["errcode"])
errMsg, _ := payload["errmsg"].(string)
info, _ := payload["info"].(map[string]any)
status := int(numberToInt64(info["sp_status"]))
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 || info == nil {
return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(errCode, 10), errMsg, startedAt)
}
snapshot, err := sonic.Marshal(info)
if err != nil {
return ApprovalDetail{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情快照失败")
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err
}
// newRequest 组装按字符串审批单号查询权威详情的企微请求。
func (c *ApprovalDetailClient) newRequest(ctx context.Context, token, spNo string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovaldetail")
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()
body, err := sonic.Marshal(map[string]string{"sp_no": spNo})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情请求失败")
}
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
}
// completeFailure 先终结 Integration Log再向 Asynq 返回可重试的统一错误。
func (c *ApprovalDetailClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error {
if strings.TrimSpace(message) == "" {
message = "企业微信审批详情接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批详情失败")
}

View File

@@ -0,0 +1,159 @@
package wecom
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"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"
)
// ApprovalDetailTaskHandler 拉取企微审批详情并翻译为现有标准决策。
type ApprovalDetailTaskHandler struct {
details *ApprovalDetailClient
contexts *ApprovalContextRepository
decisions *approvalapp.SyncDecisionService
integration TokenIntegrationLog
}
// NewApprovalDetailTaskHandler 创建企业微信审批详情同步任务 Handler。
func NewApprovalDetailTaskHandler(details *ApprovalDetailClient, contexts *ApprovalContextRepository, decisions *approvalapp.SyncDecisionService, integration TokenIntegrationLog) *ApprovalDetailTaskHandler {
return &ApprovalDetailTaskHandler{details: details, contexts: contexts, decisions: decisions, integration: integration}
}
// Handle 处理回调或后续轮询提交的详情同步任务。
func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.details == nil || h.contexts == nil || h.decisions == nil || h.integration == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批详情同步任务未配置")
}
var payload ApprovalDetailSyncTask
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil || payload.ApplicationID == 0 || strings.TrimSpace(payload.SPNo) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步任务载荷无效")
}
if !validApprovalSyncSource(payload.Source) {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效")
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
if record == nil {
record, err = h.recoverUnknownCallback(ctx, payload, detail)
if err != nil {
return err
}
if record == nil {
return h.completeIgnoredCallback(ctx, payload)
}
}
if err := h.contexts.SaveLatestDetail(ctx, record.Instance.ID, detail.SPStatus, detail.Snapshot); err != nil {
return err
}
decisions := weComDecisions(detail.SPStatus)
for _, decision := range decisions {
if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{
InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source,
}); err != nil {
return err
}
}
if strings.TrimSpace(payload.IntegrationID) != "" {
_, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultCompleted, ProviderCode: "processed",
ProviderMessage: "企业微信审批回调已完成权威详情同步",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions},
DurationMS: 0, StateChanged: len(decisions) > 0,
})
}
return err
}
func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context, payload ApprovalDetailSyncTask, detail ApprovalDetail) (*ApprovalSubmissionRecord, error) {
if payload.Source != constants.ApprovalSyncSourceCallback {
return nil, nil
}
fingerprint := approvalDetailFingerprint(detail.Snapshot)
candidate, err := h.contexts.FindUniqueUnknownByFingerprint(
ctx, payload.ApplicationID, fingerprint.TemplateID, fingerprint.CreatorUserID, fingerprint.SubmittedAt,
)
if err != nil || candidate == nil {
return nil, err
}
recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo)
if err != nil || !recovered {
return nil, err
}
return h.contexts.Get(ctx, candidate.InstanceID)
}
func (h *ApprovalDetailTaskHandler) completeIgnoredCallback(ctx context.Context, payload ApprovalDetailSyncTask) error {
if strings.TrimSpace(payload.IntegrationID) == "" {
return nil
}
_, err := h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored, ProviderCode: "unrelated_approval",
ProviderMessage: "企业微信审批单未关联本系统业务,已忽略",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "ignored": true},
})
return err
}
type approvalFingerprint struct {
TemplateID string
CreatorUserID string
SubmittedAt time.Time
}
func approvalDetailFingerprint(snapshot []byte) approvalFingerprint {
var value struct {
TemplateID string `json:"template_id"`
ApplyTime int64 `json:"apply_time"`
Applyer struct {
UserID string `json:"userid"`
} `json:"applyer"`
}
if sonic.Unmarshal(snapshot, &value) != nil || value.ApplyTime <= 0 {
return approvalFingerprint{}
}
return approvalFingerprint{
TemplateID: strings.TrimSpace(value.TemplateID), CreatorUserID: strings.TrimSpace(value.Applyer.UserID),
SubmittedAt: time.Unix(value.ApplyTime, 0).UTC(),
}
}
func validApprovalSyncSource(source string) bool {
switch source {
case constants.ApprovalSyncSourceCallback, constants.ApprovalSyncSourcePolling, constants.ApprovalSyncSourceManual:
return true
default:
return false
}
}
// weComDecisions 只翻译现有审批核心支持的企微标准终态;通过后撤销按领域允许顺序补齐两个事实。
func weComDecisions(status int) []string {
switch status {
case 2:
return []string{constants.ApprovalDecisionApproved}
case 3:
return []string{constants.ApprovalDecisionRejected}
case 4:
return []string{constants.ApprovalDecisionCancelled}
case 6:
return []string{constants.ApprovalDecisionApproved, constants.ApprovalDecisionRevokedAfterApproved}
case 7:
return []string{constants.ApprovalDecisionDeleted}
default:
return nil
}
}

View File

@@ -0,0 +1,176 @@
package wecom
import (
"context"
"fmt"
"strings"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type approvalAttachmentPort interface {
Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error)
}
type approvalFormBuildResult struct {
Contents []map[string]any
}
type attachmentPreparationError struct {
err error
}
func (e *attachmentPreparationError) Error() string {
return e.err.Error()
}
func (e *attachmentPreparationError) Unwrap() error {
return e.err
}
// buildApprovalForm 按后台已校验映射将渠道无关业务快照组装为企微控件值。
func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) {
var mappings []dto.WeComControlMappingItem
if err := sonic.Unmarshal(mappingJSON, &mappings); err != nil || len(mappings) == 0 {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效")
}
var snapshot map[string]any
if err := sonic.Unmarshal(snapshotJSON, &snapshot); err != nil || snapshot == nil {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照无效")
}
var template wecomapp.TemplateDefinition
if err := sonic.Unmarshal(templateJSON, &template); err != nil {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板快照无效")
}
requiredControls := make(map[string]bool, len(template.Controls))
for _, control := range template.Controls {
requiredControls[control.ID] = control.Required
}
contents := make([]map[string]any, 0, len(mappings))
attachmentCount := 0
for _, mapping := range mappings {
raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField)
if !exists || raw == nil {
if requiredControls[mapping.ControlID] {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
}
continue
}
value, count, err := buildControlValue(ctx, applicationID, instanceID, mapping, raw, attachments)
if err != nil {
return approvalFormBuildResult{}, err
}
attachmentCount += count
if attachmentCount > constants.WeComApprovalMaxAttachmentCount {
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidParam, "单张企业微信审批单最多支持 6 个附件")
}
contents = append(contents, map[string]any{
"control": mapping.ControlType, "id": mapping.ControlID, "value": value,
})
}
return approvalFormBuildResult{Contents: contents}, nil
}
// lookupSnapshotValue 支持用点分路径读取嵌套业务快照,避免模板映射绑定具体 DTO。
func lookupSnapshotValue(snapshot map[string]any, path string) (any, bool) {
segments := strings.Split(strings.TrimSpace(path), ".")
var current any = snapshot
for _, segment := range segments {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[segment]
if !ok {
return nil, false
}
}
return current, true
}
// buildControlValue 按企微控件类型生成官方要求的 value 结构,复杂结构允许业务快照直接传入对象。
func buildControlValue(ctx context.Context, applicationID, instanceID uint, mapping dto.WeComControlMappingItem, raw any, attachments approvalAttachmentPort) (map[string]any, int, error) {
if object, ok := raw.(map[string]any); ok && !strings.EqualFold(mapping.ControlType, "File") {
return object, 0, nil
}
switch strings.ToLower(strings.TrimSpace(mapping.ControlType)) {
case "text", "textarea":
return map[string]any{"text": fmt.Sprint(raw)}, 0, nil
case "number":
return map[string]any{"new_number": fmt.Sprint(raw)}, 0, nil
case "money":
return map[string]any{"new_money": fmt.Sprint(raw)}, 0, nil
case "date":
return map[string]any{"date": map[string]any{"type": "day", "s_timestamp": fmt.Sprint(raw)}}, 0, nil
case "selector":
businessValue := fmt.Sprint(raw)
key := mapping.OptionMapping[businessValue]
if key == "" {
return nil, 0, errors.New(errors.CodeInvalidStatus, "审批业务枚举值没有企微选项映射: "+mapping.BusinessField)
}
return map[string]any{"selector": map[string]any{"type": "single", "options": []map[string]string{{"key": key}}}}, 0, nil
case "contact":
return map[string]any{"members": []map[string]string{{"userid": fmt.Sprint(raw)}}}, 0, nil
case "department":
return map[string]any{"departments": []map[string]string{{"openapi_id": fmt.Sprint(raw)}}}, 0, nil
case "file":
if attachments == nil {
return nil, 0, errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置")
}
references, err := parseApprovalFileReferences(raw)
if err != nil {
return nil, 0, err
}
files := make([]map[string]string, 0, len(references))
for _, reference := range references {
mediaID, err := attachments.Upload(ctx, applicationID, instanceID, reference)
if err != nil {
return nil, 0, &attachmentPreparationError{err: err}
}
files = append(files, map[string]string{"file_id": mediaID})
}
return map[string]any{"files": files}, len(files), nil
default:
return nil, 0, errors.New(errors.CodeInvalidStatus, "暂不支持的企业微信审批控件类型: "+mapping.ControlType)
}
}
// parseApprovalFileReferences 兼容现有退款和线下充值的字符串 Key 列表,以及带文件名的结构化引用。
func parseApprovalFileReferences(raw any) ([]ApprovalFileReference, error) {
if storageKey, ok := raw.(string); ok && strings.TrimSpace(storageKey) != "" {
return []ApprovalFileReference{{StorageKey: strings.TrimSpace(storageKey)}}, nil
}
bytes, err := sonic.Marshal(raw)
if err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "编码审批附件引用失败")
}
var references []ApprovalFileReference
if err := sonic.Unmarshal(bytes, &references); err != nil {
var storageKeys []string
if keyErr := sonic.Unmarshal(bytes, &storageKeys); keyErr == nil && len(storageKeys) > 0 {
references = make([]ApprovalFileReference, 0, len(storageKeys))
for _, storageKey := range storageKeys {
if strings.TrimSpace(storageKey) != "" {
references = append(references, ApprovalFileReference{StorageKey: strings.TrimSpace(storageKey)})
}
}
if len(references) > 0 {
return references, nil
}
}
var single ApprovalFileReference
if singleErr := sonic.Unmarshal(bytes, &single); singleErr != nil {
return nil, errors.New(errors.CodeInvalidParam, "审批附件必须提供对象存储引用")
}
references = []ApprovalFileReference{single}
}
if len(references) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "审批附件不能为空")
}
return references, nil
}

View File

@@ -0,0 +1,195 @@
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"
)
// ApprovalInfoQuery 描述按审批单提交时间查询审批单号的过滤条件。
type ApprovalInfoQuery struct {
ApplicationID uint
StartTime time.Time
EndTime time.Time
TemplateID string
CreatorUserID string
Cursor string
Size int
}
// ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。
type ApprovalInfoPage struct {
SPNos []string
NextCursor string
}
// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。
type ApprovalInfoClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalInfoClient 创建企业微信批量审批单号客户端。
func NewApprovalInfoClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalInfoClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalInfoClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// List 获取一页审批单号,并为每次真实外呼写入 Integration Log。
func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery) (ApprovalInfoPage, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return ApprovalInfoPage{}, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号客户端未配置")
}
if err := validateApprovalInfoQuery(input); err != nil {
return ApprovalInfoPage{}, err
}
token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID)
if err != nil {
return ApprovalInfoPage{}, err
}
request, err := c.newRequest(ctx, token, input)
if err != nil {
return ApprovalInfoPage{}, err
}
resourceID := strconv.FormatUint(uint64(input.ApplicationID), 10)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
"template_id": input.TemplateID, "creator_userid": input.CreatorUserID, "size": input.Size,
"cursor_present": strings.TrimSpace(input.Cursor) != "",
},
})
if err != nil {
return ApprovalInfoPage{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信批量审批单号请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt)
}
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
SPNoList []string `json:"sp_no_list"`
NewNextCursor string `json:"new_next_cursor"`
}
if err := sonic.Unmarshal(body, &result); err != nil {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 {
return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(result.ErrCode, 10), result.ErrMsg, startedAt)
}
spNos := normalizeSPNos(result.SPNoList)
_, 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{
"sp_no_count": len(spNos), "next_cursor_present": strings.TrimSpace(result.NewNextCursor) != "",
},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err
}
func validateApprovalInfoQuery(input ApprovalInfoQuery) error {
if input.ApplicationID == 0 || input.StartTime.IsZero() || input.EndTime.IsZero() || !input.StartTime.Before(input.EndTime) {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询参数无效")
}
if input.EndTime.Sub(input.StartTime) > constants.WeComApprovalInfoMaxWindow {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询时间跨度不能超过 31 天")
}
if input.Size <= 0 || input.Size > constants.WeComApprovalInfoMaxPageSize {
return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询每页数量无效")
}
return nil
}
// newRequest 组装企业微信批量审批单号请求,筛选值仅来自本地稳定提交快照。
func (c *ApprovalInfoClient) newRequest(ctx context.Context, token string, input ApprovalInfoQuery) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovalinfo")
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()
filters := make([]map[string]string, 0, 2)
if value := strings.TrimSpace(input.TemplateID); value != "" {
filters = append(filters, map[string]string{"key": "template_id", "value": value})
}
if value := strings.TrimSpace(input.CreatorUserID); value != "" {
filters = append(filters, map[string]string{"key": "creator", "value": value})
}
payload := map[string]any{
"starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
"new_cursor": strings.TrimSpace(input.Cursor), "size": input.Size, "filters": filters,
}
body, err := sonic.Marshal(payload)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信批量审批单号请求失败")
}
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 (c *ApprovalInfoClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error {
if strings.TrimSpace(message) == "" {
message = "企业微信批量审批单号接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批单号失败")
}
func normalizeSPNos(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@@ -0,0 +1,200 @@
package wecom
import (
"context"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
approvalquery "github.com/break/junhong_cmp_fiber/internal/query/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalApproverProjection 是企微审批节点成员到系统账号的可选映射。
type ApprovalApproverProjection struct {
WeComUserID string `json:"wecom_userid"`
AccountID *uint `json:"account_id,omitempty"`
AccountName string `json:"account_name"`
}
// ApprovalChannelProjection 是通用审批 Query 返回的企微本地只读扩展。
type ApprovalChannelProjection struct {
SPNo string `json:"sp_no"`
SPStatus int `json:"sp_status"`
Approvers []ApprovalApproverProjection `json:"approvers"`
}
// ApprovalProjectionResolver 从已同步详情快照批量投影审批人,不调用企业微信接口。
type ApprovalProjectionResolver struct {
db *gorm.DB
}
// NewApprovalProjectionResolver 创建企业微信审批读取投影 Resolver。
func NewApprovalProjectionResolver(db *gorm.DB) *ApprovalProjectionResolver {
return &ApprovalProjectionResolver{db: db}
}
// Resolve 按当前页审批实例批量读取快照,并批量映射同企业下的系统账号。
func (r *ApprovalProjectionResolver) Resolve(
ctx context.Context,
_ approvalquery.Viewer,
references []approvalquery.ExtensionReference,
) (map[uint]any, error) {
result := make(map[uint]any)
if len(references) == 0 {
return result, nil
}
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "企业微信审批读取投影未配置")
}
instanceIDs := weComExtensionInstanceIDs(references)
if len(instanceIDs) == 0 {
return result, nil
}
var contexts []model.WeComApprovalContext
if err := r.db.WithContext(ctx).Where("approval_instance_id IN ?", instanceIDs).Find(&contexts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信审批详情快照失败")
}
applicationIDs := make([]uint, 0, len(contexts))
for _, channelContext := range contexts {
applicationIDs = append(applicationIDs, channelContext.ApplicationID)
}
corpByApplication, err := r.loadApplicationCorps(ctx, applicationIDs)
if err != nil {
return nil, err
}
userIDsByCorp := make(map[string][]string)
approversByInstance := make(map[uint][]string, len(contexts))
for _, channelContext := range contexts {
userIDs := approvalNodeUserIDs(channelContext.LatestDetailSnapshot)
approversByInstance[channelContext.ApprovalInstanceID] = userIDs
corpID := corpByApplication[channelContext.ApplicationID]
userIDsByCorp[corpID] = append(userIDsByCorp[corpID], userIDs...)
}
accounts, err := r.loadBoundAccounts(ctx, userIDsByCorp)
if err != nil {
return nil, err
}
for _, channelContext := range contexts {
corpID := corpByApplication[channelContext.ApplicationID]
approvers := make([]ApprovalApproverProjection, 0, len(approversByInstance[channelContext.ApprovalInstanceID]))
for _, userID := range approversByInstance[channelContext.ApprovalInstanceID] {
item := ApprovalApproverProjection{WeComUserID: userID}
if account, exists := accounts[weComAccountKey(corpID, userID)]; exists {
accountID := account.ID
item.AccountID = &accountID
item.AccountName = account.Username
}
approvers = append(approvers, item)
}
result[channelContext.ApprovalInstanceID] = ApprovalChannelProjection{
SPNo: channelContext.SPNo, SPStatus: channelContext.LatestSPStatus, Approvers: approvers,
}
}
return result, nil
}
func weComExtensionInstanceIDs(references []approvalquery.ExtensionReference) []uint {
seen := make(map[uint]struct{}, len(references))
ids := make([]uint, 0, len(references))
for _, reference := range references {
if reference.InstanceID == 0 || reference.Provider != constants.IntegrationProviderWeCom {
continue
}
if _, exists := seen[reference.InstanceID]; exists {
continue
}
seen[reference.InstanceID] = struct{}{}
ids = append(ids, reference.InstanceID)
}
return ids
}
func (r *ApprovalProjectionResolver) loadApplicationCorps(ctx context.Context, applicationIDs []uint) (map[uint]string, error) {
result := make(map[uint]string)
if len(applicationIDs) == 0 {
return result, nil
}
var applications []model.WeComApplication
if err := r.db.WithContext(ctx).Select("id", "corp_id").Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信应用企业标识失败")
}
for _, application := range applications {
result[application.ID] = application.CorpID
}
return result, nil
}
func (r *ApprovalProjectionResolver) loadBoundAccounts(ctx context.Context, userIDsByCorp map[string][]string) (map[string]model.Account, error) {
result := make(map[string]model.Account)
query := r.db.WithContext(ctx).Model(&model.Account{}).Where("1 = 0")
hasCondition := false
for corpID, userIDs := range userIDsByCorp {
corpID = strings.TrimSpace(corpID)
userIDs = uniqueStrings(userIDs)
if corpID == "" || len(userIDs) == 0 {
continue
}
query = query.Or("wecom_corp_id = ? AND wecom_userid IN ? AND status = ?", corpID, userIDs, constants.StatusEnabled)
hasCondition = true
}
if !hasCondition {
return result, nil
}
var accounts []model.Account
if err := query.Select("id", "username", "wecom_corp_id", "wecom_userid").Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量映射企业微信审批人账号失败")
}
for _, account := range accounts {
result[weComAccountKey(account.WeComCorpID, account.WeComUserID)] = account
}
return result, nil
}
func approvalNodeUserIDs(snapshot []byte) []string {
var detail struct {
SPRecords []struct {
Details []struct {
Approver struct {
UserID string `json:"userid"`
} `json:"approver"`
} `json:"details"`
} `json:"sp_record"`
}
if sonic.Unmarshal(snapshot, &detail) != nil {
return []string{}
}
values := make([]string, 0)
for _, record := range detail.SPRecords {
for _, node := range record.Details {
values = append(values, node.Approver.UserID)
}
}
return uniqueStrings(values)
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func weComAccountKey(corpID, userID string) string {
return strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
}
var _ approvalquery.ChannelExtensionResolver = (*ApprovalProjectionResolver)(nil)

View File

@@ -0,0 +1,152 @@
package wecom
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalProvider 实现通用 Approval Port 的企微准备与渠道上下文写入。
type ApprovalProvider struct {
db *gorm.DB
scenes *SceneRepository
applications *ApplicationRepository
members *MemberRepository
templates wecomapp.TemplateProvider
}
type approvalPreparationContext struct {
ApplicationID uint `json:"application_id"`
BusinessType string `json:"business_type"`
TemplateID string `json:"template_id"`
CreatorUserID string `json:"creator_userid"`
CreatorName string `json:"creator_name"`
CreatorSource string `json:"creator_source"`
ControlMapping []map[string]any `json:"control_mapping"`
TemplateSnapshot wecomapp.TemplateDefinition `json:"template_snapshot"`
TemplateFingerprint string `json:"template_fingerprint"`
}
// NewApprovalProvider 创建企业微信通用审批渠道 Adapter。
func NewApprovalProvider(db *gorm.DB, scenes *SceneRepository, applications *ApplicationRepository, members *MemberRepository, templates wecomapp.TemplateProvider) *ApprovalProvider {
return &ApprovalProvider{db: db, scenes: scenes, applications: applications, members: members, templates: templates}
}
// Prepare 在业务事务前校验场景、模板和可用的企微发起人。
func (p *ApprovalProvider) Prepare(ctx context.Context, request approvalapp.PrepareRequest) (approvalapp.ProviderPreparation, error) {
if p == nil || p.db == nil || p.scenes == nil || p.applications == nil || p.members == nil || p.templates == nil {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "企业微信审批 Adapter 未配置")
}
scene, err := p.scenes.GetEnabled(ctx, strings.TrimSpace(request.BusinessType))
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
application, err := p.applications.GetEnabled(ctx, scene.ApplicationID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
creator, source, err := p.resolveCreator(ctx, application, request.SubmitterAccountID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
definition, err := p.templates.GetTemplateDetail(ctx, scene.ApplicationID, scene.TemplateID)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
fingerprint, err := templateFingerprint(definition)
if err != nil {
return approvalapp.ProviderPreparation{}, err
}
if fingerprint != scene.TemplateFingerprint {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板已变化,请管理员重新校验场景配置")
}
var mapping []map[string]any
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil || len(mapping) == 0 {
return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效")
}
channelContext, err := sonic.Marshal(approvalPreparationContext{
ApplicationID: scene.ApplicationID, BusinessType: scene.BusinessType, TemplateID: scene.TemplateID,
CreatorUserID: creator.UserID, CreatorName: creator.Name, CreatorSource: source,
ControlMapping: mapping, TemplateSnapshot: definition, TemplateFingerprint: fingerprint,
})
if err != nil {
return approvalapp.ProviderPreparation{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批准备结果失败")
}
return approvalapp.ProviderPreparation{Provider: constants.IntegrationProviderWeCom, ChannelContext: channelContext}, nil
}
// CreateContextInTx 在调用方事务中保存不含凭据的企微提交快照。
func (p *ApprovalProvider) CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation approvalapp.ProviderPreparation, instanceID uint) error {
if p == nil || tx == nil || preparation.Provider != constants.IntegrationProviderWeCom || instanceID == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信审批渠道上下文参数无效")
}
var prepared approvalPreparationContext
if err := sonic.Unmarshal(preparation.ChannelContext, &prepared); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "解析企业微信审批准备结果失败")
}
mappingJSON, err := sonic.Marshal(prepared.ControlMapping)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批控件映射失败")
}
templateJSON, err := sonic.Marshal(prepared.TemplateSnapshot)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批模板快照失败")
}
record := model.WeComApprovalContext{
ApprovalInstanceID: instanceID, ApplicationID: prepared.ApplicationID, BusinessType: prepared.BusinessType,
TemplateID: prepared.TemplateID, CreatorUserID: prepared.CreatorUserID, CreatorName: prepared.CreatorName,
CreatorSource: prepared.CreatorSource, ControlMapping: mappingJSON, TemplateSnapshot: templateJSON,
TemplateFingerprint: prepared.TemplateFingerprint, SubmissionStatus: constants.WeComSubmissionStatusReady,
}
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批渠道上下文失败")
}
return nil
}
// resolveCreator 保留真实业务提交人,同时按账号类型选择企微本人或应用默认成员作为接口发起人。
func (p *ApprovalProvider) resolveCreator(ctx context.Context, application *model.WeComApplication, accountID uint) (*model.WeComMember, string, error) {
var account model.Account
if err := p.db.WithContext(ctx).Select("id", "user_type", "wecom_corp_id", "wecom_userid").Where("id = ?", accountID).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询审批真实提交人失败")
}
useBoundCreator := account.UserType == constants.UserTypeSuperAdmin || account.UserType == constants.UserTypePlatform
if useBoundCreator && strings.EqualFold(account.WeComCorpID, application.CorpID) && strings.TrimSpace(account.WeComUserID) != "" {
member, err := p.members.GetVisible(ctx, application.ID, account.WeComUserID)
if err == nil {
return member, constants.WeComCreatorSourceBound, nil
}
}
if strings.TrimSpace(application.DefaultCreatorUserID) == "" {
return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信应用尚未配置默认审批发起人")
}
member, err := p.members.GetVisible(ctx, application.ID, application.DefaultCreatorUserID)
if err != nil {
return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信默认审批发起人已不可用,请管理员重新配置")
}
return member, constants.WeComCreatorSourceDefault, nil
}
func templateFingerprint(definition wecomapp.TemplateDefinition) (string, error) {
snapshot, err := sonic.Marshal(definition)
if err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
}
hash := sha256.Sum256(snapshot)
return hex.EncodeToString(hash[:]), nil
}
var _ approvalapp.ProviderPort = (*ApprovalProvider)(nil)

View File

@@ -0,0 +1,155 @@
package wecom
import (
"context"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// ApprovalRecoveryTaskHandler 周期恢复结果未知审批并轮询未终态详情。
type ApprovalRecoveryTaskHandler struct {
contexts *ApprovalContextRepository
infos *ApprovalInfoClient
queue *queue.Client
now func() time.Time
}
// NewApprovalRecoveryTaskHandler 创建企业微信审批主动恢复任务 Handler。
func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos *ApprovalInfoClient, queueClient *queue.Client) *ApprovalRecoveryTaskHandler {
return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now}
}
// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置")
}
now := h.now().UTC()
if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil {
return err
}
if err := h.enqueuePendingSync(ctx, now); err != nil {
return err
}
return h.recoverUnknown(ctx, now)
}
func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, now time.Time) error {
records, err := h.contexts.ListPendingSync(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalRecoveryBatchSize)
if err != nil {
return err
}
for _, record := range records {
if err := h.enqueueDetailSync(ctx, record.ApplicationID, record.SPNo); err != nil {
return err
}
}
return nil
}
func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error {
cutoff := now.Add(-constants.WeComApprovalPollingInterval)
records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize)
if err != nil {
return err
}
for _, record := range records {
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff)
if err != nil {
return err
}
if !claimed {
continue
}
spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
if err != nil {
return err
}
if spNo == "" {
spNo, err = h.findUniqueSPNo(ctx, record, now)
if err != nil {
return err
}
}
if spNo == "" {
continue
}
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo)
if err != nil {
return err
}
if recovered {
if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil {
return err
}
}
}
return nil
}
// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) {
startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow)
endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow)
if endTime.After(now) {
endTime = now
}
if !startTime.Before(endTime) {
return "", nil
}
cursor := ""
seenCursors := make(map[string]struct{})
seenCandidates := make(map[string]struct{})
unbound := make([]string, 0, 2)
for {
page, err := h.infos.List(ctx, ApprovalInfoQuery{
ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime,
TemplateID: record.TemplateID, CreatorUserID: record.CreatorUserID,
Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize,
})
if err != nil {
return "", err
}
existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos)
if err != nil {
return "", err
}
for _, candidate := range page.SPNos {
if _, seen := seenCandidates[candidate]; seen {
continue
}
seenCandidates[candidate] = struct{}{}
if _, exists := existing[candidate]; !exists {
unbound = append(unbound, candidate)
if len(unbound) > 1 {
return "", nil
}
}
}
next := strings.TrimSpace(page.NextCursor)
if next == "" {
break
}
if _, exists := seenCursors[next]; exists {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
}
seenCursors[next] = struct{}{}
cursor = next
}
if len(unbound) != 1 {
return "", nil
}
return unbound[0], nil
}
func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error {
return h.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{
ApplicationID: applicationID, SPNo: spNo, Source: constants.ApprovalSyncSourcePolling,
})
}

View File

@@ -0,0 +1,189 @@
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
}

View File

@@ -0,0 +1,100 @@
package wecom
import (
"context"
stdErrors "errors"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalSubmissionConsumer 消费通用审批提交 Outbox并保证结果未知时不盲目重提。
type ApprovalSubmissionConsumer struct {
repository *ApprovalContextRepository
client *ApprovalSubmissionClient
attachments approvalAttachmentPort
}
// NewApprovalSubmissionConsumer 创建企业微信审批提交消费者。
func NewApprovalSubmissionConsumer(repository *ApprovalContextRepository, client *ApprovalSubmissionClient, attachments approvalAttachmentPort) *ApprovalSubmissionConsumer {
return &ApprovalSubmissionConsumer{repository: repository, client: client, attachments: attachments}
}
// Consume 校验通用事件并提交一次企业微信审批申请。
func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.repository == nil || c.client == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批提交消费者未配置")
}
if envelope.PayloadVersion != constants.ApprovalSubmissionPayloadVersionV1 {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "不支持的审批提交事件版本"))
}
var event approvalapp.SubmissionRequestedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件载荷无效"))
}
if event.InstanceID == 0 || event.Provider != constants.IntegrationProviderWeCom {
return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件渠道或实例无效"))
}
record, claimed, err := c.repository.ClaimSubmission(ctx, event.InstanceID)
if err != nil {
return err
}
if !claimed {
return nil
}
form, err := buildApprovalForm(
ctx, record.Context.ApplicationID, event.InstanceID,
record.Context.ControlMapping, record.Context.TemplateSnapshot, record.Instance.RequestSnapshot, c.attachments,
)
if err != nil {
var attachmentErr *attachmentPreparationError
if stdErrors.As(err, &attachmentErr) {
if releaseErr := c.repository.ReleaseForRetry(ctx, event.InstanceID, "审批附件准备失败,等待重试"); releaseErr != nil {
return releaseErr
}
return err
}
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil {
return markErr
}
return nil
}
result, submitErr := c.client.Submit(ctx, ApprovalSubmitRequest{
InstanceID: event.InstanceID, ApplicationID: record.Context.ApplicationID,
TemplateID: record.Context.TemplateID, CreatorUserID: record.Context.CreatorUserID,
CorrelationID: event.CorrelationID, Contents: form.Contents,
})
switch result.Outcome {
case submissionOutcomeSuccess:
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil {
return err
}
case submissionOutcomeUnknown:
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil {
return err
}
case submissionOutcomeFailed:
if result.SafeToRetry {
if err := c.repository.ReleaseForRetry(ctx, event.InstanceID, result.Message); err != nil {
return err
}
if submitErr != nil {
return submitErr
}
return errors.New(errors.CodeServiceUnavailable, result.Message)
}
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil {
return err
}
default:
return errors.New(errors.CodeInternalError, "企业微信审批提交结果无效")
}
return submitErr
}
var _ outbox.EventConsumer = (*ApprovalSubmissionConsumer)(nil)

View File

@@ -0,0 +1,85 @@
package wecom
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"sort"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CallbackCrypto 实现企微回调要求的 SHA-1 签名校验和 AES-256-CBC 解密。
type CallbackCrypto struct {
token string
aesKey []byte
receiveID string
}
// NewCallbackCrypto 创建企业微信回调加密器。
func NewCallbackCrypto(token, encodingAESKey, receiveID string) (*CallbackCrypto, error) {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodingAESKey) + "=")
if err != nil || len(key) != 32 || strings.TrimSpace(token) == "" || strings.TrimSpace(receiveID) == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信回调 Token、EncodingAESKey 或 receiveid 无效")
}
return &CallbackCrypto{token: token, aesKey: key, receiveID: receiveID}, nil
}
// VerifyAndDecrypt 校验签名并解密企业微信回调密文。
func (c *CallbackCrypto) VerifyAndDecrypt(signature, timestamp, nonce, encrypted string) ([]byte, error) {
if c == nil || !c.validSignature(signature, timestamp, nonce, encrypted) {
return nil, errors.New(errors.CodeUnauthorized, "企业微信回调签名无效")
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调密文无效")
}
block, err := aes.NewCipher(c.aesKey)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "初始化企业微信回调解密器失败")
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, c.aesKey[:aes.BlockSize]).CryptBlocks(plaintext, ciphertext)
plaintext, err = removePKCS7Padding(plaintext)
if err != nil || len(plaintext) < 20 {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调填充无效")
}
messageLength := int(binary.BigEndian.Uint32(plaintext[16:20]))
if messageLength < 0 || 20+messageLength > len(plaintext) {
return nil, errors.New(errors.CodeInvalidParam, "企业微信回调消息长度无效")
}
message := plaintext[20 : 20+messageLength]
receiveID := plaintext[20+messageLength:]
if subtle.ConstantTimeCompare(receiveID, []byte(c.receiveID)) != 1 {
return nil, errors.New(errors.CodeUnauthorized, "企业微信回调 receiveid 不匹配")
}
return append([]byte(nil), message...), nil
}
func (c *CallbackCrypto) validSignature(signature, timestamp, nonce, encrypted string) bool {
parts := []string{c.token, timestamp, nonce, encrypted}
sort.Strings(parts)
hash := sha1.Sum([]byte(strings.Join(parts, "")))
expected := hex.EncodeToString(hash[:])
return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1
}
func removePKCS7Padding(value []byte) ([]byte, error) {
if len(value) == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
padding := int(value[len(value)-1])
if padding <= 0 || padding > 32 || padding > len(value) {
return nil, errors.New(errors.CodeInvalidParam)
}
if !bytes.Equal(value[len(value)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) {
return nil, errors.New(errors.CodeInvalidParam)
}
return value[:len(value)-padding], nil
}

View File

@@ -0,0 +1,124 @@
package wecom
import (
"context"
"encoding/xml"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
type callbackIntegrationLog interface {
RecordInbound(ctx context.Context, input integrationlog.InboundAttempt) (*model.IntegrationLog, bool, error)
}
type encryptedCallbackEnvelope struct {
Encrypt string `xml:"Encrypt"`
}
type approvalChangeEvent struct {
Event string `xml:"Event"`
SPNo string `xml:"ApprovalInfo>SpNoStr"`
SPStatus int `xml:"ApprovalInfo>SpStatus"`
TemplateID string `xml:"ApprovalInfo>TemplateId"`
StatusChangeType int `xml:"ApprovalInfo>StatuChangeEvent"`
}
// ApprovalDetailSyncTask 是回调快速响应后提交给 Worker 的结构化任务载荷。
type ApprovalDetailSyncTask struct {
ApplicationID uint `json:"application_id"`
SPNo string `json:"sp_no"`
Source string `json:"source"`
IntegrationID string `json:"integration_id"`
}
// CallbackService 校验解密企微回调、记录入站幂等事实并异步拉取详情。
type CallbackService struct {
applications *ApplicationRepository
cipher *CredentialCipher
integration callbackIntegrationLog
queue *queue.Client
}
// NewCallbackService 创建企业微信审批回调服务。
func NewCallbackService(applications *ApplicationRepository, cipher *CredentialCipher, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService {
return &CallbackService{applications: applications, cipher: cipher, integration: integration, queue: queueClient}
}
// VerifyURL 校验企微回调 URL 并返回 echostr 明文。
func (s *CallbackService) VerifyURL(ctx context.Context, applicationID uint, signature, timestamp, nonce, echo string) ([]byte, error) {
crypto, err := s.callbackCrypto(ctx, applicationID)
if err != nil {
return nil, err
}
return crypto.VerifyAndDecrypt(signature, timestamp, nonce, echo)
}
// Receive 校验并解密审批事件,持久化入站事实后用 struct 载荷提交详情同步任务。
func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signature, timestamp, nonce string, body []byte) error {
if s == nil || s.integration == nil || s.queue == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置")
}
var envelope encryptedCallbackEnvelope
if err := xml.Unmarshal(body, &envelope); err != nil || strings.TrimSpace(envelope.Encrypt) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信回调 XML 无效")
}
crypto, err := s.callbackCrypto(ctx, applicationID)
if err != nil {
return err
}
plaintext, err := crypto.VerifyAndDecrypt(signature, timestamp, nonce, envelope.Encrypt)
if err != nil {
return err
}
var event approvalChangeEvent
if err := xml.Unmarshal(plaintext, &event); err != nil || event.Event != "sys_approval_change" || strings.TrimSpace(event.SPNo) == "" {
return errors.New(errors.CodeInvalidParam, "企业微信审批回调事件无效")
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
log, created, err := s.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: applicationCallbackIdempotencyKey(applicationID, signature),
Provider: constants.IntegrationProviderWeCom, Operation: constants.IntegrationOperationWeComApprovalCallback,
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID,
RawPayload: body, ContentType: "application/xml",
})
if err != nil {
return err
}
if !created && log.Result != constants.IntegrationResultPending {
return nil
}
return s.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{
ApplicationID: applicationID, SPNo: event.SPNo, Source: constants.ApprovalSyncSourceCallback,
IntegrationID: log.IntegrationID,
})
}
// callbackCrypto 每次从数据库密文解出当前回调凭据,使凭据轮换无需重启进程。
func (s *CallbackService) callbackCrypto(ctx context.Context, applicationID uint) (*CallbackCrypto, error) {
if s == nil || s.applications == nil || s.cipher == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置")
}
application, err := s.applications.GetEnabled(ctx, applicationID)
if err != nil {
return nil, err
}
token, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
if err != nil {
return nil, err
}
aesKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
if err != nil {
return nil, err
}
return NewCallbackCrypto(token, aesKey, application.CorpID)
}
func applicationCallbackIdempotencyKey(applicationID uint, signature string) string {
return "wecom-approval:" + strconv.FormatUint(uint64(applicationID), 10) + ":" + strings.ToLower(strings.TrimSpace(signature))
}

View File

@@ -0,0 +1,61 @@
// Package wecom 提供企业微信外部系统 Adapter。
package wecom
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
var credentialAAD = []byte("junhong-wecom-credential-v1")
// CredentialCipher 使用 AES-256-GCM 加解密企业微信敏感凭据。
type CredentialCipher struct {
aead cipher.AEAD
}
// NewCredentialCipher 从 32 字节 Base64 密钥创建凭据加密器。
func NewCredentialCipher(encodedKey string) (*CredentialCipher, error) {
key, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil || len(key) != 32 {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信凭据加密密钥必须是 32 字节 Base64")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密器失败")
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密模式失败")
}
return &CredentialCipher{aead: aead}, nil
}
// Encrypt 加密单个敏感凭据,返回 nonce 与密文组合。
func (c *CredentialCipher) Encrypt(plaintext string) ([]byte, error) {
if c == nil || c.aead == nil || plaintext == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid)
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "生成企业微信凭据随机数失败")
}
return c.aead.Seal(nonce, nonce, []byte(plaintext), credentialAAD), nil
}
// Decrypt 解密单个企业微信敏感凭据。
func (c *CredentialCipher) Decrypt(ciphertext []byte) (string, error) {
if c == nil || c.aead == nil || len(ciphertext) <= c.aead.NonceSize() {
return "", errors.New(errors.CodeWeComCredentialInvalid)
}
nonce := ciphertext[:c.aead.NonceSize()]
plaintext, err := c.aead.Open(nil, nonce, ciphertext[c.aead.NonceSize():], credentialAAD)
if err != nil {
return "", errors.Wrap(errors.CodeWeComCredentialInvalid, err, "解密企业微信凭据失败")
}
return string(plaintext), nil
}

View File

@@ -0,0 +1,185 @@
package wecom
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// DirectoryTokenProvider 定义通讯录客户端取得应用 access_token 的边界。
type DirectoryTokenProvider interface {
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
}
// DirectoryClient 拉取企业微信应用可见成员,不读取手机号或邮箱。
type DirectoryClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewDirectoryClient 创建企业微信通讯录客户端。
func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *DirectoryClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &DirectoryClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// ListVisibleMembers 拉取根部门及其子部门中当前应用可见的成员。
func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID uint) ([]wecomapp.DirectoryMember, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return nil, err
}
request, err := c.newListRequest(ctx, token)
if err != nil {
return nil, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "department_id": constants.WeComRootDepartmentID, "fetch_child": true,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return nil, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt)
}
defer response.Body.Close()
result, err := c.readResponse(response)
if err != nil {
return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 {
providerCode := strconv.FormatInt(result.ErrCode, 10)
if result.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt)
}
members := normalizeRemoteMembers(result.UserList)
if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result, len(members), startedAt); err != nil {
return nil, err
}
return members, nil
}
func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/user/simplelist")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
query.Set("department_id", strconv.FormatInt(constants.WeComRootDepartmentID, 10))
query.Set("fetch_child", "1")
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信通讯录请求失败")
}
return request, nil
}
type directoryResponse struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
UserList []directoryMember `json:"userlist"`
}
type directoryMember struct {
UserID string `json:"userid"`
Name string `json:"name"`
Department []int64 `json:"department"`
}
func (c *DirectoryClient) readResponse(response *http.Response) (directoryResponse, error) {
var result directoryResponse
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败")
}
if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大")
}
if err := sonic.Unmarshal(body, &result); err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败")
}
return result, nil
}
func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember {
seen := make(map[string]struct{}, len(source))
result := make([]wecomapp.DirectoryMember, 0, len(source))
for _, member := range source {
userID := strings.ToLower(strings.TrimSpace(member.UserID))
if userID == "" {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
name := strings.TrimSpace(member.Name)
if name == "" {
name = userID
}
result = append(result, wecomapp.DirectoryMember{UserID: userID, Name: name, DepartmentIDs: member.Department})
}
return result
}
func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, result directoryResponse, memberCount int, startedAt time.Time) error {
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: status,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return err
}
func (c *DirectoryClient) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信通讯录接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录同步失败,请检查应用可见范围")
}
var _ TokenIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -0,0 +1,89 @@
package wecom
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// MemberRepository 持久化企业微信应用可见成员快照。
type MemberRepository struct {
db *gorm.DB
}
// NewMemberRepository 创建企业微信成员快照 Repository。
func NewMemberRepository(db *gorm.DB) *MemberRepository {
return &MemberRepository{db: db}
}
// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。
func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || r.db == nil {
return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
return err
}
if len(members) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
DoUpdates: clause.Assignments(map[string]any{
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
}),
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
return nil
}
// ListVisible 分页查询指定应用当前可见成员。
func (r *MemberRepository) ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComMember{}).
Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled).
Where("tb_wecom_member.application_id = ? AND tb_wecom_member.visible = ?", applicationID, true)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
query = query.Where("tb_wecom_member.name ILIKE ? OR tb_wecom_member.userid ILIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信可见成员失败")
}
var members []model.WeComMember
if err := query.Select("tb_wecom_member.*").Order("tb_wecom_member.name ASC, tb_wecom_member.userid ASC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败")
}
return members, total, nil
}
// GetVisible 查询可用于账号绑定的应用可见成员。
func (r *MemberRepository) GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) {
var member model.WeComMember
if err := r.db.WithContext(ctx).Model(&model.WeComMember{}).
Select("tb_wecom_member.*").
Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled).
Where("tb_wecom_member.application_id = ? AND tb_wecom_member.userid = ? AND tb_wecom_member.visible = ?", applicationID, strings.ToLower(strings.TrimSpace(userID)), true).
First(&member).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "所选成员不在企业微信应用可见范围内")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败")
}
return &member, nil
}

View File

@@ -0,0 +1,83 @@
package wecom
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SceneRepository 持久化企业微信审批业务场景当前配置。
type SceneRepository struct {
db *gorm.DB
}
// NewSceneRepository 创建企业微信审批场景 Repository。
func NewSceneRepository(db *gorm.DB) *SceneRepository {
return &SceneRepository{db: db}
}
// FindForUpdate 在事务内锁定指定业务场景。
func (r *SceneRepository) FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error) {
var scene model.WeComApprovalScene
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_type = ?", businessType).First(&scene).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return &scene, nil
}
// Create 创建企业微信审批场景配置。
func (r *SceneRepository) Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error {
if err := tx.WithContext(ctx).Create(scene).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批场景失败")
}
return nil
}
// Update 更新企业微信审批场景当前配置。
func (r *SceneRepository) Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error {
if err := tx.WithContext(ctx).Model(&model.WeComApprovalScene{}).Where("id = ?", scene.ID).Updates(map[string]any{
"application_id": scene.ApplicationID, "template_id": scene.TemplateID, "template_name": scene.TemplateName,
"control_mapping": scene.ControlMapping, "template_snapshot": scene.TemplateSnapshot,
"template_fingerprint": scene.TemplateFingerprint,
"status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
"updated_by": scene.UpdatedBy, "updated_at": scene.UpdatedAt,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批场景失败")
}
return nil
}
// List 分页查询企业微信审批场景配置。
func (r *SceneRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error) {
query := r.db.WithContext(ctx).Model(&model.WeComApprovalScene{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信审批场景失败")
}
var scenes []model.WeComApprovalScene
if err := query.Order("business_type ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&scenes).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return scenes, total, nil
}
// GetEnabled 查询指定业务类型当前启用的企微审批场景。
func (r *SceneRepository) GetEnabled(ctx context.Context, businessType string) (*model.WeComApprovalScene, error) {
var scene model.WeComApprovalScene
if err := r.db.WithContext(ctx).Where("business_type = ? AND status = ?", businessType, constants.StatusEnabled).First(&scene).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景未配置或已禁用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败")
}
return &scene, nil
}

View File

@@ -0,0 +1,240 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TemplateClient 读取企微后台已有审批模板的控件结构。
type TemplateClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewTemplateClient 创建企业微信审批模板详情客户端。
func NewTemplateClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *TemplateClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &TemplateClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// GetTemplateDetail 获取模板详情并只投影控件结构,不保存审批节点和审批人规则。
func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (wecomapp.TemplateDefinition, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return wecomapp.TemplateDefinition{}, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
request, err := c.newRequest(ctx, token, templateID)
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "template_id": templateID,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信模板详情请求失败", startedAt)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信模板详情响应无效", startedAt)
}
definition, errCode, errMsg, err := parseTemplateDefinition(templateID, body)
if err != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 {
providerCode := strconv.FormatInt(errCode, 10)
if errCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, providerCode, errMsg, startedAt)
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"errcode": errCode, "control_count": len(definition.Controls)},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return wecomapp.TemplateDefinition{}, err
}
return definition, nil
}
func (c *TemplateClient) newRequest(ctx context.Context, token, templateID string) (*http.Request, error) {
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/gettemplatedetail")
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()
body, err := sonic.Marshal(map[string]string{"template_id": templateID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板详情请求失败")
}
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 parseTemplateDefinition(templateID string, body []byte) (wecomapp.TemplateDefinition, int64, string, error) {
var payload map[string]any
if err := sonic.Unmarshal(body, &payload); err != nil {
return wecomapp.TemplateDefinition{}, 0, "", err
}
errCode := numberToInt64(payload["errcode"])
errMsg, _ := payload["errmsg"].(string)
if errCode != 0 {
return wecomapp.TemplateDefinition{}, errCode, errMsg, nil
}
content, ok := payload["template_content"].(map[string]any)
if !ok {
return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeServiceUnavailable, "企业微信模板详情缺少模板内容")
}
controls := make([]wecomapp.TemplateControl, 0)
seen := make(map[string]struct{})
collectTemplateControls(content["controls"], &controls, seen)
if len(controls) == 0 {
return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeInvalidParam, "企业微信模板没有可映射控件")
}
name := localizedText(content["template_name"])
if name == "" {
name = localizedText(content["template_names"])
}
return wecomapp.TemplateDefinition{
TemplateID: templateID, Name: name, Controls: controls,
}, errCode, errMsg, nil
}
func collectTemplateControls(value any, result *[]wecomapp.TemplateControl, seen map[string]struct{}) {
switch typed := value.(type) {
case []any:
for _, item := range typed {
collectTemplateControls(item, result, seen)
}
case map[string]any:
if property, ok := typed["property"].(map[string]any); ok {
id, _ := property["id"].(string)
controlType, _ := property["control"].(string)
if id != "" && controlType != "" {
if _, exists := seen[id]; !exists {
seen[id] = struct{}{}
*result = append(*result, wecomapp.TemplateControl{
ID: id, Type: controlType, Title: localizedText(property["title"]),
Required: numberToInt64(property["require"]) == 1, OptionKeys: collectOptionKeys(typed),
})
}
}
}
for _, item := range typed {
collectTemplateControls(item, result, seen)
}
}
}
func collectOptionKeys(value any) []string {
keys := make([]string, 0)
seen := make(map[string]struct{})
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case []any:
for _, item := range typed {
walk(item)
}
case map[string]any:
if key, ok := typed["key"].(string); ok && key != "" {
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
keys = append(keys, key)
}
}
for _, item := range typed {
walk(item)
}
}
}
walk(value)
return keys
}
func localizedText(value any) string {
if text, ok := value.(string); ok {
return text
}
if items, ok := value.([]any); ok {
for _, item := range items {
if translated, ok := item.(map[string]any); ok {
if text, ok := translated["text"].(string); ok && text != "" {
return text
}
}
}
}
return ""
}
func numberToInt64(value any) int64 {
switch number := value.(type) {
case float64:
return int64(number)
case int64:
return number
case int:
return int64(number)
default:
return 0
}
}
func (c *TemplateClient) completeTemplateFailure(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信模板详情接口返回失败"
}
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeInvalidParam, "企业微信审批模板无效或不可访问")
}

View File

@@ -0,0 +1,296 @@
package wecom
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
const releaseTokenLockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`
// TokenApplicationRepository 定义 access_token Provider 所需的应用配置查询边界。
type TokenApplicationRepository interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error
}
// TokenCredentialCipher 定义 access_token Provider 所需的凭据解密边界。
type TokenCredentialCipher interface {
Decrypt(ciphertext []byte) (string, error)
}
// TokenIntegrationLog 定义企微外呼的 Integration Log 边界。
type TokenIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
}
// TokenProvider 按应用缓存企业微信 access_token并用 Redis 短锁抑制并发回源。
type TokenProvider struct {
repo TokenApplicationRepository
cipher TokenCredentialCipher
redis *redis.Client
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
logger *zap.Logger
now func() time.Time
}
// NewTokenProvider 创建企业微信 access_token Provider。
func NewTokenProvider(
repo TokenApplicationRepository,
cipher TokenCredentialCipher,
redisClient *redis.Client,
integration TokenIntegrationLog,
baseURL string,
timeout time.Duration,
logger *zap.Logger,
) *TokenProvider {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &TokenProvider{
repo: repo, cipher: cipher, redis: redisClient, integration: integration,
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"),
logger: logger, now: time.Now,
}
}
// GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。
func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) {
if p == nil || p.repo == nil || p.cipher == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 {
return "", errors.New(errors.CodeWeComCredentialInvalid)
}
cacheKey := constants.RedisWeComAccessTokenKey(applicationID)
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
return token, nil
} else if err != nil {
p.warnRedis("读取企业微信 access_token 缓存失败,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if p.redis == nil {
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
lockKey := constants.RedisWeComAccessTokenLockKey(applicationID)
lockValue := uuid.NewString()
acquired, err := p.redis.SetNX(ctx, lockKey, lockValue, constants.WeComTokenLockTTL).Result()
if err != nil {
p.warnRedis("获取企业微信 access_token 回源锁失败,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if !acquired {
return p.waitForToken(ctx, applicationID, cacheKey)
}
defer p.releaseLock(ctx, lockKey, lockValue, applicationID)
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
return token, nil
}
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
// Invalidate 删除指定应用的 access_token 缓存。
func (p *TokenProvider) Invalidate(ctx context.Context, applicationID uint) {
if p == nil || p.redis == nil || applicationID == 0 {
return
}
if err := p.redis.Del(ctx, constants.RedisWeComAccessTokenKey(applicationID)).Err(); err != nil {
p.warnRedis("删除企业微信 access_token 缓存失败", err, applicationID)
}
}
func (p *TokenProvider) getCachedToken(ctx context.Context, cacheKey string) (string, bool, error) {
if p.redis == nil {
return "", false, nil
}
token, err := p.redis.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, err
}
return token, token != "", nil
}
func (p *TokenProvider) waitForToken(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
timer := time.NewTimer(constants.WeComTokenWaitTimeout)
defer timer.Stop()
ticker := time.NewTicker(constants.WeComTokenWaitPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", errors.Wrap(errors.CodeServiceUnavailable, ctx.Err(), "等待企业微信 access_token 已取消")
case <-timer.C:
return "", errors.New(errors.CodeServiceUnavailable, "企业微信 access_token 正在刷新,请稍后重试")
case <-ticker.C:
token, found, err := p.getCachedToken(ctx, cacheKey)
if err != nil {
p.warnRedis("等待企业微信 access_token 时 Redis 不可用,改为受控直连", err, applicationID)
return p.fetchAndCache(ctx, applicationID, cacheKey)
}
if found {
return token, nil
}
}
}
}
func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
application, err := p.repo.GetEnabled(ctx, applicationID)
if err != nil {
return "", err
}
secret, err := p.cipher.Decrypt(application.SecretCiphertext)
if err != nil {
return "", err
}
request, err := p.newTokenRequest(ctx, application.CorpID, secret)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return "", err
}
startedAt := p.now()
response, err := p.httpClient.Do(request)
if err != nil {
return "", p.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信 Token 请求失败", startedAt)
}
defer response.Body.Close()
tokenResponse, err := p.readTokenResponse(response)
if err != nil {
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信 Token 响应无效", startedAt)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || tokenResponse.ErrCode != 0 || tokenResponse.AccessToken == "" {
providerCode := strconv.FormatInt(tokenResponse.ErrCode, 10)
if tokenResponse.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, tokenResponse.ErrMsg, startedAt)
}
ttl := time.Duration(tokenResponse.ExpiresIn)*time.Second - constants.WeComTokenRefreshAdvance
if ttl <= 0 {
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_expires_in", "企业微信 access_token 有效期无效", startedAt)
}
if err := p.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, tokenResponse, startedAt); err != nil {
return "", err
}
if p.redis != nil {
if err := p.redis.Set(ctx, cacheKey, tokenResponse.AccessToken, ttl).Err(); err != nil {
p.warnRedis("缓存企业微信 access_token 失败,本次继续使用直连结果", err, applicationID)
}
}
if err := p.repo.MarkConnected(ctx, applicationID, p.now()); err != nil && p.logger != nil {
p.logger.Error("记录企业微信最近连接时间失败,本次 token 仍可使用", zap.Uint("application_id", applicationID), zap.Error(err))
}
return tokenResponse.AccessToken, nil
}
func (p *TokenProvider) newTokenRequest(ctx context.Context, corpID, secret string) (*http.Request, error) {
endpoint, err := url.Parse(p.baseURL + "/cgi-bin/gettoken")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("corpid", corpID)
query.Set("corpsecret", secret)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信 Token 请求失败")
}
return request, nil
}
type tokenResponse struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
func (p *TokenProvider) readTokenResponse(response *http.Response) (tokenResponse, error) {
var result tokenResponse
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信 Token 响应失败")
}
if int64(len(body)) > constants.WeComMaxResponseBodyBytes {
return result, errors.New(errors.CodeServiceUnavailable, "企业微信 Token 响应过大")
}
if err := sonic.Unmarshal(body, &result); err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信 Token 响应失败")
}
return result, nil
}
func (p *TokenProvider) completeSuccess(ctx context.Context, integrationID string, status int, result tokenResponse, startedAt time.Time) error {
_, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: status,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"errcode": result.ErrCode, "expires_in": result.ExpiresIn, "token_present": result.AccessToken != ""},
DurationMS: p.now().Sub(startedAt).Milliseconds(),
})
return err
}
func (p *TokenProvider) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
if providerMessage == "" {
providerMessage = "企业微信 Token 接口返回失败"
}
_, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
DurationMS: p.now().Sub(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return errors.New(errors.CodeServiceUnavailable, "企业微信连接失败,请检查应用配置和可信 IP")
}
func (p *TokenProvider) releaseLock(ctx context.Context, lockKey, lockValue string, applicationID uint) {
if p.redis == nil {
return
}
if err := p.redis.Eval(ctx, releaseTokenLockScript, []string{lockKey}, lockValue).Err(); err != nil {
p.warnRedis("释放企业微信 access_token 回源锁失败", err, applicationID)
}
}
func (p *TokenProvider) warnRedis(message string, err error, applicationID uint) {
if p.logger != nil {
p.logger.Warn(message, zap.Uint("application_id", applicationID), zap.Error(err))
}
}