七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
49
internal/infrastructure/exchange/shipping_notification.go
Normal file
49
internal/infrastructure/exchange/shipping_notification.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Package exchange 提供换货业务对公共基础设施的 Adapter。
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ShippingNotificationWriter 将物流换货创建事实转换为个人客户通知 Outbox。
|
||||
type ShippingNotificationWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewShippingNotificationWriter 创建物流换货通知 Outbox Adapter。
|
||||
func NewShippingNotificationWriter(repository *outbox.Repository) *ShippingNotificationWriter {
|
||||
return &ShippingNotificationWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在换货业务事务中追加稳定且可重复提交的个人客户通知事件。
|
||||
func (w *ShippingNotificationWriter) Append(ctx context.Context, tx *gorm.DB, event exchangeapp.ShippingCreatedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "物流换货通知 Outbox Writer 未配置")
|
||||
}
|
||||
exchangeID := strconv.FormatUint(uint64(event.ExchangeID), 10)
|
||||
assetID := strconv.FormatUint(uint64(event.AssetID), 10)
|
||||
eventID := fmt.Sprintf("exchange-created:%d:%d", event.ExchangeID, event.CustomerID)
|
||||
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "exchange", AggregateID: exchangeID,
|
||||
ResourceType: event.AssetType, ResourceID: assetID,
|
||||
BusinessKey: eventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: notificationapp.PersonalCustomerDirectPayload{
|
||||
RecipientID: event.CustomerID, NotificationType: constants.NotificationTypeExchangeShippingCreated,
|
||||
TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: assetID, RefKey: event.AssetIdentifier,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
// DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。
|
||||
@@ -63,11 +62,16 @@ func Permanent(err error) error {
|
||||
|
||||
// QueuePublisher 使用项目统一队列客户端发布结构化信封。
|
||||
type QueuePublisher struct {
|
||||
client *queue.Client
|
||||
client TaskEnqueuer
|
||||
}
|
||||
|
||||
// TaskEnqueuer 定义 Outbox Relay 所需的最小队列提交能力,避免基础设施包反向依赖队列处理器装配。
|
||||
type TaskEnqueuer interface {
|
||||
EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error
|
||||
}
|
||||
|
||||
// NewQueuePublisher 创建项目统一队列客户端 Adapter。
|
||||
func NewQueuePublisher(client *queue.Client) *QueuePublisher {
|
||||
func NewQueuePublisher(client TaskEnqueuer) *QueuePublisher {
|
||||
return &QueuePublisher{client: client}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,28 @@ func NewRegistry() *Registry {
|
||||
constants.NotificationRefTypeAsset: {},
|
||||
},
|
||||
},
|
||||
constants.NotificationTypeExchangeShippingCreated: {
|
||||
Type: constants.NotificationTypeExchangeShippingCreated, Category: constants.NotificationCategorySystem,
|
||||
Severity: constants.NotificationSeverityInfo,
|
||||
TitleTemplate: "换货申请待处理",
|
||||
BodyTemplate: "您有一条物流换货申请待处理,请及时填写收货信息。",
|
||||
TemplateFields: map[string]struct{}{},
|
||||
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
|
||||
AllowedRefTypes: map[string]struct{}{
|
||||
constants.NotificationRefTypeAsset: {},
|
||||
},
|
||||
},
|
||||
constants.NotificationTypeAgentMainWalletLowBalance: {
|
||||
Type: constants.NotificationTypeAgentMainWalletLowBalance, Category: constants.NotificationCategorySystem,
|
||||
Severity: constants.NotificationSeverityWarning,
|
||||
TitleTemplate: "店铺主钱包余额不足",
|
||||
BodyTemplate: "店铺主钱包余额低于 100 元,请及时关注。",
|
||||
TemplateFields: map[string]struct{}{},
|
||||
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
|
||||
AllowedRefTypes: map[string]struct{}{
|
||||
constants.NotificationRefTypeShopFund: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
141
internal/infrastructure/packageexpiry/reminder_publisher.go
Normal file
141
internal/infrastructure/packageexpiry/reminder_publisher.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Package packageexpiry 提供套餐临期提醒的 PostgreSQL 与 Outbox Adapter。
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"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"
|
||||
)
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。
|
||||
type ReminderPublisher struct {
|
||||
db *gorm.DB
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewReminderPublisher 创建套餐临期通知 Outbox Adapter。
|
||||
func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderPublisher {
|
||||
return &ReminderPublisher{db: db, outbox: repository}
|
||||
}
|
||||
|
||||
type recipientRow struct {
|
||||
AssetID uint
|
||||
CustomerID uint
|
||||
}
|
||||
|
||||
// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。
|
||||
func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error {
|
||||
if p == nil || p.db == nil || p.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置")
|
||||
}
|
||||
recipients, err := p.loadRecipients(ctx, candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
key := recipientKey(candidate.AssetType, candidate.AssetID)
|
||||
for customerID := range recipients[key] {
|
||||
if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) {
|
||||
cardIDs := make([]uint, 0)
|
||||
deviceIDs := make([]uint, 0)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.AssetType == constants.AssetTypeIotCard {
|
||||
cardIDs = append(cardIDs, candidate.AssetID)
|
||||
} else if candidate.AssetType == constants.AssetTypeDevice {
|
||||
deviceIDs = append(deviceIDs, candidate.AssetID)
|
||||
}
|
||||
}
|
||||
result := make(map[string]map[uint]struct{})
|
||||
queries := []struct {
|
||||
assetType string
|
||||
ids []uint
|
||||
sql string
|
||||
}{
|
||||
{constants.AssetTypeIotCard, cardIDs, `
|
||||
SELECT c.id AS asset_id, b.customer_id
|
||||
FROM tb_iot_card c
|
||||
JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no
|
||||
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
||||
{constants.AssetTypeIotCard, cardIDs, `
|
||||
SELECT c.id AS asset_id, b.customer_id
|
||||
FROM tb_iot_card c
|
||||
JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19))
|
||||
WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
||||
{constants.AssetTypeDevice, deviceIDs, `
|
||||
SELECT d.id AS asset_id, b.customer_id
|
||||
FROM tb_device d
|
||||
JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END
|
||||
WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`},
|
||||
}
|
||||
for _, query := range queries {
|
||||
if len(query.ids) == 0 {
|
||||
continue
|
||||
}
|
||||
var rows []recipientRow
|
||||
if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
key := recipientKey(query.assetType, row.AssetID)
|
||||
if result[key] == nil {
|
||||
result[key] = make(map[uint]struct{})
|
||||
}
|
||||
result[key][row.CustomerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID uint) error {
|
||||
node := *candidate.DaysUntilFinalExpiry
|
||||
expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102")
|
||||
assetCode := "c"
|
||||
if candidate.AssetType == constants.AssetTypeDevice {
|
||||
assetCode = "d"
|
||||
}
|
||||
eventID := fmt.Sprintf("pex:%s:%d:%s:%d:%d", assetCode, candidate.AssetID, expiryDate, node, customerID)
|
||||
assetID := strconv.FormatUint(uint64(candidate.AssetID), 10)
|
||||
_, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10),
|
||||
ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID,
|
||||
Payload: notificationapp.PersonalCustomerDirectPayload{
|
||||
RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring,
|
||||
TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recipientKey(assetType string, assetID uint) string {
|
||||
return fmt.Sprintf("%s:%d", assetType, assetID)
|
||||
}
|
||||
@@ -96,6 +96,44 @@ func (r *Reader) Get(ctx context.Context, key string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// GetStrict 严格读取单个已注册配置;已落库的非法值或数据库读取失败不会回退默认值。
|
||||
// 该入口用于支付方式等必须失败关闭的关键配置,未落库时仍返回代码注册的首次初始化默认值。
|
||||
func (r *Reader) GetStrict(ctx context.Context, key string) (string, error) {
|
||||
definition, registered := r.registry.Get(key)
|
||||
if !registered {
|
||||
return "", stderrors.New("系统配置 Key 未注册")
|
||||
}
|
||||
cacheKey := constants.RedisSystemConfigKey(key)
|
||||
if r.cache != nil {
|
||||
if value, err := r.cache.Get(ctx, cacheKey); err == nil {
|
||||
if ValidateValue(definition, value) == nil {
|
||||
return value, nil
|
||||
}
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_INVALID", key, "关键系统配置缓存值非法,已回源 PostgreSQL")
|
||||
} else if err != redis.Nil {
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_READ_FAILED", key, "关键系统配置缓存读取失败,已回源 PostgreSQL")
|
||||
}
|
||||
}
|
||||
var record model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("config_key = ?", key).First(&record).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return definition.DefaultValue, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ValidateValue(definition, record.ConfigValue) != nil {
|
||||
r.warn(ctx, "SYSTEM_CONFIG_DATABASE_VALUE_INVALID", key, "关键系统配置数据库值非法,已失败关闭")
|
||||
return "", stderrors.New("系统配置数据库值非法")
|
||||
}
|
||||
if r.cache != nil {
|
||||
if err := r.cache.Set(ctx, cacheKey, record.ConfigValue, constants.SystemConfigCacheTTL); err != nil {
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_WRITE_FAILED", key, "关键系统配置缓存回填失败")
|
||||
}
|
||||
}
|
||||
return record.ConfigValue, nil
|
||||
}
|
||||
|
||||
// ListItem 是查询层组装 DTO 所需的稳定投影。
|
||||
type ListItem struct {
|
||||
Definition Definition
|
||||
|
||||
@@ -28,6 +28,7 @@ type Definition struct {
|
||||
EnumValues []string
|
||||
Min *int64
|
||||
Max *int64
|
||||
Validator func(string) error
|
||||
}
|
||||
|
||||
// Registry 保存可写配置的代码权威定义。
|
||||
@@ -127,12 +128,19 @@ func ValidateValue(definition Definition, value string) error {
|
||||
return stderrors.New("系统配置类型不受支持")
|
||||
}
|
||||
if len(definition.EnumValues) > 0 {
|
||||
matched := false
|
||||
for _, allowed := range definition.EnumValues {
|
||||
if value == allowed {
|
||||
return nil
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return stderrors.New("系统配置值不在允许枚举中")
|
||||
if !matched {
|
||||
return stderrors.New("系统配置值不在允许枚举中")
|
||||
}
|
||||
}
|
||||
if definition.Validator != nil {
|
||||
return definition.Validator(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -16,17 +18,18 @@ import (
|
||||
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
|
||||
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
|
||||
type DebitEventConsumer struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
|
||||
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
|
||||
return &DebitEventConsumer{db: db}
|
||||
return &DebitEventConsumer{db: db, outbox: outbox.NewRepository()}
|
||||
}
|
||||
|
||||
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
|
||||
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
if c == nil || c.db == nil || c.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
|
||||
@@ -37,14 +40,21 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 ||
|
||||
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 || event.ReferenceID == 0 ||
|
||||
event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct ||
|
||||
event.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整")
|
||||
}
|
||||
|
||||
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return c.consumeInTx(ctx, tx, event)
|
||||
})
|
||||
}
|
||||
|
||||
// consumeInTx 复核权威扣款流水,并在余额首次跨过固定阈值时追加业务员通知事件。
|
||||
func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := c.db.WithContext(ctx).Unscoped().
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
@@ -58,5 +68,57 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive
|
||||
transaction.BalanceAfter != event.BalanceAfter {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
|
||||
}
|
||||
if event.BalanceBefore < constants.AgentMainWalletLowBalanceThreshold ||
|
||||
event.BalanceAfter >= constants.AgentMainWalletLowBalanceThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
recipientID, err := resolveBusinessOwnerAccountID(ctx, tx, event.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recipientID == 0 {
|
||||
return nil
|
||||
}
|
||||
shopID := strconv.FormatUint(uint64(event.ShopID), 10)
|
||||
notificationEventID := "wallet-low:order:" + strconv.FormatUint(uint64(event.ReferenceID), 10)
|
||||
_, err = c.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: constants.NotificationRefTypeShopFund, ResourceID: shopID,
|
||||
BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: notificationapp.AdminDirectPayload{
|
||||
RecipientID: recipientID, NotificationType: constants.NotificationTypeAgentMainWalletLowBalance,
|
||||
RefType: constants.NotificationRefTypeShopFund, RefID: shopID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入主钱包低余额通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBusinessOwnerAccountID 返回店铺当前启用的平台业务员账号;无有效归属时返回零值。
|
||||
func resolveBusinessOwnerAccountID(ctx context.Context, tx *gorm.DB, shopID uint) (uint, error) {
|
||||
var shop model.Shop
|
||||
err := tx.WithContext(ctx).Select("business_owner_account_id").Where("id = ?", shopID).Take(&shop).Error
|
||||
if err == gorm.ErrRecordNotFound || shop.BusinessOwnerAccountID == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员归属失败")
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
err = tx.WithContext(ctx).Select("id").
|
||||
Where("id = ? AND status = ? AND user_type = ?", *shop.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform).
|
||||
Take(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺业务员账号失败")
|
||||
}
|
||||
return account.ID, nil
|
||||
}
|
||||
|
||||
109
internal/infrastructure/wecom/application_repository.go
Normal file
109
internal/infrastructure/wecom/application_repository.go
Normal 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
|
||||
}
|
||||
166
internal/infrastructure/wecom/approval_attachment_uploader.go
Normal file
166
internal/infrastructure/wecom/approval_attachment_uploader.go
Normal 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
|
||||
}
|
||||
395
internal/infrastructure/wecom/approval_context_repository.go
Normal file
395
internal/infrastructure/wecom/approval_context_repository.go
Normal 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
|
||||
})
|
||||
}
|
||||
130
internal/infrastructure/wecom/approval_detail_client.go
Normal file
130
internal/infrastructure/wecom/approval_detail_client.go
Normal 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, "获取企业微信审批详情失败")
|
||||
}
|
||||
159
internal/infrastructure/wecom/approval_detail_task.go
Normal file
159
internal/infrastructure/wecom/approval_detail_task.go
Normal 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
|
||||
}
|
||||
}
|
||||
176
internal/infrastructure/wecom/approval_form.go
Normal file
176
internal/infrastructure/wecom/approval_form.go
Normal 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
|
||||
}
|
||||
195
internal/infrastructure/wecom/approval_info_client.go
Normal file
195
internal/infrastructure/wecom/approval_info_client.go
Normal 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
|
||||
}
|
||||
200
internal/infrastructure/wecom/approval_projection.go
Normal file
200
internal/infrastructure/wecom/approval_projection.go
Normal 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)
|
||||
152
internal/infrastructure/wecom/approval_provider.go
Normal file
152
internal/infrastructure/wecom/approval_provider.go
Normal 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)
|
||||
155
internal/infrastructure/wecom/approval_recovery_task.go
Normal file
155
internal/infrastructure/wecom/approval_recovery_task.go
Normal 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,
|
||||
})
|
||||
}
|
||||
189
internal/infrastructure/wecom/approval_submission_client.go
Normal file
189
internal/infrastructure/wecom/approval_submission_client.go
Normal 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
|
||||
}
|
||||
100
internal/infrastructure/wecom/approval_submission_consumer.go
Normal file
100
internal/infrastructure/wecom/approval_submission_consumer.go
Normal 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)
|
||||
85
internal/infrastructure/wecom/callback_crypto.go
Normal file
85
internal/infrastructure/wecom/callback_crypto.go
Normal 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
|
||||
}
|
||||
124
internal/infrastructure/wecom/callback_service.go
Normal file
124
internal/infrastructure/wecom/callback_service.go
Normal 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))
|
||||
}
|
||||
61
internal/infrastructure/wecom/credential_cipher.go
Normal file
61
internal/infrastructure/wecom/credential_cipher.go
Normal 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
|
||||
}
|
||||
185
internal/infrastructure/wecom/directory_client.go
Normal file
185
internal/infrastructure/wecom/directory_client.go
Normal 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)
|
||||
89
internal/infrastructure/wecom/member_repository.go
Normal file
89
internal/infrastructure/wecom/member_repository.go
Normal 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
|
||||
}
|
||||
83
internal/infrastructure/wecom/scene_repository.go
Normal file
83
internal/infrastructure/wecom/scene_repository.go
Normal 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
|
||||
}
|
||||
240
internal/infrastructure/wecom/template_client.go
Normal file
240
internal/infrastructure/wecom/template_client.go
Normal 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, "企业微信审批模板无效或不可访问")
|
||||
}
|
||||
296
internal/infrastructure/wecom/token_provider.go
Normal file
296
internal/infrastructure/wecom/token_provider.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user