七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
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:
119
internal/application/agentrecharge/approval_decision.go
Normal file
119
internal/application/agentrecharge/approval_decision.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
|
||||
type ApprovalDecisionHandler struct {
|
||||
db *gorm.DB
|
||||
posting *walletapp.PostingService
|
||||
}
|
||||
|
||||
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
|
||||
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, posting: posting}
|
||||
}
|
||||
|
||||
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
|
||||
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
if h == nil || h.db == nil || h.posting == nil {
|
||||
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
|
||||
}
|
||||
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
|
||||
}
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var record model.AgentRechargeRecord
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND payment_method = ?", event.BusinessID, constants.RechargeMethodOffline).
|
||||
First(&record).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定员工线下代充值申请失败")
|
||||
}
|
||||
if record.ApprovalInstanceID == nil || *record.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "线下代充值申请关联的审批实例不一致")
|
||||
}
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, &record, event)
|
||||
case constants.ApprovalDecisionRejected:
|
||||
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝")
|
||||
case constants.ApprovalDecisionCancelled:
|
||||
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销")
|
||||
case constants.ApprovalDecisionDeleted:
|
||||
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除")
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ApprovalDecisionHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
record *model.AgentRechargeRecord,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
if record.Status != constants.RechargeStatusPending && record.Status != constants.RechargeStatusCompleted {
|
||||
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许审批入账")
|
||||
}
|
||||
if record.Status == constants.RechargeStatusPending {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.RechargeStatusCompleted, "paid_at": event.OccurredAt,
|
||||
"completed_at": event.OccurredAt, "updated_at": event.OccurredAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成线下代充值审批申请失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
|
||||
}
|
||||
}
|
||||
_, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
|
||||
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
|
||||
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
|
||||
TransactionType: constants.AgentTransactionTypeRecharge,
|
||||
UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID,
|
||||
Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error {
|
||||
if record.Status == status {
|
||||
return nil
|
||||
}
|
||||
if record.Status != constants.RechargeStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许结束审批")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
|
||||
Updates(map[string]any{"status": status, "rejection_reason": reason})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束线下代充值审批申请失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil)
|
||||
188
internal/application/agentrecharge/offline_creation.go
Normal file
188
internal/application/agentrecharge/offline_creation.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Package agentrecharge 收口员工线下代充值申请和审批终态业务用例。
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CreateOfflineCommand 描述员工创建线下代充值审批申请的稳定输入。
|
||||
type CreateOfflineCommand struct {
|
||||
SubmitterAccountID uint
|
||||
SubmitterUserType int
|
||||
ShopID uint
|
||||
RechargeNo string
|
||||
Amount int64
|
||||
PaymentVoucherKeys []string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
|
||||
type CreateOfflineResult struct {
|
||||
Record *model.AgentRechargeRecord
|
||||
ShopName string
|
||||
SubmitterName string
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// OfflineCreationService 创建员工线下代充值申请及唯一通用审批实例。
|
||||
type OfflineCreationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
}
|
||||
|
||||
// NewOfflineCreationService 创建员工线下代充值申请用例。
|
||||
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port) *OfflineCreationService {
|
||||
return &OfflineCreationService{db: db, approval: approval}
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
|
||||
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
|
||||
}
|
||||
if err := validateCreateOfflineCommand(command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: command.SubmitterAccountID,
|
||||
CorrelationID: strings.TrimSpace(command.RechargeNo),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentChannel := constants.RechargeMethodOffline
|
||||
record := &model.AgentRechargeRecord{
|
||||
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
|
||||
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
|
||||
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
|
||||
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
|
||||
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
var approvalStatus int
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
BusinessID: record.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: record.RechargeNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", record.ID).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联员工线下代充值审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "员工线下代充值审批实例关联已变化")
|
||||
}
|
||||
record.ApprovalInstanceID = &reference.InstanceID
|
||||
approvalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateOfflineResult{
|
||||
Record: record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateCreateOfflineCommand(command CreateOfflineCommand) error {
|
||||
if command.SubmitterUserType != constants.UserTypePlatform && command.SubmitterUserType != constants.UserTypeSuperAdmin {
|
||||
return errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
|
||||
}
|
||||
if command.SubmitterAccountID == 0 || command.ShopID == 0 || strings.TrimSpace(command.RechargeNo) == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
|
||||
}
|
||||
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
|
||||
}
|
||||
for _, key := range command.PaymentVoucherKeys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OfflineCreationService) loadCreationFacts(
|
||||
ctx context.Context,
|
||||
command CreateOfflineCommand,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.SubmitterAccountID, constants.StatusEnabled).First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeForbidden, "提交人账号不可用")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值提交人失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", command.ShopID).First(&shop).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺失败")
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND wallet_type = ? AND status = ?", command.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
|
||||
First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "目标店铺主钱包不存在或不可用")
|
||||
}
|
||||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺主钱包失败")
|
||||
}
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": command.SubmitterAccountID, "account_name": submitterName,
|
||||
"user_type": command.SubmitterUserType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
|
||||
}
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
|
||||
constants.ApprovalFieldShopID: command.ShopID,
|
||||
constants.ApprovalFieldShopName: shopName,
|
||||
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
|
||||
constants.ApprovalFieldAmountCent: command.Amount,
|
||||
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
|
||||
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
|
||||
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
|
||||
constants.ApprovalFieldSubmitterName: submitterName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")
|
||||
}
|
||||
return submitterSnapshot, requestSnapshot, nil
|
||||
}
|
||||
49
internal/application/exchange/shipping_notification.go
Normal file
49
internal/application/exchange/shipping_notification.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Package exchange 提供换货用例的可靠通知编排。
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ShippingCreatedEvent 是物流换货单创建后通知个人客户的稳定业务事件。
|
||||
type ShippingCreatedEvent struct {
|
||||
ExchangeID uint
|
||||
ExchangeNo string
|
||||
CustomerID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// ShippingCreatedEventWriter 将物流换货创建通知写入可靠事件基础设施。
|
||||
type ShippingCreatedEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error
|
||||
}
|
||||
|
||||
// ShippingCreatedNotifier 在换货业务事务内编排个人客户通知事件。
|
||||
type ShippingCreatedNotifier struct {
|
||||
writer ShippingCreatedEventWriter
|
||||
}
|
||||
|
||||
// NewShippingCreatedNotifier 创建物流换货通知用例。
|
||||
func NewShippingCreatedNotifier(writer ShippingCreatedEventWriter) *ShippingCreatedNotifier {
|
||||
return &ShippingCreatedNotifier{writer: writer}
|
||||
}
|
||||
|
||||
// Notify 在换货单事务内追加指定个人客户的可靠通知事件。
|
||||
func (n *ShippingCreatedNotifier) Notify(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error {
|
||||
if n == nil || n.writer == nil {
|
||||
return errors.New(errors.CodeInternalError, "物流换货通知事件 Writer 未配置")
|
||||
}
|
||||
if tx == nil || event.ExchangeID == 0 || event.ExchangeNo == "" || event.CustomerID == 0 ||
|
||||
event.AssetType == "" || event.AssetID == 0 || event.AssetIdentifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "物流换货通知事件参数不完整")
|
||||
}
|
||||
return n.writer.Append(ctx, tx, event)
|
||||
}
|
||||
@@ -103,8 +103,8 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
|
||||
[]string{constants.NotificationTypePackageExpiring},
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
|
||||
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
45
internal/application/packageexpiry/reminder.go
Normal file
45
internal/application/packageexpiry/reminder.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package packageexpiry 编排套餐临期节点提醒用例。
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ReminderScanner 查询当天命中临期节点的资产。
|
||||
type ReminderScanner interface {
|
||||
ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error)
|
||||
}
|
||||
|
||||
// ReminderPublisher 批量发布个人客户临期通知。
|
||||
type ReminderPublisher interface {
|
||||
Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error
|
||||
}
|
||||
|
||||
// ReminderService 扫描并发布每日套餐临期节点提醒。
|
||||
type ReminderService struct {
|
||||
scanner ReminderScanner
|
||||
publisher ReminderPublisher
|
||||
}
|
||||
|
||||
// NewReminderService 创建套餐临期提醒用例。
|
||||
func NewReminderService(scanner ReminderScanner, publisher ReminderPublisher) *ReminderService {
|
||||
return &ReminderService{scanner: scanner, publisher: publisher}
|
||||
}
|
||||
|
||||
// Run 执行当天 15、7、3 天节点扫描并可靠发布通知。
|
||||
func (s *ReminderService) Run(ctx context.Context) error {
|
||||
if s == nil || s.scanner == nil || s.publisher == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐临期提醒用例未配置")
|
||||
}
|
||||
candidates, err := s.scanner.ReminderCandidates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.publisher.Publish(ctx, candidates)
|
||||
}
|
||||
151
internal/application/refundapproval/creation.go
Normal file
151
internal/application/refundapproval/creation.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package refundapproval 收口退款申请与渠道无关审批的事务边界。
|
||||
package refundapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||||
type CreateCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
SubmitterAccountID uint
|
||||
}
|
||||
|
||||
// CreateResult 返回原子保存后的退款申请和初始审批状态。
|
||||
type CreateResult struct {
|
||||
Refund *model.RefundRequest
|
||||
SubmitterName string
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
|
||||
type CreationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
}
|
||||
|
||||
// NewCreationService 创建退款审批申请用例。
|
||||
func NewCreationService(db *gorm.DB, approval approvalapp.Port) *CreationService {
|
||||
return &CreationService{db: db, approval: approval}
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
|
||||
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
if command.Refund == nil || command.Refund.OrderID == 0 || command.SubmitterAccountID == 0 ||
|
||||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
account, err := s.loadSubmitter(ctx, command.SubmitterAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID,
|
||||
CorrelationID: command.Refund.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var approvalStatus int
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
|
||||
}
|
||||
var activeCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
|
||||
}
|
||||
if activeCount > 0 {
|
||||
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: command.Refund.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
|
||||
}
|
||||
command.Refund.ApprovalInstanceID = &reference.InstanceID
|
||||
approvalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
}
|
||||
|
||||
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败")
|
||||
}
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldRefundNo: refund.RefundNo,
|
||||
constants.ApprovalFieldOrderID: refund.OrderID,
|
||||
constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
|
||||
constants.ApprovalFieldAssetType: refund.OrderType,
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
|
||||
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||||
constants.ApprovalFieldSubmitterID: account.ID,
|
||||
constants.ApprovalFieldSubmitterName: account.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败")
|
||||
}
|
||||
return submitterSnapshot, requestSnapshot, nil
|
||||
}
|
||||
|
||||
func formatCentAmount(amount int64) string {
|
||||
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
|
||||
}
|
||||
@@ -219,7 +219,8 @@ func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
|
||||
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
|
||||
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
|
||||
District: shop.District, Address: shop.Address, Status: shop.Status,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
ClientLoginDisabled: shop.ClientLoginDisabled,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||||
}
|
||||
if userType == constants.UserTypeAgent && request.ClientLoginDisabled != nil && middleware.GetShopIDFromContext(ctx) != shopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限修改其他店铺的C端登录限制")
|
||||
}
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
@@ -59,6 +62,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
}
|
||||
shop.BusinessOwnerAccountID = ownerID
|
||||
}
|
||||
if request.ClientLoginDisabled != nil {
|
||||
shop.ClientLoginDisabled = *request.ClientLoginDisabled
|
||||
}
|
||||
shop.ShopName = request.ShopName
|
||||
shop.ContactName = request.ContactName
|
||||
shop.ContactPhone = request.ContactPhone
|
||||
|
||||
303
internal/application/wecom/connection.go
Normal file
303
internal/application/wecom/connection.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Package wecom 提供企业微信配置简单写与连接测试用例。
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// ApplicationRepository 定义企业微信应用配置持久化边界。
|
||||
type ApplicationRepository interface {
|
||||
FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error)
|
||||
Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
|
||||
Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error
|
||||
List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error)
|
||||
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
|
||||
UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error
|
||||
}
|
||||
|
||||
// DefaultCreatorMemberFinder 定义默认审批发起人的可见成员查询边界。
|
||||
type DefaultCreatorMemberFinder interface {
|
||||
GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error)
|
||||
}
|
||||
|
||||
// CredentialCipher 定义企业微信敏感凭据加密边界。
|
||||
type CredentialCipher interface {
|
||||
Encrypt(plaintext string) ([]byte, error)
|
||||
Decrypt(ciphertext []byte) (string, error)
|
||||
}
|
||||
|
||||
// AccessTokenProvider 定义按应用取得及失效 access_token 的边界。
|
||||
type AccessTokenProvider interface {
|
||||
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
|
||||
Invalidate(ctx context.Context, applicationID uint)
|
||||
}
|
||||
|
||||
// ConnectionService 保存加密配置并测试企业微信连接。
|
||||
type ConnectionService struct {
|
||||
db *gorm.DB
|
||||
repo ApplicationRepository
|
||||
cipher CredentialCipher
|
||||
tokens AccessTokenProvider
|
||||
audit systemconfigapp.AuditWriter
|
||||
members DefaultCreatorMemberFinder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
|
||||
func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) {
|
||||
s.members = finder
|
||||
}
|
||||
|
||||
// NewConnectionService 创建企业微信连接用例。
|
||||
func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService {
|
||||
return &ConnectionService{db: db, repo: repo, cipher: cipher, tokens: tokens, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// Save 创建或更新企业微信应用,并保证数据库仅保存密文凭据。
|
||||
func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) {
|
||||
if s == nil || s.db == nil || s.repo == nil || s.cipher == nil {
|
||||
return nil, errors.New(errors.CodeWeComCredentialInvalid)
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
secret, err := s.cipher.Encrypt(request.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callbackToken, err := s.cipher.Encrypt(request.CallbackToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encodingAESKey, err := s.cipher.Encrypt(request.EncodingAESKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var saved *model.WeComApplication
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing, findErr := s.repo.FindByIdentityForUpdate(ctx, tx, request.CorpID, request.AgentID)
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
before := map[string]any{"configured": false}
|
||||
if existing == nil {
|
||||
existing = &model.WeComApplication{
|
||||
Model: gorm.Model{CreatedAt: now},
|
||||
CorpID: request.CorpID,
|
||||
AgentID: request.AgentID,
|
||||
CreatedBy: operatorID,
|
||||
}
|
||||
} else {
|
||||
before = applicationAuditSnapshot(existing)
|
||||
}
|
||||
existing.Name = request.Name
|
||||
existing.SecretCiphertext = secret
|
||||
existing.CallbackTokenCiphertext = callbackToken
|
||||
existing.EncodingAESKeyCiphertext = encodingAESKey
|
||||
existing.Status = request.Status
|
||||
existing.UpdatedBy = operatorID
|
||||
existing.UpdatedAt = now
|
||||
if existing.ID == 0 {
|
||||
if err := s.repo.Create(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.audit != nil {
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置",
|
||||
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
|
||||
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stdErrors.As(err, &appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信应用配置失败")
|
||||
}
|
||||
if s.tokens != nil {
|
||||
s.tokens.Invalidate(ctx, saved.ID)
|
||||
}
|
||||
response := toApplicationResponse(*saved, request.Secret, request.CallbackToken, request.EncodingAESKey)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。
|
||||
func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) {
|
||||
if s == nil || s.repo == nil || s.cipher == nil {
|
||||
return nil, errors.New(errors.CodeWeComCredentialInvalid)
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
if request.Page <= 0 {
|
||||
request.Page = constants.DefaultPage
|
||||
}
|
||||
if request.PageSize <= 0 {
|
||||
request.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
if request.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
applications, total, err := s.repo.List(ctx, request.Page, request.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]dto.WeComApplicationResponse, 0, len(applications))
|
||||
for _, application := range applications {
|
||||
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, toApplicationResponse(application, secret, callbackToken, encodingAESKey))
|
||||
}
|
||||
return &dto.WeComApplicationListResponse{
|
||||
Items: result, Total: total, Page: request.Page, PageSize: request.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Test 强制失效旧缓存后取得一次 access_token,但绝不向调用方返回 token。
|
||||
func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error {
|
||||
if s == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return errors.New(errors.CodeForbidden)
|
||||
}
|
||||
if s.tokens == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
s.tokens.Invalidate(ctx, applicationID)
|
||||
_, err := s.tokens.GetAccessToken(ctx, applicationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
|
||||
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) {
|
||||
if s == nil || s.db == nil || s.repo == nil || s.cipher == nil || s.members == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if applicationID == 0 || operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
application, err := s.repo.GetEnabled(ctx, applicationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member, err := s.members.GetVisible(ctx, applicationID, request.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
before := applicationAuditSnapshot(application)
|
||||
if err := s.repo.UpdateDefaultCreator(ctx, tx, applicationID, member.UserID, member.Name, operatorID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
application.DefaultCreatorUserID = member.UserID
|
||||
application.DefaultCreatorName = member.Name
|
||||
application.UpdatedBy = operatorID
|
||||
application.UpdatedAt = now
|
||||
if s.audit != nil {
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人",
|
||||
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
|
||||
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stdErrors.As(err, &appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败")
|
||||
}
|
||||
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := toApplicationResponse(*application, secret, callbackToken, encodingAESKey)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func applicationAuditSnapshot(application *model.WeComApplication) map[string]any {
|
||||
return map[string]any{
|
||||
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
|
||||
"name": application.Name, "status": application.Status, "credentials_configured": true,
|
||||
"default_creator_userid": application.DefaultCreatorUserID,
|
||||
"default_creator_name": application.DefaultCreatorName,
|
||||
}
|
||||
}
|
||||
|
||||
func toApplicationResponse(application model.WeComApplication, secret, callbackToken, encodingAESKey string) dto.WeComApplicationResponse {
|
||||
statusName := "禁用"
|
||||
if application.Status == constants.StatusEnabled {
|
||||
statusName = "启用"
|
||||
}
|
||||
return dto.WeComApplicationResponse{
|
||||
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name,
|
||||
Secret: secret, CallbackToken: callbackToken, EncodingAESKey: encodingAESKey,
|
||||
DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName,
|
||||
Status: application.Status, StatusName: statusName,
|
||||
CredentialsSet: len(application.SecretCiphertext) > 0 && len(application.CallbackTokenCiphertext) > 0 && len(application.EncodingAESKeyCiphertext) > 0,
|
||||
LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
|
||||
}
|
||||
}
|
||||
129
internal/application/wecom/directory.go
Normal file
129
internal/application/wecom/directory.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// DirectoryMember 是企微 Adapter 交给 Application 的最小成员快照。
|
||||
type DirectoryMember struct {
|
||||
UserID string
|
||||
Name string
|
||||
DepartmentIDs []int64
|
||||
}
|
||||
|
||||
// DirectoryProvider 定义拉取应用可见成员的外部端口。
|
||||
type DirectoryProvider interface {
|
||||
ListVisibleMembers(ctx context.Context, applicationID uint) ([]DirectoryMember, error)
|
||||
}
|
||||
|
||||
// MemberRepository 定义可见成员快照同步和分页查询边界。
|
||||
type MemberRepository interface {
|
||||
ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
|
||||
ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error)
|
||||
}
|
||||
|
||||
// DirectoryService 同步并分页查询企业微信应用可见成员。
|
||||
type DirectoryService struct {
|
||||
applications interface {
|
||||
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
|
||||
}
|
||||
provider DirectoryProvider
|
||||
members MemberRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDirectoryService 创建企业微信通讯录同步用例。
|
||||
func NewDirectoryService(applications interface {
|
||||
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
|
||||
}, provider DirectoryProvider, members MemberRepository) *DirectoryService {
|
||||
return &DirectoryService{applications: applications, provider: provider, members: members, now: time.Now}
|
||||
}
|
||||
|
||||
// Sync 拉取并替换指定应用当前可见成员快照。
|
||||
func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) {
|
||||
if s == nil || s.applications == nil || s.provider == nil || s.members == nil || applicationID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
|
||||
}
|
||||
if !canManageWeComDirectory(ctx) {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
application, err := s.applications.GetEnabled(ctx, applicationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
syncedAt := s.now().UTC()
|
||||
members := make([]model.WeComMember, 0, len(remoteMembers))
|
||||
for _, member := range remoteMembers {
|
||||
departments, err := sonic.Marshal(member.DepartmentIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化企业微信成员部门失败")
|
||||
}
|
||||
members = append(members, model.WeComMember{
|
||||
ApplicationID: applicationID, CorpID: application.CorpID, UserID: member.UserID,
|
||||
Name: member.Name, DepartmentIDs: departments, Visible: true, SyncedAt: syncedAt,
|
||||
CreatedAt: syncedAt, UpdatedAt: syncedAt,
|
||||
})
|
||||
}
|
||||
if err := s.members.ReplaceVisible(ctx, applicationID, members, syncedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil
|
||||
}
|
||||
|
||||
// List 分页返回本地最近一次同步的应用可见成员。
|
||||
func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) {
|
||||
if s == nil || s.applications == nil || s.members == nil || applicationID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
|
||||
}
|
||||
if !canManageWeComDirectory(ctx) {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
if request.Page <= 0 {
|
||||
request.Page = constants.DefaultPage
|
||||
}
|
||||
if request.PageSize <= 0 {
|
||||
request.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
if request.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if _, err := s.applications.GetEnabled(ctx, applicationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members, total, err := s.members.ListVisible(ctx, applicationID, request.Page, request.PageSize, request.Keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.WeComMemberResponse, 0, len(members))
|
||||
for _, member := range members {
|
||||
var departmentIDs []int64
|
||||
if len(member.DepartmentIDs) > 0 {
|
||||
if err := sonic.Unmarshal(member.DepartmentIDs, &departmentIDs); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信成员部门失败")
|
||||
}
|
||||
}
|
||||
items = append(items, dto.WeComMemberResponse{
|
||||
ApplicationID: member.ApplicationID, CorpID: member.CorpID, UserID: member.UserID,
|
||||
Name: member.Name, DepartmentIDs: departmentIDs, SyncedAt: member.SyncedAt,
|
||||
})
|
||||
}
|
||||
return &dto.WeComMemberListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
|
||||
func canManageWeComDirectory(ctx context.Context) bool {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
|
||||
}
|
||||
322
internal/application/wecom/scene.go
Normal file
322
internal/application/wecom/scene.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
stdErrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// TemplateControl 是模板详情中可供业务映射的最小控件结构。
|
||||
type TemplateControl struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
OptionKeys []string `json:"option_keys"`
|
||||
}
|
||||
|
||||
// TemplateDefinition 是企微 Adapter 返回的模板最小结构。
|
||||
type TemplateDefinition struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name"`
|
||||
Controls []TemplateControl `json:"controls"`
|
||||
}
|
||||
|
||||
// TemplateProvider 定义审批模板详情外部端口。
|
||||
type TemplateProvider interface {
|
||||
GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error)
|
||||
}
|
||||
|
||||
// SceneRepository 定义审批场景当前配置持久化边界。
|
||||
type SceneRepository interface {
|
||||
FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error)
|
||||
Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
|
||||
Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
|
||||
List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error)
|
||||
}
|
||||
|
||||
// SceneService 保存经企微模板详情校验的业务场景当前映射。
|
||||
type SceneService struct {
|
||||
db *gorm.DB
|
||||
provider TemplateProvider
|
||||
repo SceneRepository
|
||||
audit systemconfigapp.AuditWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSceneService 创建企业微信审批场景配置用例。
|
||||
func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService {
|
||||
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
|
||||
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
|
||||
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
businessType = strings.TrimSpace(businessType)
|
||||
if !validApprovalBusinessType(businessType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
|
||||
}
|
||||
if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 ||
|
||||
(request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
|
||||
mappingJSON, err := sonic.Marshal(request.ControlMapping)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败")
|
||||
}
|
||||
snapshotJSON, err := sonic.Marshal(definition)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
|
||||
}
|
||||
fingerprintBytes := sha256.Sum256(snapshotJSON)
|
||||
fingerprint := hex.EncodeToString(fingerprintBytes[:])
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var saved *model.WeComApprovalScene
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := s.repo.FindForUpdate(ctx, tx, businessType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := map[string]any{"configured": false, "business_type": businessType}
|
||||
if existing == nil {
|
||||
existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now}
|
||||
} else {
|
||||
before = sceneAuditSnapshot(existing)
|
||||
}
|
||||
existing.ApplicationID = request.ApplicationID
|
||||
existing.TemplateID = definition.TemplateID
|
||||
existing.TemplateName = definition.Name
|
||||
existing.ControlMapping = mappingJSON
|
||||
existing.TemplateSnapshot = snapshotJSON
|
||||
existing.TemplateFingerprint = fingerprint
|
||||
existing.Status = request.Status
|
||||
existing.LastVerifiedAt = now
|
||||
existing.UpdatedBy = operatorID
|
||||
existing.UpdatedAt = now
|
||||
if existing.ID == 0 {
|
||||
if err := s.repo.Create(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.audit != nil {
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
|
||||
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
|
||||
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stdErrors.As(err, &appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败")
|
||||
}
|
||||
return sceneResponse(*saved)
|
||||
}
|
||||
|
||||
// List 分页查询企业微信审批场景当前配置。
|
||||
func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
if request.Page <= 0 {
|
||||
request.Page = constants.DefaultPage
|
||||
}
|
||||
if request.PageSize <= 0 {
|
||||
request.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
if request.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes))
|
||||
for _, scene := range scenes {
|
||||
item, err := sceneResponse(scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
|
||||
func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
|
||||
controlByID := make(map[string]TemplateControl, len(controls))
|
||||
for _, control := range controls {
|
||||
controlByID[control.ID] = control
|
||||
}
|
||||
mappedControls := make(map[string]struct{}, len(mapping))
|
||||
businessFields := make(map[string]struct{}, len(mapping))
|
||||
for _, item := range mapping {
|
||||
item.BusinessField = strings.TrimSpace(item.BusinessField)
|
||||
item.ControlID = strings.TrimSpace(item.ControlID)
|
||||
if item.BusinessField == "" || item.ControlID == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空")
|
||||
}
|
||||
if !allowedSceneBusinessField(businessType, item.BusinessField) {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段")
|
||||
}
|
||||
if _, exists := businessFields[item.BusinessField]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复")
|
||||
}
|
||||
if _, exists := mappedControls[item.ControlID]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射")
|
||||
}
|
||||
control, exists := controlByID[item.ControlID]
|
||||
if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效")
|
||||
}
|
||||
validOptions := make(map[string]struct{}, len(control.OptionKeys))
|
||||
for _, key := range control.OptionKeys {
|
||||
validOptions[key] = struct{}{}
|
||||
}
|
||||
for _, key := range item.OptionMapping {
|
||||
if _, exists := validOptions[key]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效")
|
||||
}
|
||||
}
|
||||
businessFields[item.BusinessField] = struct{}{}
|
||||
mappedControls[item.ControlID] = struct{}{}
|
||||
}
|
||||
for _, control := range controls {
|
||||
if control.Required {
|
||||
if _, exists := mappedControls[control.ID]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
allowed := map[string]struct{}{}
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeOfflineRecharge:
|
||||
allowed = map[string]struct{}{
|
||||
constants.ApprovalFieldRechargeNo: {},
|
||||
constants.ApprovalFieldShopID: {},
|
||||
constants.ApprovalFieldShopName: {},
|
||||
constants.ApprovalFieldAmount: {},
|
||||
constants.ApprovalFieldAmountCent: {},
|
||||
constants.ApprovalFieldPaymentVoucherKey: {},
|
||||
constants.ApprovalFieldRemark: {},
|
||||
constants.ApprovalFieldSubmitterID: {},
|
||||
constants.ApprovalFieldSubmitterName: {},
|
||||
}
|
||||
case constants.ApprovalBusinessTypeRefund:
|
||||
allowed = map[string]struct{}{
|
||||
constants.ApprovalFieldRefundNo: {},
|
||||
constants.ApprovalFieldOrderID: {},
|
||||
constants.ApprovalFieldOrderNo: {},
|
||||
constants.ApprovalFieldAssetIdentifier: {},
|
||||
constants.ApprovalFieldAssetType: {},
|
||||
constants.ApprovalFieldActualReceivedAmount: {},
|
||||
constants.ApprovalFieldRequestedRefundAmount: {},
|
||||
constants.ApprovalFieldRefundVoucherKey: {},
|
||||
constants.ApprovalFieldRefundReason: {},
|
||||
constants.ApprovalFieldPackageUsageID: {},
|
||||
constants.ApprovalFieldSubmitterID: {},
|
||||
constants.ApprovalFieldSubmitterName: {},
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
_, exists := allowed[businessField]
|
||||
return exists
|
||||
}
|
||||
|
||||
func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem {
|
||||
result := make([]dto.WeComControlMappingItem, 0, len(mapping))
|
||||
for _, item := range mapping {
|
||||
item.BusinessField = strings.TrimSpace(item.BusinessField)
|
||||
item.ControlID = strings.TrimSpace(item.ControlID)
|
||||
item.ControlType = strings.TrimSpace(item.ControlType)
|
||||
if item.OptionMapping == nil {
|
||||
item.OptionMapping = map[string]string{}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
|
||||
}
|
||||
|
||||
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
|
||||
return map[string]any{
|
||||
"business_type": scene.BusinessType, "application_id": scene.ApplicationID,
|
||||
"template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
|
||||
"template_fingerprint": scene.TemplateFingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
|
||||
var mapping []dto.WeComControlMappingItem
|
||||
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败")
|
||||
}
|
||||
name := "员工线下代充值审批"
|
||||
if scene.BusinessType == constants.ApprovalBusinessTypeRefund {
|
||||
name = "退款审批"
|
||||
}
|
||||
return &dto.WeComApprovalSceneResponse{
|
||||
ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: name,
|
||||
ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName,
|
||||
TemplateFingerprint: scene.TemplateFingerprint,
|
||||
ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status),
|
||||
LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
wecomApp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||
@@ -22,11 +24,14 @@ import (
|
||||
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
|
||||
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
@@ -92,16 +97,58 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
systemConfigRegistry = systemConfigInfra.NewRegistry()
|
||||
}
|
||||
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
|
||||
registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger)
|
||||
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
|
||||
var systemConfigCache systemConfigInfra.Cache
|
||||
if deps.Redis != nil {
|
||||
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
|
||||
}
|
||||
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
|
||||
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
|
||||
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
|
||||
systemConfigUpdate := systemConfigApp.NewUpdateService(
|
||||
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
|
||||
)
|
||||
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
var credentialCipher *wecomInfra.CredentialCipher
|
||||
wecomBaseURL := ""
|
||||
wecomTimeout := constants.WeComDefaultHTTPTimeout
|
||||
if cfg := config.Get(); cfg != nil {
|
||||
wecomBaseURL = cfg.WeCom.BaseURL
|
||||
wecomTimeout = cfg.WeCom.Timeout
|
||||
cipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
|
||||
if err != nil {
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.Warn("企业微信凭据加密密钥未配置或无效,相关接口将拒绝业务调用", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
credentialCipher = cipher
|
||||
}
|
||||
}
|
||||
wecomTokens := wecomInfra.NewTokenProvider(
|
||||
wecomRepository, credentialCipher, deps.Redis, integrationlog.NewRepository(deps.DB),
|
||||
wecomBaseURL, wecomTimeout, deps.Logger,
|
||||
)
|
||||
wecomConnections := wecomApp.NewConnectionService(
|
||||
deps.DB, wecomRepository, credentialCipher, wecomTokens, deps.SystemConfigAudit,
|
||||
)
|
||||
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
|
||||
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
|
||||
wecomDirectory := wecomApp.NewDirectoryService(
|
||||
wecomRepository,
|
||||
wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
|
||||
wecomMembers,
|
||||
)
|
||||
wecomScenes := wecomApp.NewSceneService(
|
||||
deps.DB,
|
||||
wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
|
||||
wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit,
|
||||
)
|
||||
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
|
||||
wecomRepository, credentialCipher, integrationlog.NewRepository(deps.DB), deps.QueueClient,
|
||||
))
|
||||
svc.Account.SetWeComMemberFinder(wecomMembers)
|
||||
|
||||
return &Handlers{
|
||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||
@@ -117,9 +164,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
ClientAsset: func() *app.ClientAssetHandler {
|
||||
handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger)
|
||||
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
|
||||
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
handler.SetForceRechargeChecker(svc.Recharge)
|
||||
return handler
|
||||
}(),
|
||||
ClientWallet: func() *app.ClientWalletHandler {
|
||||
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
|
||||
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
return handler
|
||||
}(),
|
||||
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
|
||||
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
|
||||
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
|
||||
ClientRealname: func() *app.ClientRealnameHandler {
|
||||
@@ -189,12 +242,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
|
||||
integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger,
|
||||
),
|
||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
WeComApprovalCallback: wecomApprovalCallback,
|
||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
|
||||
@@ -208,6 +262,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
|
||||
h.SetLifecycleService(svc.AssetLifecycle)
|
||||
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
|
||||
h.SetPackageExpiryQuery(packageExpiry)
|
||||
return h
|
||||
}(),
|
||||
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||
@@ -220,9 +275,16 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
|
||||
AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate),
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
|
||||
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
|
||||
WeCom: func() *admin.WeComHandler {
|
||||
handler := admin.NewWeComHandler(wecomConnections, validate)
|
||||
handler.SetDirectoryService(wecomDirectory)
|
||||
handler.SetSceneService(wecomScenes)
|
||||
return handler
|
||||
}(),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
|
||||
}
|
||||
}
|
||||
|
||||
36
internal/bootstrap/payment_method_config.go
Normal file
36
internal/bootstrap/payment_method_config.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/paymentmethod"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
definitions := []systemconfig.Definition{
|
||||
{Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
|
||||
{Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if existing, exists := registry.Get(definition.Key); exists {
|
||||
if existing.ValueType != definition.ValueType || existing.Module != definition.Module {
|
||||
logPaymentMethodConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
logPaymentMethodConfigRegistrationError(logger, definition.Key, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logPaymentMethodConfigRegistrationError(logger *zap.Logger, key, reason string) {
|
||||
if logger != nil {
|
||||
logger.Error("注册支付方式系统配置失败,C端支付将失败关闭", zap.String("config_key", key), zap.String("reason", reason))
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,19 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
||||
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
||||
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
|
||||
@@ -26,11 +33,13 @@ import (
|
||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/payment"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
|
||||
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
|
||||
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
||||
@@ -65,6 +74,7 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
Approval *approvalApp.CreationService
|
||||
Account *accountSvc.Service
|
||||
AccountAudit *accountAuditSvc.Service
|
||||
AssetAudit *assetAuditSvc.Service
|
||||
@@ -120,6 +130,7 @@ type services struct {
|
||||
AgentOpenAPI *agentOpenAPISvc.Service
|
||||
CustomerBinding *customerBindingSvc.Service
|
||||
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
|
||||
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
|
||||
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
CardObservation *cardObservationApp.Service
|
||||
CardObservationSeries *cardObservationApp.SeriesAttemptService
|
||||
@@ -155,6 +166,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
|
||||
)
|
||||
iotCard.SetCardObservationService(cardObservationService)
|
||||
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
|
||||
seriesIntegration := integrationlog.NewRepository(deps.DB)
|
||||
seriesTrigger := cardObservationApp.NewSeriesTrigger(
|
||||
@@ -251,7 +263,8 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
)
|
||||
agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil))
|
||||
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)
|
||||
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
|
||||
refundService := refundSvc.New(
|
||||
deps.DB,
|
||||
s.RefundRequest,
|
||||
@@ -268,11 +281,45 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.Logger,
|
||||
)
|
||||
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
|
||||
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
|
||||
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
|
||||
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
|
||||
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
|
||||
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
|
||||
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
|
||||
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)
|
||||
wecomBaseURL := ""
|
||||
wecomTimeout := constants.WeComDefaultHTTPTimeout
|
||||
var wecomCredentialCipher *wecomInfra.CredentialCipher
|
||||
if cfg := config.Get(); cfg != nil {
|
||||
wecomBaseURL = cfg.WeCom.BaseURL
|
||||
wecomTimeout = cfg.WeCom.Timeout
|
||||
wecomCredentialCipher, _ = wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey)
|
||||
}
|
||||
wecomIntegrationRepository := integrationlog.NewRepository(deps.DB)
|
||||
wecomTokenProvider := wecomInfra.NewTokenProvider(
|
||||
wecomApplicationRepository, wecomCredentialCipher, deps.Redis, wecomIntegrationRepository,
|
||||
wecomBaseURL, wecomTimeout, deps.Logger,
|
||||
)
|
||||
approvalCreationService := approvalApp.NewCreationService(
|
||||
wecomInfra.NewApprovalProvider(
|
||||
deps.DB, wecomSceneRepository, wecomApplicationRepository, wecomMemberRepository,
|
||||
wecomInfra.NewTemplateClient(wecomTokenProvider, wecomIntegrationRepository, wecomBaseURL, wecomTimeout),
|
||||
),
|
||||
approvalInfra.NewRepositoryProvider(),
|
||||
approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()),
|
||||
nil,
|
||||
)
|
||||
agentRechargeService.SetOfflineCreationService(
|
||||
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService),
|
||||
)
|
||||
refundService.SetRefundApprovalCreationService(
|
||||
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
|
||||
)
|
||||
|
||||
return &services{
|
||||
Approval: approvalCreationService,
|
||||
Account: account,
|
||||
AccountAudit: accountAudit,
|
||||
AssetAudit: assetAudit,
|
||||
@@ -337,7 +384,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Order: orderService,
|
||||
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger),
|
||||
Exchange: exchangeService,
|
||||
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
|
||||
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
|
||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||
@@ -358,6 +405,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Refund: refundService,
|
||||
CustomerBinding: customerBinding,
|
||||
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
|
||||
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
|
||||
ObservationSeries: observationSeries,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeries,
|
||||
|
||||
@@ -68,6 +68,8 @@ type stores struct {
|
||||
RefundRequest *postgres.RefundStore
|
||||
// 订单套餐失效任务
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
// 资产套餐批量订购任务
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 资产标识符注册表
|
||||
@@ -128,14 +130,15 @@ func initStores(deps *Dependencies) *stores {
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
|
||||
// 资产钱包系统
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type Handlers struct {
|
||||
CMCCRealnameCallback *callback.CMCCRealnameHandler
|
||||
CUCCRealnameCallback *callback.CUCCRealnameHandler
|
||||
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
|
||||
WeComApprovalCallback *callback.WeComApprovalHandler
|
||||
PollingConfig *admin.PollingConfigHandler
|
||||
PollingConcurrency *admin.PollingConcurrencyHandler
|
||||
PollingMonitoring *admin.PollingMonitoringHandler
|
||||
@@ -70,9 +71,11 @@ type Handlers struct {
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
WeCom *admin.WeComHandler
|
||||
AgentOpenAPI *openapiHandler.Handler
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type WorkerDependencies struct {
|
||||
Redis *redis.Client
|
||||
Logger *zap.Logger
|
||||
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
|
||||
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
|
||||
StorageService *storage.Service // 对象存储(可选)
|
||||
GatewayClient *gateway.Client // Gateway 客户端(可选)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
@@ -102,7 +104,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
|
||||
)
|
||||
|
||||
// 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端)
|
||||
// 初始化订单服务,供超时取消和批量订购共同复用现有订单规则。
|
||||
purchaseValidation := purchaseValidationSvc.New(deps.DB, stores.IotCard, stores.Device, stores.Package, stores.ShopPackageAllocation)
|
||||
orderService := orderSvc.New(
|
||||
deps.DB,
|
||||
deps.Redis,
|
||||
@@ -111,7 +114,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
stores.AgentWallet,
|
||||
stores.AssetWallet,
|
||||
nil, // paymentStore: 超时取消不需要
|
||||
nil, // purchaseValidationService: 超时取消不需要
|
||||
purchaseValidation,
|
||||
stores.ShopPackageAllocation,
|
||||
stores.ShopSeriesAllocation,
|
||||
stores.IotCard,
|
||||
@@ -122,7 +125,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
nil, // wechatConfigService: 超时取消不需要
|
||||
nil, // wechatPayment: 超时取消不需要
|
||||
nil, // paymentLoader: 超时取消不需要
|
||||
nil, // queueClient: 超时取消不触发分佣
|
||||
deps.QueueClient,
|
||||
deps.Logger,
|
||||
stores.AssetIdentifier,
|
||||
stores.PersonalCustomer,
|
||||
@@ -131,6 +134,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
walletOutbox := outbox.NewRepository()
|
||||
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
|
||||
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
|
||||
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(
|
||||
@@ -148,19 +152,26 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
activationService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
resetService.SetResumeCallback(stopResumeService)
|
||||
deviceBatchAllocator := deviceSvc.New(
|
||||
deps.DB, deps.Redis, stores.Device, stores.DeviceSimBinding, stores.IotCard, stores.Shop,
|
||||
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
|
||||
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, assetAudit, nil, nil,
|
||||
)
|
||||
|
||||
return &queue.WorkerServices{
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeriesService,
|
||||
ObservationSeriesEvents: observationSeriesEvents,
|
||||
CommissionCalculation: commissionCalculationService,
|
||||
CommissionStats: commissionStatsService,
|
||||
UsageService: usageService,
|
||||
ActivationService: activationService,
|
||||
ResetService: resetService,
|
||||
AlertService: alertService,
|
||||
CleanupService: cleanupService,
|
||||
StopResumeService: stopResumeService,
|
||||
OrderExpirer: orderService,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeriesService,
|
||||
ObservationSeriesEvents: observationSeriesEvents,
|
||||
CommissionCalculation: commissionCalculationService,
|
||||
CommissionStats: commissionStatsService,
|
||||
UsageService: usageService,
|
||||
ActivationService: activationService,
|
||||
ResetService: resetService,
|
||||
AlertService: alertService,
|
||||
CleanupService: cleanupService,
|
||||
StopResumeService: stopResumeService,
|
||||
OrderExpirer: orderService,
|
||||
AssetPackageOrderCreator: orderService,
|
||||
DeviceBatchAllocator: deviceBatchAllocator,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,102 +6,108 @@ import (
|
||||
)
|
||||
|
||||
type workerStores struct {
|
||||
AssetOperationLog *postgres.AssetOperationLogStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
AssetOperationLog *postgres.AssetOperationLogStore
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
stores := &workerStores{
|
||||
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
AssetOperationLog: stores.AssetOperationLog,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
AssetOperationLog: stores.AssetOperationLog,
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
|
||||
}
|
||||
}
|
||||
|
||||
190
internal/exporter/agent_recharge_scene.go
Normal file
190
internal/exporter/agent_recharge_scene.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// AgentRechargeDataSource 代理充值导出数据源。
|
||||
type AgentRechargeDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAgentRechargeDataSource 创建代理充值导出数据源。
|
||||
func NewAgentRechargeDataSource(db *gorm.DB) *AgentRechargeDataSource {
|
||||
return &AgentRechargeDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *AgentRechargeDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneAgentRecharge
|
||||
}
|
||||
|
||||
// Count 统计代理充值导出行数。
|
||||
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回代理充值导出表头。
|
||||
func (s *AgentRechargeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"充值单号", "店铺名称", "充值类型", "充值金额(元)", "实付金额(元)", "充值前余额(元)", "充值后余额(元)",
|
||||
"状态", "支付方式", "运营备注", "驳回原因", "创建时间", "支付时间", "完成时间", "提交人", "审批来源", "审批状态", "支付凭证",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询代理充值导出数据。
|
||||
func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var items []agentRechargeExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
r.recharge_no,
|
||||
r.amount,
|
||||
r.payment_method,
|
||||
r.status,
|
||||
r.remark,
|
||||
COALESCE(r.rejection_reason, '') AS rejection_reason,
|
||||
r.created_at,
|
||||
r.paid_at,
|
||||
r.completed_at,
|
||||
COALESCE(sh.shop_name, '') AS shop_name,
|
||||
COALESCE(ac.username, '') AS submitter_name,
|
||||
ai.provider AS approval_provider,
|
||||
ai.status AS approval_status,
|
||||
wt.amount AS actual_amount,
|
||||
wt.balance_before,
|
||||
wt.balance_after,
|
||||
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
|
||||
FROM jsonb_array_elements_text(COALESCE(r.payment_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
|
||||
`).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
|
||||
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.user_id").
|
||||
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
|
||||
Joins(`LEFT JOIN LATERAL (
|
||||
SELECT t.amount, t.balance_before, t.balance_after
|
||||
FROM tb_agent_wallet_transaction AS t
|
||||
WHERE t.reference_type = ?
|
||||
AND t.reference_id = r.id
|
||||
AND t.transaction_type = ?
|
||||
AND t.status = ?
|
||||
AND t.deleted_at IS NULL
|
||||
ORDER BY t.id ASC
|
||||
LIMIT 1
|
||||
) AS wt ON TRUE`, constants.ReferenceTypeTopup, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
|
||||
Order("r.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
item.RechargeNo,
|
||||
item.ShopName,
|
||||
formatRechargeType(item.PaymentMethod),
|
||||
formatMoneyYuan(item.Amount),
|
||||
formatOptionalMoneyYuan(item.ActualAmount),
|
||||
formatOptionalMoneyYuan(item.BalanceBefore),
|
||||
formatOptionalMoneyYuan(item.BalanceAfter),
|
||||
constants.GetRechargeStatusName(item.Status),
|
||||
constants.GetBusinessPaymentMethodName(item.PaymentMethod),
|
||||
item.Remark,
|
||||
item.RejectionReason,
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
formatOptionalTime(item.PaidAt),
|
||||
formatOptionalTime(item.CompletedAt),
|
||||
item.SubmitterName,
|
||||
formatApprovalProvider(item.ApprovalProvider),
|
||||
formatOptionalApprovalStatus(item.ApprovalStatus),
|
||||
item.VoucherKeys,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
query = applyExportShopScope(query, params, "r.shop_id")
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("r.shop_id = ?", shopID)
|
||||
}
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
if start, ok := filterTime(params.Filters, "start_date"); ok {
|
||||
query = query.Where("r.created_at >= ?", start)
|
||||
}
|
||||
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
|
||||
query = query.Where("r.created_at <= ?", end)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type agentRechargeExportRow struct {
|
||||
RechargeNo string `gorm:"column:recharge_no"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
ActualAmount *int64 `gorm:"column:actual_amount"`
|
||||
BalanceBefore *int64 `gorm:"column:balance_before"`
|
||||
BalanceAfter *int64 `gorm:"column:balance_after"`
|
||||
PaymentMethod string `gorm:"column:payment_method"`
|
||||
Status int `gorm:"column:status"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
RejectionReason string `gorm:"column:rejection_reason"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
SubmitterName string `gorm:"column:submitter_name"`
|
||||
ApprovalProvider *string `gorm:"column:approval_provider"`
|
||||
ApprovalStatus *int `gorm:"column:approval_status"`
|
||||
VoucherKeys string `gorm:"column:voucher_keys"`
|
||||
}
|
||||
|
||||
func formatRechargeType(paymentMethod string) string {
|
||||
if paymentMethod == constants.RechargeMethodOffline {
|
||||
return "员工线下代充值"
|
||||
}
|
||||
return "在线充值"
|
||||
}
|
||||
|
||||
func formatOptionalMoneyYuan(value *int64) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return formatMoneyYuan(*value)
|
||||
}
|
||||
|
||||
func formatApprovalProvider(provider *string) string {
|
||||
if provider == nil || *provider == "" {
|
||||
return ""
|
||||
}
|
||||
if *provider == constants.IntegrationProviderWeCom {
|
||||
return "企业微信"
|
||||
}
|
||||
return *provider
|
||||
}
|
||||
|
||||
func formatOptionalApprovalStatus(status *int) string {
|
||||
if status == nil {
|
||||
return ""
|
||||
}
|
||||
return constants.GetApprovalStatusName(*status)
|
||||
}
|
||||
168
internal/exporter/agent_wallet_transaction_scene.go
Normal file
168
internal/exporter/agent_wallet_transaction_scene.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// AgentWalletTransactionDataSource 代理主钱包流水导出数据源。
|
||||
type AgentWalletTransactionDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAgentWalletTransactionDataSource 创建代理主钱包流水导出数据源。
|
||||
func NewAgentWalletTransactionDataSource(db *gorm.DB) *AgentWalletTransactionDataSource {
|
||||
return &AgentWalletTransactionDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *AgentWalletTransactionDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneAgentWalletTransaction
|
||||
}
|
||||
|
||||
// Count 统计代理主钱包流水导出行数。
|
||||
func (s *AgentWalletTransactionDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回代理主钱包流水导出表头。
|
||||
func (s *AgentWalletTransactionDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"店铺名称", "交易类型", "交易金额(元)", "状态", "资产类型", "资产标识", "交易时间",
|
||||
"交易前金额(元)", "交易后金额(元)", "购买套餐名称", "操作人", "交易ID", "关联业务订单号", "交易渠道/支付方式",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询代理主钱包流水导出数据。
|
||||
func (s *AgentWalletTransactionDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var items []agentWalletTransactionExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
t.id,
|
||||
t.transaction_type,
|
||||
t.amount,
|
||||
t.status,
|
||||
t.asset_type,
|
||||
t.asset_identifier,
|
||||
t.created_at,
|
||||
t.balance_before,
|
||||
t.balance_after,
|
||||
COALESCE(sh.shop_name, '') AS shop_name,
|
||||
COALESCE(items.package_names, t.metadata ->> 'package_name', '') AS package_name,
|
||||
COALESCE(ac.username, '') AS operator_name,
|
||||
COALESCE(
|
||||
CASE t.reference_type
|
||||
WHEN ? THEN o.order_no
|
||||
WHEN ? THEN ar.recharge_no
|
||||
WHEN ? THEN rr.refund_no
|
||||
WHEN ? THEN wr.withdrawal_no
|
||||
WHEN ? THEN ex.exchange_no
|
||||
END,
|
||||
''
|
||||
) AS business_order_no,
|
||||
COALESCE(t.metadata ->> 'payment_method', '') AS payment_method
|
||||
`, constants.ReferenceTypeOrder, constants.ReferenceTypeTopup, constants.ReferenceTypeRefund, constants.ReferenceTypeWithdrawal, constants.ReferenceTypeExchange).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = t.shop_id").
|
||||
Joins("LEFT JOIN tb_account AS ac ON ac.id = COALESCE(NULLIF(t.creator, 0), t.user_id)").
|
||||
Joins("LEFT JOIN tb_order AS o ON t.reference_type = ? AND o.id = t.reference_id AND o.deleted_at IS NULL", constants.ReferenceTypeOrder).
|
||||
Joins("LEFT JOIN tb_agent_recharge_record AS ar ON t.reference_type = ? AND ar.id = t.reference_id AND ar.deleted_at IS NULL", constants.ReferenceTypeTopup).
|
||||
Joins("LEFT JOIN tb_refund_request AS rr ON t.reference_type = ? AND rr.id = t.reference_id AND rr.deleted_at IS NULL", constants.ReferenceTypeRefund).
|
||||
Joins("LEFT JOIN tb_commission_withdrawal_request AS wr ON t.reference_type = ? AND wr.id = t.reference_id AND wr.deleted_at IS NULL", constants.ReferenceTypeWithdrawal).
|
||||
Joins("LEFT JOIN tb_exchange_order AS ex ON t.reference_type = ? AND ex.id = t.reference_id AND ex.deleted_at IS NULL", constants.ReferenceTypeExchange).
|
||||
Joins(`LEFT JOIN LATERAL (
|
||||
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
|
||||
FROM tb_order_item AS oi
|
||||
WHERE t.reference_type = ?
|
||||
AND oi.order_id = t.reference_id
|
||||
AND oi.deleted_at IS NULL
|
||||
) AS items ON TRUE`, constants.ReferenceTypeOrder).
|
||||
Order("t.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
paymentMethod := item.PaymentMethod
|
||||
if paymentMethod == "" && item.TransactionType == constants.AgentTransactionTypeDeduct {
|
||||
paymentMethod = constants.PaymentMethodWallet
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
item.ShopName,
|
||||
constants.GetAgentTransactionTypeName(item.TransactionType),
|
||||
formatMoneyYuan(item.Amount),
|
||||
constants.GetTransactionStatusName(item.Status),
|
||||
constants.GetWalletAssetTypeName(item.AssetType),
|
||||
item.AssetIdentifier,
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
formatMoneyYuan(item.BalanceBefore),
|
||||
formatMoneyYuan(item.BalanceAfter),
|
||||
item.PackageName,
|
||||
item.OperatorName,
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.BusinessOrderNo,
|
||||
constants.GetBusinessPaymentMethodName(paymentMethod),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AgentWalletTransactionDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).
|
||||
Table("tb_agent_wallet_transaction AS t").
|
||||
Joins("INNER JOIN tb_agent_wallet AS w ON w.id = t.agent_wallet_id AND w.wallet_type = ? AND w.deleted_at IS NULL", constants.AgentWalletTypeMain).
|
||||
Where("t.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *AgentWalletTransactionDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
query = applyExportShopScope(query, params, "t.shop_id")
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("t.shop_id = ?", shopID)
|
||||
}
|
||||
if transactionType, ok := filterString(params.Filters, "transaction_type"); ok {
|
||||
query = query.Where("t.transaction_type = ?", transactionType)
|
||||
}
|
||||
if start, ok := filterTime(params.Filters, "start_date"); ok {
|
||||
query = query.Where("t.created_at >= ?", start)
|
||||
}
|
||||
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
|
||||
query = query.Where("t.created_at <= ?", end)
|
||||
}
|
||||
if assetIdentifier, ok := filterString(params.Filters, "asset_identifier"); ok {
|
||||
query = query.Where("t.asset_identifier = ?", assetIdentifier)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type agentWalletTransactionExportRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
TransactionType string `gorm:"column:transaction_type"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
Status int `gorm:"column:status"`
|
||||
AssetType string `gorm:"column:asset_type"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
BalanceBefore int64 `gorm:"column:balance_before"`
|
||||
BalanceAfter int64 `gorm:"column:balance_after"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
OperatorName string `gorm:"column:operator_name"`
|
||||
BusinessOrderNo string `gorm:"column:business_order_no"`
|
||||
PaymentMethod string `gorm:"column:payment_method"`
|
||||
}
|
||||
176
internal/exporter/exchange_scene.go
Normal file
176
internal/exporter/exchange_scene.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ExchangeDataSource 换货记录导出数据源。
|
||||
type ExchangeDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewExchangeDataSource 创建换货记录导出数据源。
|
||||
func NewExchangeDataSource(db *gorm.DB) *ExchangeDataSource {
|
||||
return &ExchangeDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *ExchangeDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneExchange
|
||||
}
|
||||
|
||||
// Count 统计换货记录导出行数。
|
||||
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回换货记录导出表头。
|
||||
func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符",
|
||||
"收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询换货记录导出数据。
|
||||
func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var items []exchangeExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
e.exchange_no,
|
||||
e.flow_type,
|
||||
e.exchange_reason,
|
||||
COALESCE(e.remark, '') AS remark,
|
||||
e.old_asset_type,
|
||||
e.old_asset_identifier,
|
||||
e.new_asset_identifier,
|
||||
e.recipient_name,
|
||||
e.recipient_phone,
|
||||
e.recipient_address,
|
||||
e.express_company,
|
||||
e.express_no,
|
||||
e.status,
|
||||
e.created_at,
|
||||
COALESCE(ac.username, '') AS creator_name
|
||||
`).
|
||||
Joins("LEFT JOIN tb_account AS ac ON ac.id = e.creator").
|
||||
Order("e.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
item.ExchangeNo,
|
||||
constants.GetExchangeFlowTypeName(item.FlowType),
|
||||
item.ExchangeReason,
|
||||
item.Remark,
|
||||
formatExchangeAssetType(item.OldAssetType),
|
||||
item.OldAssetIdentifier,
|
||||
item.NewAssetIdentifier,
|
||||
item.RecipientName,
|
||||
item.RecipientPhone,
|
||||
item.RecipientAddress,
|
||||
item.ExpressCompany,
|
||||
item.ExpressNo,
|
||||
constants.GetExchangeStatusName(item.Status),
|
||||
item.CreatorName,
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
query = applyExportShopScope(query, params, "e.shop_id")
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("e.status = ?", status)
|
||||
}
|
||||
if flowType, ok := filterString(params.Filters, "flow_type"); ok {
|
||||
query = query.Where("COALESCE(NULLIF(e.flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType)
|
||||
}
|
||||
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
|
||||
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
|
||||
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
|
||||
query = query.Where("e.created_at >= ?", start)
|
||||
}
|
||||
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
|
||||
query = query.Where("e.created_at <= ?", end)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
like := "%" + keyword + "%"
|
||||
cardIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_iot_card").Select("id").
|
||||
Where("deleted_at IS NULL").
|
||||
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
|
||||
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_device").Select("id").
|
||||
Where("deleted_at IS NULL").
|
||||
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
|
||||
prefix := "e." + side
|
||||
return query.Where(
|
||||
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
|
||||
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
|
||||
)
|
||||
}
|
||||
|
||||
func filterValue(filters map[string]any, key string) string {
|
||||
value, _ := filterString(filters, key)
|
||||
return value
|
||||
}
|
||||
|
||||
func formatExchangeAssetType(assetType string) string {
|
||||
switch assetType {
|
||||
case constants.ExchangeAssetTypeIotCard:
|
||||
return "物联网卡"
|
||||
case constants.ExchangeAssetTypeDevice:
|
||||
return "设备"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
type exchangeExportRow struct {
|
||||
ExchangeNo string `gorm:"column:exchange_no"`
|
||||
FlowType string `gorm:"column:flow_type"`
|
||||
ExchangeReason string `gorm:"column:exchange_reason"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
OldAssetType string `gorm:"column:old_asset_type"`
|
||||
OldAssetIdentifier string `gorm:"column:old_asset_identifier"`
|
||||
NewAssetIdentifier string `gorm:"column:new_asset_identifier"`
|
||||
RecipientName string `gorm:"column:recipient_name"`
|
||||
RecipientPhone string `gorm:"column:recipient_phone"`
|
||||
RecipientAddress string `gorm:"column:recipient_address"`
|
||||
ExpressCompany string `gorm:"column:express_company"`
|
||||
ExpressNo string `gorm:"column:express_no"`
|
||||
Status int `gorm:"column:status"`
|
||||
CreatorName string `gorm:"column:creator_name"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
@@ -255,6 +255,18 @@ func filterTime(filters map[string]any, key string) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// filterEndDate 解析截止时间;纯日期输入覆盖到当天结束,带时分秒输入保持原值。
|
||||
func filterEndDate(filters map[string]any, key string) (time.Time, bool) {
|
||||
text, ok := filterString(filters, key)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if parsed, err := time.ParseInLocation("2006-01-02", text, time.Local); err == nil {
|
||||
return parsed.Add(24*time.Hour - time.Nanosecond), true
|
||||
}
|
||||
return filterTime(filters, key)
|
||||
}
|
||||
|
||||
func formatOptionalUint(value *uint) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
|
||||
@@ -2,6 +2,7 @@ package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -36,7 +37,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
|
||||
|
||||
// Headers 返回 IoT 卡导出表头。
|
||||
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态"}, nil
|
||||
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
|
||||
@@ -56,7 +57,10 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
|
||||
COALESCE(d.device_name, '') AS device_name,
|
||||
c.real_name_status,
|
||||
c.first_realname_at,
|
||||
c.network_status
|
||||
c.network_status,
|
||||
COALESCE(pkg.package_name, '') AS package_name,
|
||||
COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb,
|
||||
COALESCE(pkg.data_limit_mb, 0) AS data_limit_mb
|
||||
`).
|
||||
Order("c.id ASC").
|
||||
Limit(limit).
|
||||
@@ -77,6 +81,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
|
||||
formatRealNameVerified(item.RealNameStatus),
|
||||
formatOptionalTime(item.FirstRealnameAt),
|
||||
constants.GetNetworkStatusName(item.NetworkStatus),
|
||||
item.PackageName,
|
||||
strconv.FormatInt(item.DataUsageMB, 10),
|
||||
strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
@@ -100,6 +107,22 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
LIMIT 1
|
||||
) AS d ON TRUE
|
||||
`, constants.BindStatusBound).
|
||||
Joins(`
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
|
||||
pu.data_usage_mb,
|
||||
pu.data_limit_mb
|
||||
FROM tb_package_usage AS pu
|
||||
LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL
|
||||
WHERE pu.iot_card_id = c.id
|
||||
AND pu.status = ?
|
||||
AND pu.master_usage_id IS NULL
|
||||
AND pu.deleted_at IS NULL
|
||||
ORDER BY pu.priority ASC, pu.activated_at ASC, pu.id ASC
|
||||
LIMIT 1
|
||||
) AS pkg ON TRUE
|
||||
`, constants.PackageUsageStatusActive).
|
||||
Where("c.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
@@ -206,6 +229,17 @@ type iotCardExportRow struct {
|
||||
RealNameStatus int `gorm:"column:real_name_status"`
|
||||
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
|
||||
NetworkStatus int `gorm:"column:network_status"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb"`
|
||||
}
|
||||
|
||||
func remainingPackageDataMB(limit, used int64) int64 {
|
||||
remaining := limit - used
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func formatRealNameVerified(status int) string {
|
||||
|
||||
228
internal/exporter/package_scene.go
Normal file
228
internal/exporter/package_scene.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PackageDataSource 套餐导出数据源。
|
||||
type PackageDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPackageDataSource 创建套餐导出数据源。
|
||||
func NewPackageDataSource(db *gorm.DB) *PackageDataSource {
|
||||
return &PackageDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *PackageDataSource) Scene() string {
|
||||
return constants.ExportTaskScenePackage
|
||||
}
|
||||
|
||||
// Count 统计套餐导出行数。
|
||||
func (s *PackageDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx, params), params)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回套餐导出固定表头。
|
||||
func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"套餐编码", "套餐名称", "套餐系列名称", "套餐类型", "套餐时长(月)",
|
||||
"套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)",
|
||||
"是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)",
|
||||
"建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐",
|
||||
"创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询套餐导出数据。
|
||||
func (s *PackageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var items []packageExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx, params), params).
|
||||
Joins("LEFT JOIN tb_package_series AS ps ON ps.id = p.series_id AND ps.deleted_at IS NULL").
|
||||
Select(s.selectColumns(params)).
|
||||
Order("p.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, buildPackageExportRow(item))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *PackageDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
|
||||
query := s.db.WithContext(ctx).Table("tb_package AS p").Where("p.deleted_at IS NULL")
|
||||
switch params.UserType {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
return query
|
||||
case constants.UserTypeAgent:
|
||||
if params.CreatorShopID == nil || *params.CreatorShopID == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.
|
||||
Joins(`INNER JOIN tb_shop_package_allocation AS a
|
||||
ON a.package_id = p.id
|
||||
AND a.shop_id = ?
|
||||
AND a.status = ?
|
||||
AND a.deleted_at IS NULL`, *params.CreatorShopID, constants.StatusEnabled).
|
||||
Where("p.is_gift = ?", false)
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
if packageName, ok := filterString(params.Filters, "package_name"); ok {
|
||||
query = query.Where("p.package_name LIKE ?", "%"+packageName+"%")
|
||||
}
|
||||
if seriesID, ok := filterUint(params.Filters, "series_id"); ok {
|
||||
query = query.Where("p.series_id = ?", seriesID)
|
||||
}
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("p.status = ?", status)
|
||||
}
|
||||
if shelfStatus, ok := filterInt(params.Filters, "shelf_status"); ok {
|
||||
if params.UserType == constants.UserTypeAgent {
|
||||
query = query.Where("a.shelf_status = ?", shelfStatus)
|
||||
} else {
|
||||
query = query.Where("p.shelf_status = ?", shelfStatus)
|
||||
}
|
||||
}
|
||||
if packageType, ok := filterString(params.Filters, "package_type"); ok {
|
||||
query = query.Where("p.package_type = ?", packageType)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (s *PackageDataSource) selectColumns(params ExportParams) string {
|
||||
costPriceColumn := "p.cost_price"
|
||||
shelfStatusColumn := "p.shelf_status"
|
||||
if params.UserType == constants.UserTypeAgent {
|
||||
costPriceColumn = "a.cost_price"
|
||||
shelfStatusColumn = "a.shelf_status"
|
||||
}
|
||||
return `
|
||||
p.package_code,
|
||||
p.package_name,
|
||||
COALESCE(ps.series_name, '') AS series_name,
|
||||
p.package_type,
|
||||
p.duration_months,
|
||||
p.calendar_type,
|
||||
p.duration_days,
|
||||
p.real_data_mb,
|
||||
p.virtual_data_mb,
|
||||
p.enable_virtual_data,
|
||||
p.virtual_ratio,
|
||||
p.data_reset_cycle,
|
||||
p.expiry_base,
|
||||
` + costPriceColumn + ` AS cost_price,
|
||||
p.suggested_retail_price,
|
||||
p.price_config_status,
|
||||
p.status,
|
||||
` + shelfStatusColumn + ` AS shelf_status,
|
||||
p.is_gift,
|
||||
p.creator,
|
||||
p.updater,
|
||||
p.created_at,
|
||||
p.updated_at`
|
||||
}
|
||||
|
||||
type packageExportRow struct {
|
||||
PackageCode string `gorm:"column:package_code"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
SeriesName string `gorm:"column:series_name"`
|
||||
PackageType string `gorm:"column:package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months"`
|
||||
CalendarType string `gorm:"column:calendar_type"`
|
||||
DurationDays int `gorm:"column:duration_days"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle"`
|
||||
ExpiryBase string `gorm:"column:expiry_base"`
|
||||
CostPrice int64 `gorm:"column:cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price"`
|
||||
PriceConfigStatus int `gorm:"column:price_config_status"`
|
||||
Status int `gorm:"column:status"`
|
||||
ShelfStatus int `gorm:"column:shelf_status"`
|
||||
IsGift bool `gorm:"column:is_gift"`
|
||||
Creator uint `gorm:"column:creator"`
|
||||
Updater uint `gorm:"column:updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func buildPackageExportRow(item packageExportRow) []string {
|
||||
return []string{
|
||||
item.PackageCode,
|
||||
item.PackageName,
|
||||
item.SeriesName,
|
||||
constants.GetPackageTypeName(item.PackageType),
|
||||
strconv.Itoa(item.DurationMonths),
|
||||
formatPackageDurationDescription(item),
|
||||
constants.GetPackageCalendarTypeName(item.CalendarType),
|
||||
strconv.Itoa(item.DurationDays),
|
||||
strconv.FormatInt(item.RealDataMB, 10),
|
||||
strconv.FormatInt(item.VirtualDataMB, 10),
|
||||
formatYesNo(item.EnableVirtualData),
|
||||
strconv.FormatFloat(item.VirtualRatio, 'f', 6, 64),
|
||||
constants.GetPackageDataResetCycleName(item.DataResetCycle),
|
||||
constants.GetPackageExpiryBaseName(item.ExpiryBase),
|
||||
formatMoneyYuan(item.CostPrice),
|
||||
formatPackageSuggestedRetailPrice(item),
|
||||
constants.GetPackagePriceConfigStatusName(item.PriceConfigStatus),
|
||||
constants.GetStatusName(item.Status),
|
||||
constants.GetShelfStatusName(item.ShelfStatus),
|
||||
formatYesNo(item.IsGift),
|
||||
strconv.FormatUint(uint64(item.Creator), 10),
|
||||
strconv.FormatUint(uint64(item.Updater), 10),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
item.UpdatedAt.Format(exportTimeLayout),
|
||||
"",
|
||||
}
|
||||
}
|
||||
|
||||
func formatPackageDurationDescription(item packageExportRow) string {
|
||||
if item.CalendarType == constants.PackageCalendarTypeByDay && item.DurationDays > 0 {
|
||||
return strconv.Itoa(item.DurationDays) + "天"
|
||||
}
|
||||
if item.DurationMonths > 0 {
|
||||
return strconv.Itoa(item.DurationMonths) + "个月"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatPackageSuggestedRetailPrice(item packageExportRow) string {
|
||||
if item.PriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
|
||||
return ""
|
||||
}
|
||||
return formatMoneyYuan(item.SuggestedRetailPrice)
|
||||
}
|
||||
|
||||
func formatYesNo(value bool) string {
|
||||
if value {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
206
internal/exporter/refund_scene.go
Normal file
206
internal/exporter/refund_scene.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// RefundDataSource 退款记录导出数据源。
|
||||
type RefundDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRefundDataSource 创建退款记录导出数据源。
|
||||
func NewRefundDataSource(db *gorm.DB) *RefundDataSource {
|
||||
return &RefundDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *RefundDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneRefund
|
||||
}
|
||||
|
||||
// Count 统计退款记录导出行数。
|
||||
func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
||||
var total int64
|
||||
query := s.applyFilters(s.baseQuery(ctx), params)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// Headers 返回退款记录导出表头。
|
||||
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
||||
return []string{
|
||||
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
|
||||
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
|
||||
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询退款记录导出数据。
|
||||
func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var items []refundExportRow
|
||||
query := s.applyFilters(s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
r.refund_no,
|
||||
r.order_no,
|
||||
r.order_type,
|
||||
r.asset_identifier,
|
||||
r.actual_received_amount,
|
||||
r.requested_refund_amount,
|
||||
r.approved_refund_amount,
|
||||
r.status,
|
||||
r.refund_reason,
|
||||
r.remark,
|
||||
r.commission_deducted,
|
||||
r.asset_reset,
|
||||
r.created_at,
|
||||
r.processed_at,
|
||||
COALESCE(sh.shop_name, '') AS shop_name,
|
||||
o.total_amount AS original_amount,
|
||||
o.actual_paid_amount AS refundable_amount,
|
||||
COALESCE(pu.package_name, items.package_names, '') AS package_name,
|
||||
COALESCE(ac.username, '') AS submitter_name,
|
||||
ai.provider AS approval_provider,
|
||||
ai.status AS approval_status,
|
||||
COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality)
|
||||
FROM jsonb_array_elements_text(COALESCE(r.refund_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys
|
||||
`).
|
||||
Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id").
|
||||
Joins("LEFT JOIN tb_order AS o ON o.id = r.order_id AND o.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_package_usage AS pu ON pu.id = r.package_usage_id AND pu.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_account AS ac ON ac.id = r.creator").
|
||||
Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id").
|
||||
Joins(`LEFT JOIN LATERAL (
|
||||
SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names
|
||||
FROM tb_order_item AS oi
|
||||
WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL
|
||||
) AS items ON TRUE`).
|
||||
Order("r.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
item.RefundNo,
|
||||
item.ShopName,
|
||||
item.OrderNo,
|
||||
formatRefundAssetType(item.OrderType),
|
||||
item.AssetIdentifier,
|
||||
item.PackageName,
|
||||
formatOptionalMoneyYuan(item.OriginalAmount),
|
||||
formatMoneyYuan(item.ActualReceivedAmount),
|
||||
formatOptionalMoneyYuan(item.RefundableAmount),
|
||||
formatMoneyYuan(item.RequestedRefundAmount),
|
||||
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
|
||||
constants.GetRefundStatusName(item.Status),
|
||||
item.RefundReason,
|
||||
item.Remark,
|
||||
formatRefundApprovalSource(item.ApprovalProvider),
|
||||
formatOptionalApprovalStatus(item.ApprovalStatus),
|
||||
formatRefundProcessingStatus(item.Status, item.CommissionDeducted, item.AssetReset),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
formatOptionalTime(item.ProcessedAt),
|
||||
item.SubmitterName,
|
||||
item.VoucherKeys,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *RefundDataSource) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("tb_refund_request AS r").Where("r.deleted_at IS NULL")
|
||||
}
|
||||
|
||||
func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
query = applyExportShopScope(query, params, "r.shop_id")
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
if orderID, ok := filterUint(params.Filters, "order_id"); ok {
|
||||
query = query.Where("r.order_id = ?", orderID)
|
||||
}
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("r.shop_id = ?", shopID)
|
||||
}
|
||||
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
|
||||
query = query.Where("r.asset_identifier = ?", identifier)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type refundExportRow struct {
|
||||
RefundNo string `gorm:"column:refund_no"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
OrderNo string `gorm:"column:order_no"`
|
||||
OrderType string `gorm:"column:order_type"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
OriginalAmount *int64 `gorm:"column:original_amount"`
|
||||
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
|
||||
RefundableAmount *int64 `gorm:"column:refundable_amount"`
|
||||
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
|
||||
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
|
||||
Status int `gorm:"column:status"`
|
||||
RefundReason string `gorm:"column:refund_reason"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
ApprovalProvider *string `gorm:"column:approval_provider"`
|
||||
ApprovalStatus *int `gorm:"column:approval_status"`
|
||||
CommissionDeducted bool `gorm:"column:commission_deducted"`
|
||||
AssetReset bool `gorm:"column:asset_reset"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at"`
|
||||
SubmitterName string `gorm:"column:submitter_name"`
|
||||
VoucherKeys string `gorm:"column:voucher_keys"`
|
||||
}
|
||||
|
||||
func formatRefundAssetType(orderType string) string {
|
||||
switch orderType {
|
||||
case model.OrderTypeSingleCard:
|
||||
return "物联网卡"
|
||||
case model.OrderTypeDevice:
|
||||
return "设备"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
func formatRefundApprovalSource(provider *string) string {
|
||||
if provider == nil || *provider == "" {
|
||||
return "历史审批"
|
||||
}
|
||||
return formatApprovalProvider(provider)
|
||||
}
|
||||
|
||||
func formatRefundProcessingStatus(status int, commissionDeducted, assetReset bool) string {
|
||||
switch status {
|
||||
case model.RefundStatusPending, model.RefundStatusReturned:
|
||||
return "待审批"
|
||||
case model.RefundStatusRejected:
|
||||
return "无需处理"
|
||||
case model.RefundStatusApproved:
|
||||
if commissionDeducted && assetReset {
|
||||
return "已完成"
|
||||
}
|
||||
return "处理中"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
|
||||
NewDeviceDataSource(db),
|
||||
NewIotCardDataSource(db),
|
||||
NewOrderDataSource(db),
|
||||
NewPackageDataSource(db),
|
||||
NewAgentWalletTransactionDataSource(db),
|
||||
NewAgentRechargeDataSource(db),
|
||||
NewRefundDataSource(db),
|
||||
NewExchangeDataSource(db),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +64,14 @@ func (r *Registry) Scenes() []string {
|
||||
// IsSupportedScene 判断是否为受支持的场景。
|
||||
func IsSupportedScene(scene string) bool {
|
||||
switch scene {
|
||||
case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneOrder:
|
||||
case constants.ExportTaskSceneDevice,
|
||||
constants.ExportTaskSceneIotCard,
|
||||
constants.ExportTaskSceneOrder,
|
||||
constants.ExportTaskScenePackage,
|
||||
constants.ExportTaskSceneAgentWalletTransaction,
|
||||
constants.ExportTaskSceneAgentRecharge,
|
||||
constants.ExportTaskSceneRefund,
|
||||
constants.ExportTaskSceneExchange:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -27,14 +27,6 @@ func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfo
|
||||
return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req)
|
||||
}
|
||||
|
||||
// SetSpeedLimit 设置设备限速
|
||||
// 设置设备的统一限速值(单位 KB/s)
|
||||
// POST /device/speed-limit
|
||||
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
|
||||
_, err := c.doRequest(ctx, "/device/speed-limit", req)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetWiFi 设置设备 WiFi
|
||||
// Gateway 实际要求 cardNo 传设备 IMEI,并搭配内层 params 下发 WiFi 名称和密码
|
||||
// POST /device/wifi-config
|
||||
|
||||
@@ -45,6 +45,19 @@ func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*Real
|
||||
return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req)
|
||||
}
|
||||
|
||||
// SetCardSpeedTier 设置或恢复流量卡固定限速档位。
|
||||
// POST /flow-card/speedLimit
|
||||
func (c *Client) SetCardSpeedTier(ctx context.Context, req *CardSpeedTierReq) error {
|
||||
if req == nil || req.CardNo == "" || req.Code == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "流量卡号和限速档位不能为空")
|
||||
}
|
||||
// 限速是外部状态写入,网络错误或超时后实际结果不可确定,禁止沿用查询接口的自动重试。
|
||||
requestClient := *c
|
||||
requestClient.maxRetries = 0
|
||||
_, err := requestClient.doRequest(ctx, "/flow-card/speedLimit", req)
|
||||
return err
|
||||
}
|
||||
|
||||
// BatchQuery 批量查询(预留接口,暂未实现)
|
||||
func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) {
|
||||
return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现")
|
||||
|
||||
@@ -187,12 +187,10 @@ type DeviceInfoResp struct {
|
||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||
}
|
||||
|
||||
// SpeedLimitReq 是设置设备限速的请求
|
||||
type SpeedLimitReq struct {
|
||||
CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"`
|
||||
DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI(与 CardNo 二选一)"`
|
||||
SpeedLimit int `json:"speedLimit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值(KB/s)"`
|
||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||
// CardSpeedTierReq 是流量卡固定限速档位请求。
|
||||
type CardSpeedTierReq struct {
|
||||
CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡 ICCID"`
|
||||
Code string `json:"code" validate:"required" required:"true" description:"限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"`
|
||||
}
|
||||
|
||||
// WiFiParams 是设置设备 WiFi 的内层参数
|
||||
|
||||
@@ -77,6 +77,24 @@ func (h *AccountHandler) Update(c *fiber.Ctx) error {
|
||||
return response.Success(c, account)
|
||||
}
|
||||
|
||||
// BindWeCom 绑定账号与企业微信应用可见成员。
|
||||
// PUT /api/admin/accounts/:id/wecom-binding
|
||||
func (h *AccountHandler) BindWeCom(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
|
||||
}
|
||||
var request dto.BindAccountWeComRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.BindWeCom(c.UserContext(), uint(id), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Delete 删除账号
|
||||
// DELETE /api/admin/accounts/:id
|
||||
func (h *AccountHandler) Delete(c *fiber.Ctx) error {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
@@ -17,8 +18,10 @@ import (
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AssetHandler 资产管理处理器
|
||||
@@ -33,6 +36,7 @@ type AssetHandler struct {
|
||||
assetLifecycleService AssetLifecycleService
|
||||
exchangeTraceQuery AssetExchangeTraceResolver
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
packageExpiryQuery *packageExpiryQuery.Query
|
||||
}
|
||||
|
||||
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
|
||||
@@ -40,6 +44,11 @@ func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservation
|
||||
h.observationSeries = dispatcher
|
||||
}
|
||||
|
||||
// SetPackageExpiryQuery 注入临期资产分页查询。
|
||||
func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
|
||||
h.packageExpiryQuery = query
|
||||
}
|
||||
|
||||
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
|
||||
type AssetExchangeTraceResolver interface {
|
||||
Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error)
|
||||
@@ -105,6 +114,27 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListExpiring 查询当前权限范围内的临期资产列表和数量汇总。
|
||||
// GET /api/admin/expiring-assets
|
||||
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
|
||||
var request dto.ExpiringAssetListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
|
||||
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if h.packageExpiryQuery == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
|
||||
}
|
||||
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, dto.ExpiringAssetListResponse{
|
||||
Items: result.Items, Total: result.Total, Page: result.Page, Size: result.Size, Summary: result.Summary,
|
||||
})
|
||||
}
|
||||
|
||||
// RealtimeStatus 获取资产实时状态
|
||||
// GET /api/admin/assets/:identifier/realtime-status
|
||||
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||
@@ -463,6 +493,10 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
|
||||
// UpdateRealnamePolicy 更新资产实名认证策略
|
||||
// PATCH /api/admin/assets/:identifier/realname-mode
|
||||
func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
|
||||
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权修改资产实名认证策略")
|
||||
}
|
||||
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
|
||||
92
internal/handler/admin/asset_package_batch_order.go
Normal file
92
internal/handler/admin/asset_package_batch_order.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
batchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// AssetPackageBatchOrderHandler 资产套餐批量订购 Handler。
|
||||
type AssetPackageBatchOrderHandler struct {
|
||||
service *batchOrderSvc.Service
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewAssetPackageBatchOrderHandler 创建资产套餐批量订购 Handler。
|
||||
func NewAssetPackageBatchOrderHandler(service *batchOrderSvc.Service, validator *validator.Validate) *AssetPackageBatchOrderHandler {
|
||||
return &AssetPackageBatchOrderHandler{service: service, validator: validator}
|
||||
}
|
||||
|
||||
// Create 创建资产套餐批量订购任务。
|
||||
// POST /api/admin/asset-package-batch-orders
|
||||
func (h *AssetPackageBatchOrderHandler) Create(c *fiber.Ctx) error {
|
||||
var req dto.CreateAssetPackageBatchOrderRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
|
||||
}
|
||||
if req.PaymentMethod == model.PaymentMethodWallet && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
|
||||
return errors.New(errors.CodeForbidden, "无权创建批量订购任务")
|
||||
}
|
||||
result, err := h.service.Create(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// List 查询资产套餐批量订购任务列表。
|
||||
// GET /api/admin/asset-package-batch-orders
|
||||
func (h *AssetPackageBatchOrderHandler) List(c *fiber.Ctx) error {
|
||||
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
|
||||
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
|
||||
}
|
||||
var req dto.ListAssetPackageBatchOrderRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.List(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Get 查询资产套餐批量订购任务详情。
|
||||
// GET /api/admin/asset-package-batch-orders/:id
|
||||
func (h *AssetPackageBatchOrderHandler) Get(c *fiber.Ctx) error {
|
||||
if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) {
|
||||
return errors.New(errors.CodeForbidden, "无权查询批量订购任务")
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "任务ID无效")
|
||||
}
|
||||
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func canAccessAssetPackageBatchOrders(userType int) bool {
|
||||
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform || userType == constants.UserTypeAgent
|
||||
}
|
||||
@@ -35,6 +35,20 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// BatchUpdateRealnamePolicy 批量更新设备实名认证策略。
|
||||
// POST /api/admin/devices/batch-update-realname-policy
|
||||
func (h *DeviceHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
|
||||
var req dto.BatchUpdateAssetRealnamePolicyRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
@@ -226,26 +240,6 @@ func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// SetSpeedLimit 设置设备限速
|
||||
// PUT /api/admin/devices/by-identifier/:identifier/speed-limit
|
||||
func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||
}
|
||||
|
||||
var req dto.SetSpeedLimitRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
if err := h.service.GatewaySetSpeedLimit(c.UserContext(), identifier, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// SetWiFi 设置设备 WiFi
|
||||
// PUT /api/admin/devices/by-identifier/:identifier/wifi
|
||||
func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
||||
|
||||
@@ -46,6 +46,20 @@ func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// CreateAllocation 创建单列 CSV 设备批量分配任务。
|
||||
// POST /api/admin/devices/import/allocations
|
||||
func (h *DeviceImportHandler) CreateAllocation(c *fiber.Ctx) error {
|
||||
var req dto.CreateDeviceBatchAllocationRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
result, err := h.service.CreateBatchAllocationTask(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
|
||||
@@ -35,6 +35,38 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// BatchUpdateRealnamePolicy 批量更新卡实名认证策略。
|
||||
// POST /api/admin/iot-cards/batch-update-realname-policy
|
||||
func (h *IotCardHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {
|
||||
var req dto.BatchUpdateAssetRealnamePolicyRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// SetSpeedTier 设置或恢复 IoT 卡固定限速档位。
|
||||
// PUT /api/admin/iot-cards/:iccid/speed-tier
|
||||
func (h *IotCardHandler) SetSpeedTier(c *fiber.Ctx) error {
|
||||
iccid := c.Params("iccid")
|
||||
if iccid == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "ICCID 不能为空")
|
||||
}
|
||||
var req dto.SetIotCardSpeedTierRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
result, err := h.service.SetSpeedTier(c.UserContext(), iccid, req.Code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
|
||||
var req dto.AllocateStandaloneCardsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
|
||||
197
internal/handler/admin/wecom.go
Normal file
197
internal/handler/admin/wecom.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
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/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// WeComHandler 提供企业微信自建应用连接配置接口。
|
||||
type WeComHandler struct {
|
||||
service *wecomapp.ConnectionService
|
||||
directory *wecomapp.DirectoryService
|
||||
scenes *wecomapp.SceneService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// SetSceneService 注入企业微信审批场景配置用例。
|
||||
func (h *WeComHandler) SetSceneService(service *wecomapp.SceneService) {
|
||||
h.scenes = service
|
||||
}
|
||||
|
||||
// NewWeComHandler 创建企业微信连接配置 Handler。
|
||||
func NewWeComHandler(service *wecomapp.ConnectionService, validate *validator.Validate) *WeComHandler {
|
||||
return &WeComHandler{service: service, validator: validate}
|
||||
}
|
||||
|
||||
// SetDirectoryService 注入企业微信通讯录同步用例。
|
||||
func (h *WeComHandler) SetDirectoryService(service *wecomapp.DirectoryService) {
|
||||
h.directory = service
|
||||
}
|
||||
|
||||
// Save 创建或更新企业微信应用连接配置。
|
||||
// POST /api/admin/wecom/applications
|
||||
func (h *WeComHandler) Save(c *fiber.Ctx) error {
|
||||
if h == nil || h.service == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
var request dto.SaveWeComApplicationRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.Save(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// List 查询企业微信应用连接配置。
|
||||
// GET /api/admin/wecom/applications
|
||||
func (h *WeComHandler) List(c *fiber.Ctx) error {
|
||||
if h == nil || h.service == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
var request dto.WeComApplicationListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.List(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Test 测试指定企业微信应用能否成功取得 access_token。
|
||||
// POST /api/admin/wecom/applications/:id/test
|
||||
func (h *WeComHandler) Test(c *fiber.Ctx) error {
|
||||
if h == nil || h.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
|
||||
}
|
||||
if err := h.service.Test(c.UserContext(), uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, dto.WeComConnectionTestResponse{Success: true})
|
||||
}
|
||||
|
||||
// SaveDefaultCreator 保存应用默认审批发起人。
|
||||
// PUT /api/admin/wecom/applications/:id/default-creator
|
||||
func (h *WeComHandler) SaveDefaultCreator(c *fiber.Ctx) error {
|
||||
if h == nil || h.service == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
|
||||
}
|
||||
var request dto.SaveWeComDefaultCreatorRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.service.SaveDefaultCreator(c.UserContext(), uint(id), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// SyncMembers 同步指定应用当前可见成员。
|
||||
// POST /api/admin/wecom/applications/:id/members/sync
|
||||
func (h *WeComHandler) SyncMembers(c *fiber.Ctx) error {
|
||||
if h == nil || h.directory == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
|
||||
}
|
||||
result, err := h.directory.Sync(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListMembers 分页查询指定应用最近同步的可见成员。
|
||||
// GET /api/admin/wecom/applications/:id/members
|
||||
func (h *WeComHandler) ListMembers(c *fiber.Ctx) error {
|
||||
if h == nil || h.directory == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
|
||||
}
|
||||
var request dto.WeComMemberListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.directory.List(c.UserContext(), uint(id), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// SaveScene 保存并校验稳定业务类型的企微模板控件映射。
|
||||
// PUT /api/admin/wecom/scenes/:business_type
|
||||
func (h *WeComHandler) SaveScene(c *fiber.Ctx) error {
|
||||
if h == nil || h.scenes == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
var request dto.SaveWeComApprovalSceneRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.scenes.Save(c.UserContext(), c.Params("business_type"), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListScenes 分页查询企业微信审批场景当前配置。
|
||||
// GET /api/admin/wecom/scenes
|
||||
func (h *WeComHandler) ListScenes(c *fiber.Ctx) error {
|
||||
if h == nil || h.scenes == nil || h.validator == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
var request dto.WeComApprovalSceneListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.scenes.List(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -39,6 +40,21 @@ type ClientAssetHandler struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
paymentMethodPolicy ClientPaymentMethodPolicy
|
||||
forceRechargeChecker ClientForceRechargeChecker
|
||||
}
|
||||
|
||||
// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。
|
||||
type ClientPaymentMethodPolicy interface {
|
||||
AllowedMethods(ctx context.Context, assetType string) ([]string, error)
|
||||
AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error)
|
||||
EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error
|
||||
EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error
|
||||
}
|
||||
|
||||
// ClientForceRechargeChecker 提供 C 端资产当前强充资格的统一判定。
|
||||
type ClientForceRechargeChecker interface {
|
||||
GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*rechargeSvc.ForceRechargeRequirement, error)
|
||||
}
|
||||
|
||||
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
|
||||
@@ -46,6 +62,16 @@ func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObser
|
||||
h.observationSeries = dispatcher
|
||||
}
|
||||
|
||||
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
|
||||
func (h *ClientAssetHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
|
||||
h.paymentMethodPolicy = policy
|
||||
}
|
||||
|
||||
// SetForceRechargeChecker 注入复用现有一次性佣金状态语义的强充判定服务。
|
||||
func (h *ClientAssetHandler) SetForceRechargeChecker(checker ClientForceRechargeChecker) {
|
||||
h.forceRechargeChecker = checker
|
||||
}
|
||||
|
||||
// NewClientAssetHandler 创建 C 端资产信息处理器
|
||||
func NewClientAssetHandler(
|
||||
assetService *asset.Service,
|
||||
@@ -158,6 +184,34 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentPackageID, err := h.getCurrentPackageID(resolved.SkipPermissionCtx, resolved.Asset.CurrentPackageUsageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.paymentMethodPolicy == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig)
|
||||
}
|
||||
if h.forceRechargeChecker == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig)
|
||||
}
|
||||
resourceType := resolved.Asset.AssetType
|
||||
if resourceType == "card" {
|
||||
resourceType = constants.ResourceTypeIotCard
|
||||
}
|
||||
forceRecharge, err := h.forceRechargeChecker.GetRechargeCheck(resolved.SkipPermissionCtx, resourceType, resolved.Asset.AssetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if forceRecharge.NeedForceRecharge {
|
||||
allowedPaymentMethods, err = h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
phone, _ := middleware.GetCustomerPhone(c)
|
||||
|
||||
@@ -172,11 +226,16 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
StatusName: resolved.Asset.StatusName,
|
||||
RealNameStatus: resolved.Asset.RealNameStatus,
|
||||
RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus),
|
||||
RealnamePolicy: resolved.Asset.RealnamePolicy,
|
||||
EffectiveRealnamePolicy: resolved.Asset.RealnamePolicy,
|
||||
RealnameRequired: resolved.Asset.RealnamePolicy != constants.RealnamePolicyNone,
|
||||
CarrierName: resolved.Asset.CarrierName,
|
||||
Generation: strconv.Itoa(resolved.Generation),
|
||||
WalletBalance: resolved.WalletBalance,
|
||||
AllowedPaymentMethods: allowedPaymentMethods,
|
||||
ActivatedAt: resolved.Asset.ActivatedAt,
|
||||
CurrentPackage: resolved.Asset.CurrentPackage,
|
||||
CurrentPackageID: currentPackageID,
|
||||
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
|
||||
CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt,
|
||||
CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt,
|
||||
@@ -226,6 +285,20 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
func (h *ClientAssetHandler) getCurrentPackageID(ctx context.Context, usageID *uint) (uint, error) {
|
||||
if usageID == nil || *usageID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := h.db.WithContext(ctx).Select("package_id").First(&usage, *usageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐引用失败")
|
||||
}
|
||||
return usage.PackageID, nil
|
||||
}
|
||||
|
||||
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
|
||||
if h.observationSeries == nil || asset == nil {
|
||||
return
|
||||
@@ -301,8 +374,9 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
||||
PageSize: constants.MaxPageSize,
|
||||
OrderBy: "id DESC",
|
||||
}, map[string]any{
|
||||
"series_id": *resolved.Asset.SeriesID,
|
||||
"status": constants.StatusEnabled,
|
||||
"series_id": *resolved.Asset.SeriesID,
|
||||
"status": constants.StatusEnabled,
|
||||
"shelf_status": constants.ShelfStatusOn,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
|
||||
|
||||
@@ -45,6 +45,12 @@ type ClientWalletHandler struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
paymentMethodPolicy ClientPaymentMethodPolicy
|
||||
}
|
||||
|
||||
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
|
||||
func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
|
||||
h.paymentMethodPolicy = policy
|
||||
}
|
||||
|
||||
// NewClientWalletHandler 创建 C 端钱包处理器
|
||||
@@ -243,14 +249,22 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.paymentMethodPolicy == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig)
|
||||
}
|
||||
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := &dto.ClientRechargeCheckResponse{
|
||||
NeedForceRecharge: check.NeedForceRecharge,
|
||||
ForceRechargeAmount: check.ForceRechargeAmount,
|
||||
TriggerType: check.TriggerType,
|
||||
MinAmount: check.MinAmount,
|
||||
MaxAmount: check.MaxAmount,
|
||||
Message: check.Message,
|
||||
NeedForceRecharge: check.NeedForceRecharge,
|
||||
ForceRechargeAmount: check.ForceRechargeAmount,
|
||||
TriggerType: check.TriggerType,
|
||||
MinAmount: check.MinAmount,
|
||||
MaxAmount: check.MaxAmount,
|
||||
Message: check.Message,
|
||||
AllowedPaymentMethods: allowedPaymentMethods,
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
@@ -273,6 +287,12 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
|
||||
return errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
if h.paymentMethodPolicy == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig)
|
||||
}
|
||||
if err := h.paymentMethodPolicy.EnsureRechargeAllowed(resolved.SkipPermissionCtx, resolved.Asset.AssetType, req.PaymentMethod); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wallet, err := h.getOrCreateWallet(resolved)
|
||||
if err != nil {
|
||||
@@ -302,9 +322,10 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
switch req.PaymentMethod {
|
||||
case constants.RechargeMethodAlipay:
|
||||
return h.createAlipayRecharge(c, resolved, config, wallet, req)
|
||||
default:
|
||||
// 微信充值(默认):app_type 必填
|
||||
case constants.RechargeMethodWechat:
|
||||
return h.createWechatRecharge(c, resolved, config, wallet, req)
|
||||
default:
|
||||
return errors.New(errors.CodePaymentMethodUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +606,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||
switch assetType {
|
||||
case "card":
|
||||
|
||||
61
internal/handler/callback/wecom_approval.go
Normal file
61
internal/handler/callback/wecom_approval.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package callback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
wecominfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// WeComApprovalHandler 提供企业微信审批加密回调入口。
|
||||
type WeComApprovalHandler struct {
|
||||
service *wecominfra.CallbackService
|
||||
}
|
||||
|
||||
// NewWeComApprovalHandler 创建企业微信审批回调 Handler。
|
||||
func NewWeComApprovalHandler(service *wecominfra.CallbackService) *WeComApprovalHandler {
|
||||
return &WeComApprovalHandler{service: service}
|
||||
}
|
||||
|
||||
// Verify 验证企业微信审批回调 URL。
|
||||
// GET /api/callback/wecom/approval/:application_id
|
||||
func (h *WeComApprovalHandler) Verify(c *fiber.Ctx) error {
|
||||
applicationID, err := parseWeComApplicationID(c.Params("application_id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plaintext, err := h.service.VerifyURL(
|
||||
c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Query("echostr"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
|
||||
return c.Send(plaintext)
|
||||
}
|
||||
|
||||
// Receive 接收企业微信审批状态变化事件并快速返回 success。
|
||||
// POST /api/callback/wecom/approval/:application_id
|
||||
func (h *WeComApprovalHandler) Receive(c *fiber.Ctx) error {
|
||||
applicationID, err := parseWeComApplicationID(c.Params("application_id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.service.Receive(
|
||||
c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Body(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
|
||||
return c.SendString("success")
|
||||
}
|
||||
|
||||
func parseWeComApplicationID(value string) (uint, error) {
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ type Account struct {
|
||||
UserType int `gorm:"column:user_type;type:int;not null;index;comment:用户类型 1=超级管理员 2=平台用户 3=代理账号 4=企业账号" json:"user_type"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(代理账号必填)" json:"shop_id,omitempty"`
|
||||
EnterpriseID *uint `gorm:"column:enterprise_id;index;comment:企业ID(企业账号必填)" json:"enterprise_id,omitempty"`
|
||||
WeComCorpID string `gorm:"column:wecom_corp_id;type:varchar(64);comment:绑定的企业微信企业 ID" json:"wecom_corp_id"`
|
||||
WeComUserID string `gorm:"column:wecom_userid;type:varchar(64);comment:绑定的企业微信成员 userid" json:"wecom_userid"`
|
||||
WeComName string `gorm:"column:wecom_name;type:varchar(100);comment:企业微信成员姓名快照" json:"wecom_name"`
|
||||
IsPrimary bool `gorm:"column:is_primary;type:boolean;default:false;comment:是否为店铺主账号(默认 false)" json:"is_primary"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
@@ -84,10 +84,11 @@ type AgentRechargeRecord struct {
|
||||
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
|
||||
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"`
|
||||
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"`
|
||||
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
|
||||
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
|
||||
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`
|
||||
|
||||
78
internal/model/asset_package_batch_order.go
Normal file
78
internal/model/asset_package_batch_order.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AssetPackageBatchOrderTask 资产套餐批量订购任务。
|
||||
type AssetPackageBatchOrderTask struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);not null;uniqueIndex" json:"task_no"`
|
||||
PackageID uint `gorm:"column:package_id;not null;index" json:"package_id"`
|
||||
PackageCode string `gorm:"column:package_code;type:varchar(100);not null;default:''" json:"package_code"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:''" json:"package_name"`
|
||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:''" json:"file_name"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null" json:"storage_key"`
|
||||
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]'" json:"voucher_keys"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;not null;default:0" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"`
|
||||
ResultItems AssetPackageBatchOrderResultItems `gorm:"column:result_items;type:jsonb;not null;default:'[]'" json:"result_items"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:''" json:"error_message"`
|
||||
CreatorUserType int `gorm:"column:creator_user_type;type:int;not null;default:0" json:"creator_user_type"`
|
||||
CreatorShopID uint `gorm:"column:creator_shop_id;not null;default:0" json:"creator_shop_id"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:''" json:"creator_name"`
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定批量订购任务表名。
|
||||
func (AssetPackageBatchOrderTask) TableName() string {
|
||||
return "tb_asset_package_batch_order_task"
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderResultItem 批量订购单行结果。
|
||||
type AssetPackageBatchOrderResultItem struct {
|
||||
Line int `json:"line"`
|
||||
AssetIdentifier string `json:"asset_identifier"`
|
||||
Status int `json:"status"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
OrderNo string `json:"order_no,omitempty"`
|
||||
Amount int64 `json:"amount,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderResultItems 批量订购单行结果集合。
|
||||
type AssetPackageBatchOrderResultItems []AssetPackageBatchOrderResultItem
|
||||
|
||||
// Value 将批量订购单行结果序列化为 JSONB。
|
||||
func (items AssetPackageBatchOrderResultItems) Value() (driver.Value, error) {
|
||||
if items == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(items)
|
||||
}
|
||||
|
||||
// Scan 从 JSONB 读取批量订购单行结果。
|
||||
func (items *AssetPackageBatchOrderResultItems) Scan(value any) error {
|
||||
if value == nil {
|
||||
*items = AssetPackageBatchOrderResultItems{}
|
||||
return nil
|
||||
}
|
||||
data, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, items)
|
||||
}
|
||||
@@ -13,6 +13,10 @@ type DeviceImportTask struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"`
|
||||
OperationType string `gorm:"column:operation_type;type:varchar(32);not null;default:'import';comment:任务业务类型(import/assign_shop/assign_series)" json:"operation_type"`
|
||||
TargetID *uint `gorm:"column:target_id;comment:批量分配目标店铺或套餐系列ID" json:"target_id"`
|
||||
OperatorType int `gorm:"column:operator_type;type:int;not null;default:0;comment:任务创建时操作者类型快照" json:"operator_type"`
|
||||
OperatorShopID *uint `gorm:"column:operator_shop_id;comment:任务创建时操作者店铺ID快照" json:"operator_shop_id"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
|
||||
|
||||
@@ -40,6 +40,10 @@ type AccountResponse struct {
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
EnterpriseID *uint `json:"enterprise_id,omitempty" description:"关联企业ID"`
|
||||
EnterpriseName string `json:"enterprise_name,omitempty" description:"企业名称"`
|
||||
WeComCorpID string `json:"wecom_corp_id" description:"已绑定的企业微信企业 ID"`
|
||||
WeComUserID string `json:"wecom_userid" description:"已绑定的企业微信成员 userid"`
|
||||
WeComName string `json:"wecom_name" description:"已绑定的企业微信成员姓名快照"`
|
||||
WeComBound bool `json:"wecom_bound" description:"是否已绑定企业微信成员"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
@@ -97,3 +101,15 @@ type UpdateStatusParams struct {
|
||||
IDReq
|
||||
UpdateStatusRequest
|
||||
}
|
||||
|
||||
// BindAccountWeComRequest 绑定系统账号与企业微信可见成员请求。
|
||||
type BindAccountWeComRequest struct {
|
||||
ApplicationID uint `json:"application_id" validate:"required,gt=0" minimum:"1" description:"企业微信应用配置 ID"`
|
||||
UserID string `json:"userid" validate:"required,min=1,max=64" minLength:"1" maxLength:"64" description:"企业微信成员 userid"`
|
||||
}
|
||||
|
||||
// BindAccountWeComParams 绑定系统账号与企业微信成员参数。
|
||||
type BindAccountWeComParams struct {
|
||||
IDReq
|
||||
BindAccountWeComRequest
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package dto
|
||||
|
||||
// CreateAgentRechargeRequest 创建代理充值请求
|
||||
type CreateAgentRechargeRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"`
|
||||
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"`
|
||||
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
@@ -37,6 +37,12 @@ type AgentRechargeResponse struct {
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID,在线充值为null"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企微审批为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
PaidAt *string `json:"paid_at" description:"支付时间"`
|
||||
CompletedAt *string `json:"completed_at" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
|
||||
@@ -359,6 +359,18 @@ type UpdateAssetRealnamePolicyResponse struct {
|
||||
RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
}
|
||||
|
||||
// BatchUpdateAssetRealnamePolicyRequest 批量更新卡或设备实名认证策略请求。
|
||||
type BatchUpdateAssetRealnamePolicyRequest struct {
|
||||
AssetIDs []uint `json:"asset_ids" validate:"required,min=1,max=500,dive,min=1" required:"true" minItems:"1" maxItems:"500" description:"资产ID列表(最多500条)"`
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" required:"true" enum:"none,before_order,after_order" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
}
|
||||
|
||||
// BatchUpdateAssetRealnamePolicyResponse 批量更新卡或设备实名认证策略响应。
|
||||
type BatchUpdateAssetRealnamePolicyResponse struct {
|
||||
SuccessCount int `json:"success_count" description:"成功更新数量"`
|
||||
RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
}
|
||||
|
||||
// UpdateAssetRealnameStatusRequest 手动更新资产实名状态请求
|
||||
type UpdateAssetRealnameStatusRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"`
|
||||
|
||||
70
internal/model/dto/asset_package_batch_order_dto.go
Normal file
70
internal/model/dto/asset_package_batch_order_dto.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package dto
|
||||
|
||||
// CreateAssetPackageBatchOrderRequest 创建资产套餐批量订购任务请求。
|
||||
type CreateAssetPackageBatchOrderRequest struct {
|
||||
FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"单列CSV对象存储Key(每行一个资产标识)"`
|
||||
PackageID uint `json:"package_id" validate:"required,min=1" required:"true" minimum:"1" description:"整批统一套餐ID"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"整批统一支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储Key列表(offline时1至5个)"`
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderTaskResponse 批量订购任务响应。
|
||||
type AssetPackageBatchOrderTaskResponse struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码快照"`
|
||||
PackageName string `json:"package_name" description:"套餐名称快照"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
FileName string `json:"file_name" description:"源CSV文件名"`
|
||||
VoucherKeys []string `json:"voucher_keys" description:"线下支付凭证对象存储Key列表"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
||||
TotalCount int `json:"total_count" description:"总行数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
FailCount int `json:"fail_count" description:"失败数"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"任务级中文错误信息"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名快照"`
|
||||
StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderResultItemResponse 批量订购单行结果响应。
|
||||
type AssetPackageBatchOrderResultItemResponse struct {
|
||||
Line int `json:"line" description:"CSV行号"`
|
||||
AssetIdentifier string `json:"asset_identifier" description:"资产标识"`
|
||||
Status int `json:"status" description:"单行状态 (3:成功, 4:失败)"`
|
||||
StatusName string `json:"status_name" description:"单行状态名称(中文)"`
|
||||
OrderID uint `json:"order_id,omitempty" description:"成功订单ID"`
|
||||
OrderNo string `json:"order_no,omitempty" description:"成功订单号"`
|
||||
Amount int64 `json:"amount,omitempty" description:"成功订单金额(分)"`
|
||||
Reason string `json:"reason,omitempty" description:"中文失败原因"`
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderTaskDetailResponse 批量订购任务详情响应。
|
||||
type AssetPackageBatchOrderTaskDetailResponse struct {
|
||||
AssetPackageBatchOrderTaskResponse
|
||||
Items []AssetPackageBatchOrderResultItemResponse `json:"items" description:"逐行成功和失败明细"`
|
||||
}
|
||||
|
||||
// ListAssetPackageBatchOrderRequest 批量订购任务列表请求。
|
||||
type ListAssetPackageBatchOrderRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
}
|
||||
|
||||
// AssetPackageBatchOrderTaskListResponse 批量订购任务列表响应。
|
||||
type AssetPackageBatchOrderTaskListResponse struct {
|
||||
Total int64 `json:"total" description:"总记录数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"size" description:"每页条数"`
|
||||
Items []*AssetPackageBatchOrderTaskResponse `json:"items" description:"任务列表"`
|
||||
}
|
||||
|
||||
// GetAssetPackageBatchOrderRequest 查询批量订购任务路径参数。
|
||||
type GetAssetPackageBatchOrderRequest struct {
|
||||
ID uint `path:"id" required:"true" description:"任务ID"`
|
||||
}
|
||||
@@ -19,24 +19,28 @@ type AssetInfoResponse struct {
|
||||
BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"`
|
||||
|
||||
// === 基础信息(通用) ===
|
||||
AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
Identifier string `json:"identifier" description:"资产标识符"`
|
||||
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||
Status int `json:"status" description:"资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ActivationStatus int `json:"activation_status" description:"激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名)"`
|
||||
ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"`
|
||||
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||
Generation string `json:"generation" description:"世代"`
|
||||
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
Identifier string `json:"identifier" description:"资产标识符"`
|
||||
VirtualNo string `json:"virtual_no" description:"虚拟号"`
|
||||
Status int `json:"status" description:"资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ActivationStatus int `json:"activation_status" description:"激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名)"`
|
||||
ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"`
|
||||
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
EffectiveRealnamePolicy string `json:"effective_realname_policy" description:"当前资产实际生效的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
RealnameRequired bool `json:"realname_required" description:"当前资产是否要求完成实名认证"`
|
||||
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||
Generation string `json:"generation" description:"世代"`
|
||||
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
|
||||
AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
|
||||
// === 套餐信息(通用) ===
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
CurrentPackageID uint `json:"current_package_id" description:"当前主套餐ID,无主套餐时为0,可用于发起续费"`
|
||||
CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"`
|
||||
CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at,omitempty" description:"当前主套餐开始时间"`
|
||||
CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at,omitempty" description:"当前主套餐过期时间"`
|
||||
|
||||
@@ -8,8 +8,8 @@ package dto
|
||||
type ClientCreateOrderRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
|
||||
// PaymentMethod 指定支付方式;强充场景必传。wechat 时 app_type 也必传,alipay 时不需要 app_type
|
||||
PaymentMethod string `json:"payment_method" validate:"omitempty,oneof=wechat alipay" description:"支付方式(强充必传)(wechat:微信, alipay:支付宝)"`
|
||||
// PaymentMethod 指定新订单固化的支付方式;wechat 时 app_type 也必传,alipay 时不需要 app_type。
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"订单支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
// AppType 仅强充 + 微信支付场景必传;支付宝强充无需传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信强充必传)(official_account:公众号, miniapp:小程序)"`
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type ClientOrderInfo struct {
|
||||
OrderID uint `json:"order_id" description:"订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||
PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
@@ -79,10 +80,15 @@ type ClientOrderListRequest struct {
|
||||
type ClientOrderListItem struct {
|
||||
OrderID uint `json:"order_id" description:"订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
AssetIdentifier string `json:"asset_identifier" description:"下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)"`
|
||||
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||
PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
PackageIDs []uint `json:"package_ids" description:"套餐ID列表,可用于发起续费"`
|
||||
PackageNames []string `json:"package_names" description:"套餐名称列表"`
|
||||
}
|
||||
|
||||
@@ -102,6 +108,9 @@ type ClientOrderListResponse struct {
|
||||
type ClientOrderDetailResponse struct {
|
||||
OrderID uint `json:"order_id" description:"订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
AssetIdentifier string `json:"asset_identifier" description:"下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)"`
|
||||
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||
PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"`
|
||||
@@ -135,7 +144,7 @@ type ClientPaymentLink struct {
|
||||
|
||||
// ClientPayOrderRequest D4 订单支付请求
|
||||
type ClientPayOrderRequest struct {
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
PaymentMethod string `json:"payment_method,omitempty" validate:"omitempty,oneof=wallet wechat alipay" description:"过渡兼容字段;为空时使用订单快照,非空时必须与订单快照一致"`
|
||||
// AppType 仅 payment_method=wechat 时必填,alipay 无需传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)"`
|
||||
}
|
||||
|
||||
@@ -62,12 +62,13 @@ type ClientRechargeCheckRequest struct {
|
||||
|
||||
// ClientRechargeCheckResponse C3 充值前校验响应
|
||||
type ClientRechargeCheckResponse struct {
|
||||
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"`
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||
TriggerType string `json:"trigger_type" description:"触发类型"`
|
||||
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
|
||||
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"`
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||
TriggerType string `json:"trigger_type" description:"触发类型"`
|
||||
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
|
||||
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -3,25 +3,26 @@ package dto
|
||||
import "time"
|
||||
|
||||
type ListDeviceRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"`
|
||||
IMEI string `json:"imei" query:"imei" validate:"omitempty,max=20" maxLength:"20" description:"设备IMEI(模糊查询)"`
|
||||
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)"`
|
||||
ActivationStatus *int `json:"activation_status" query:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID(兼容旧参数,单选;NULL表示平台库存)"`
|
||||
ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"`
|
||||
Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"`
|
||||
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
||||
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
||||
HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐)"`
|
||||
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"`
|
||||
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"`
|
||||
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"`
|
||||
IMEI string `json:"imei" query:"imei" validate:"omitempty,max=20" maxLength:"20" description:"设备IMEI(模糊查询)"`
|
||||
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)"`
|
||||
ActivationStatus *int `json:"activation_status" query:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"`
|
||||
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名;任意有效绑定卡已实名即视为设备已实名)"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID(兼容旧参数,单选;NULL表示平台库存)"`
|
||||
ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"`
|
||||
Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"`
|
||||
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
||||
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
||||
HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐)"`
|
||||
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"`
|
||||
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"`
|
||||
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
|
||||
}
|
||||
|
||||
type DeviceResponse struct {
|
||||
@@ -196,12 +197,6 @@ type BatchSetDeviceSeriesBindngResponse struct {
|
||||
FailedItems []DeviceSeriesBindngFailedItem `json:"failed_items" description:"失败详情列表"`
|
||||
}
|
||||
|
||||
// SetSpeedLimitRequest 设置设备限速请求
|
||||
type SetSpeedLimitRequest struct {
|
||||
Identifier string `path:"identifier" description:"设备标识符(支持虚拟号/IMEI/SN)" required:"true"`
|
||||
SpeedLimit int `json:"speed_limit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值(KB/s)"`
|
||||
}
|
||||
|
||||
// SetWiFiRequest 设置设备 WiFi 请求
|
||||
type SetWiFiRequest struct {
|
||||
Identifier string `path:"identifier" description:"设备标识符(支持虚拟号/IMEI/SN)" required:"true"`
|
||||
|
||||
@@ -14,19 +14,38 @@ type ImportDeviceResponse struct {
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
// CreateDeviceBatchAllocationRequest 创建设备 CSV 批量分配任务请求。
|
||||
type CreateDeviceBatchAllocationRequest struct {
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"单列设备标识 CSV 对象存储 Key(每行支持 VirtualNo、IMEI 或 SN)"`
|
||||
OperationType string `json:"operation_type" validate:"required,oneof=assign_shop assign_series" required:"true" enum:"assign_shop,assign_series" description:"操作类型 (assign_shop:分配目标代理, assign_series:设置套餐系列)"`
|
||||
TargetID uint `json:"target_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标代理店铺ID或套餐系列ID,由 operation_type 决定"`
|
||||
}
|
||||
|
||||
// CreateDeviceBatchAllocationResponse 创建设备 CSV 批量分配任务响应。
|
||||
type CreateDeviceBatchAllocationResponse struct {
|
||||
TaskID uint `json:"task_id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
type ListDeviceImportTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
|
||||
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series" enum:"import,assign_shop,assign_series" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
|
||||
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||
}
|
||||
|
||||
type DeviceImportTaskResponse struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
OperationType string `json:"operation_type" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"`
|
||||
OperationName string `json:"operation_name" description:"任务业务类型名称(中文)"`
|
||||
TargetID *uint `json:"target_id,omitempty" description:"批量分配目标店铺或套餐系列ID"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
||||
StatusText string `json:"status_text" description:"任务状态文本"`
|
||||
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
@@ -51,9 +70,10 @@ type ListDeviceImportTaskResponse struct {
|
||||
}
|
||||
|
||||
type DeviceImportResultItemDTO struct {
|
||||
Line int `json:"line" description:"行号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
Line int `json:"line" description:"行号"`
|
||||
VirtualNo string `json:"virtual_no" description:"设备虚拟号(兼容原设备导入任务字段)"`
|
||||
DeviceIdentifier string `json:"device_identifier" description:"CSV原始设备标识(VirtualNo、IMEI或SN)"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
}
|
||||
|
||||
type GetDeviceImportTaskRequest struct {
|
||||
|
||||
@@ -97,6 +97,8 @@ type ExchangeOrderResponse struct {
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// CreateExportTaskRequest 创建导出任务请求。
|
||||
type CreateExportTaskRequest struct {
|
||||
Scene string `json:"scene" validate:"required,oneof=device iot_card order" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"`
|
||||
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
|
||||
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选)"`
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
|
||||
type ListExportTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"`
|
||||
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||
@@ -33,7 +33,7 @@ type ExportTaskItem struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskID uint `json:"task_id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"`
|
||||
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
|
||||
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
||||
|
||||
@@ -3,29 +3,30 @@ package dto
|
||||
import "time"
|
||||
|
||||
type ListStandaloneIotCardRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"分销商ID(兼容旧参数,单选)"`
|
||||
ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表(多选)"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"`
|
||||
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"卡虚拟号(模糊查询)"`
|
||||
MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"`
|
||||
IsStandalone *bool `json:"is_standalone" query:"is_standalone" description:"是否为独立卡(true:仅返回未绑定设备的卡, false:仅返回已绑定设备的卡, 不传:返回全部)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"`
|
||||
IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"`
|
||||
IsReplaced *bool `json:"is_replaced" query:"is_replaced" description:"是否有换卡记录 (true:有换卡记录, false:无换卡记录)"`
|
||||
HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐 (true:有生效中套餐, false:无生效中套餐, 不传:不过滤)"`
|
||||
ICCIDStart string `json:"iccid_start" query:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"ICCID起始号"`
|
||||
ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"`
|
||||
CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"`
|
||||
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立)"`
|
||||
NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"`
|
||||
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"`
|
||||
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"分销商ID(兼容旧参数,单选)"`
|
||||
ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表(多选)"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"`
|
||||
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"卡虚拟号(模糊查询)"`
|
||||
MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"`
|
||||
IsStandalone *bool `json:"is_standalone" query:"is_standalone" description:"是否为独立卡(true:仅返回未绑定设备的卡, false:仅返回已绑定设备的卡, 不传:返回全部)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"`
|
||||
IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"`
|
||||
IsReplaced *bool `json:"is_replaced" query:"is_replaced" description:"是否有换卡记录 (true:有换卡记录, false:无换卡记录)"`
|
||||
HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐 (true:有生效中套餐, false:无生效中套餐, 不传:不过滤)"`
|
||||
ICCIDStart string `json:"iccid_start" query:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"ICCID起始号"`
|
||||
ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"`
|
||||
CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"`
|
||||
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立)"`
|
||||
NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"`
|
||||
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||
AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"`
|
||||
IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"`
|
||||
}
|
||||
|
||||
type StandaloneIotCardResponse struct {
|
||||
@@ -159,6 +160,21 @@ type GetIotCardByICCIDRequest struct {
|
||||
ICCID string `path:"iccid" description:"ICCID" required:"true"`
|
||||
}
|
||||
|
||||
// SetIotCardSpeedTierRequest 设置 IoT 卡固定限速档位请求。
|
||||
type SetIotCardSpeedTierRequest struct {
|
||||
ICCID string `path:"iccid" description:"IoT 卡 ICCID" required:"true"`
|
||||
Code *int `json:"code" validate:"required,oneof=-1 0 1 2 3 4 5 6 7 8" required:"true" enum:"-1,0,1,2,3,4,5,6,7,8" description:"固定限速档位 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"`
|
||||
}
|
||||
|
||||
// SetIotCardSpeedTierResponse 设置 IoT 卡固定限速档位响应。
|
||||
type SetIotCardSpeedTierResponse struct {
|
||||
IotCardID uint `json:"iot_card_id" description:"IoT 卡ID"`
|
||||
ICCID string `json:"iccid" description:"实际调用 Gateway 的卡 ICCID"`
|
||||
Code int `json:"code" description:"固定限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"`
|
||||
SpeedTierName string `json:"speed_tier_name" description:"固定限速档位名称(中文)"`
|
||||
IntegrationID string `json:"integration_id" description:"Gateway 外部交互记录ID"`
|
||||
}
|
||||
|
||||
type IotCardDetailResponse struct {
|
||||
StandaloneIotCardResponse
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ type NotificationItem struct {
|
||||
Severity string `json:"severity" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
|
||||
Title string `json:"title" description:"纯文本标题"`
|
||||
Body string `json:"body" description:"纯文本正文"`
|
||||
RefType string `json:"ref_type" description:"受控资源类型"`
|
||||
RefID string `json:"ref_id" description:"受控资源ID"`
|
||||
RefKey string `json:"ref_key" description:"受控资源Key"`
|
||||
RefType string `json:"ref_type" description:"受控资源类型;可能为空。可选值及含义:system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录。后台点击通知应调用目标解析接口,不得直接拼接路由"`
|
||||
RefID string `json:"ref_id" description:"受控资源数字ID的十进制字符串;可能为空。refund、agent_recharge、wecom_approval、iot_card、device、expiring_asset、shop_fund、asset 等类型使用;仅用于资源定位,不是前端URL"`
|
||||
RefKey string `json:"ref_key" description:"受控资源稳定Key或展示快照;可能为空。system_config 为配置Key,integration_log/card_sync 为集成标识,asset 为资产标识快照;仅用于定位或展示,不是前端URL"`
|
||||
IsRead bool `json:"is_read" description:"是否已读"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty" description:"首次已读时间(ISO 8601)"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间(ISO 8601)"`
|
||||
@@ -54,10 +54,10 @@ type NotificationReadResponse struct {
|
||||
|
||||
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
|
||||
type NotificationTargetResponse struct {
|
||||
TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转"`
|
||||
TargetID *uint `json:"target_id,omitempty" description:"受控数值目标ID"`
|
||||
TargetKey string `json:"target_key,omitempty" description:"受控稳定目标Key"`
|
||||
Available bool `json:"available" description:"当前账号是否仍可访问目标"`
|
||||
TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转。可选值:refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config"`
|
||||
TargetID *uint `json:"target_id,omitempty" description:"ID型目标的业务主键;前端按 target_type 映射受控页面,不得自行拼接任意URL"`
|
||||
TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标"`
|
||||
Available bool `json:"available" description:"当前账号是否仍可访问目标;false 时只展示通知正文,不执行跳转"`
|
||||
}
|
||||
|
||||
// NotificationUnreadSummaryResponse 是后台账号未读通知的固定分类汇总。
|
||||
@@ -81,8 +81,9 @@ type NotificationReadAllResponse struct {
|
||||
|
||||
// PersonalNotificationListRequest 是个人客户通知的简化分页参数。
|
||||
type PersonalNotificationListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1,最大 10000"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20,最大 50"`
|
||||
IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;false 仅查询未读,true 仅查询已读,不传时查询全部"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1,最大 10000"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20,最大 50"`
|
||||
}
|
||||
|
||||
// PersonalNotificationListResponse 是个人客户通知的简化分页结果。
|
||||
|
||||
@@ -12,9 +12,9 @@ type CreateOrderRequest struct {
|
||||
|
||||
// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline)
|
||||
type CreateAdminOrderRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(卡支持 ICCID,设备支持 VirtualNo、IMEI 或 SN)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ type OrderResponse struct {
|
||||
IsExpired bool `json:"is_expired" description:"是否已过期"`
|
||||
|
||||
// 资产标识符快照
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"`
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"`
|
||||
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,50 @@ type PackageExpiryEstimate struct {
|
||||
ExpiryEstimateStatusName string `json:"expiry_estimate_status_name" description:"预计套餐到期推算状态名称(中文)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true)"`
|
||||
}
|
||||
|
||||
// ExpiringAssetListRequest 临期资产分页查询参数。
|
||||
type ExpiringAssetListRequest struct {
|
||||
AssetType string `query:"asset_type" validate:"omitempty,oneof=iot_card device" enums:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
Keyword string `query:"keyword" validate:"omitempty,max=100" description:"资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI"`
|
||||
ShopID *uint `query:"shop_id" validate:"omitempty,gt=0" description:"店铺 ID,只能缩小当前账号的数据权限范围"`
|
||||
PackageID *uint `query:"package_id" validate:"omitempty,gt=0" description:"最终排队套餐 ID"`
|
||||
DaysMin *int `query:"days_min" validate:"omitempty,min=0,max=15" description:"最小剩余上海自然日天数,范围 0 至 15"`
|
||||
DaysMax *int `query:"days_max" validate:"omitempty,min=0,max=15" description:"最大剩余上海自然日天数,范围 0 至 15"`
|
||||
ExpiresFrom string `query:"expires_from" validate:"omitempty,datetime=2006-01-02" description:"预计到期开始日期(上海自然日,格式 YYYY-MM-DD)"`
|
||||
ExpiresTo string `query:"expires_to" validate:"omitempty,datetime=2006-01-02" description:"预计到期结束日期(上海自然日,格式 YYYY-MM-DD)"`
|
||||
Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,最大 100"`
|
||||
}
|
||||
|
||||
// ExpiringAssetItem 临期资产列表项。
|
||||
type ExpiringAssetItem struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
|
||||
AssetID uint `json:"asset_id" description:"资产 ID"`
|
||||
Identifier string `json:"identifier" description:"稳定资产标识,卡返回 ICCID,设备优先返回虚拟号、为空时返回 IMEI"`
|
||||
ShopID *uint `json:"shop_id" description:"所属店铺 ID,平台库存为 null"`
|
||||
ShopName string `json:"shop_name" description:"所属店铺名称,平台库存为空字符串"`
|
||||
PackageUsageID uint `json:"package_usage_id" description:"最终到期队列末条主套餐使用记录 ID"`
|
||||
PackageID uint `json:"package_id" description:"最终到期队列末条套餐 ID"`
|
||||
PackageName string `json:"package_name" description:"最终到期队列末条套餐名称"`
|
||||
PackageExpiryEstimate
|
||||
ExpiryLevel string `json:"expiry_level" description:"临期高亮等级 (pink:8至15天, purple:4至7天, red:0至3天)"`
|
||||
ExpiryLevelName string `json:"expiry_level_name" description:"临期高亮等级名称(中文)"`
|
||||
IsPriority bool `json:"is_priority" description:"是否优先展示,剩余 0 至 3 天时为 true"`
|
||||
}
|
||||
|
||||
// ExpiringAssetSummary 临期资产数量汇总。
|
||||
type ExpiringAssetSummary struct {
|
||||
CardCount int64 `json:"card_count" description:"临期物联网卡数量"`
|
||||
DeviceCount int64 `json:"device_count" description:"临期设备数量"`
|
||||
TotalCount int64 `json:"total_count" description:"临期资产总数"`
|
||||
WindowDays int `json:"window_days" description:"临期统计窗口天数,固定为 15"`
|
||||
}
|
||||
|
||||
// ExpiringAssetListResponse 临期资产分页响应。
|
||||
type ExpiringAssetListResponse struct {
|
||||
Items []ExpiringAssetItem `json:"items" description:"临期资产列表"`
|
||||
Total int64 `json:"total" description:"符合条件的总数量"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
Size int `json:"size" description:"每页数量"`
|
||||
Summary ExpiringAssetSummary `json:"summary" description:"同一筛选与权限范围内的卡、设备数量汇总"`
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package dto
|
||||
|
||||
// CreateRefundRequest 创建退款申请请求
|
||||
type CreateRefundRequest struct {
|
||||
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
}
|
||||
|
||||
// RefundIDRequest 退款申请ID路径参数
|
||||
@@ -24,11 +24,11 @@ type RejectRefundRequest struct {
|
||||
// ResubmitRefundRequest 重新提交退款申请请求
|
||||
// 退款单被退回后,可修改部分字段后重新提交
|
||||
type ResubmitRefundRequest struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
}
|
||||
|
||||
// ApproveRefundRequest 审批通过退款申请请求
|
||||
@@ -56,34 +56,40 @@ type RefundListRequest struct {
|
||||
|
||||
// RefundResponse 退款申请详情响应
|
||||
type RefundResponse struct {
|
||||
ID uint `json:"id" description:"退款申请ID"`
|
||||
RefundNo string `json:"refund_no" description:"退款单号"`
|
||||
OrderID uint `json:"order_id" description:"关联订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"`
|
||||
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
|
||||
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
|
||||
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
|
||||
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
ID uint `json:"id" description:"退款申请ID"`
|
||||
RefundNo string `json:"refund_no" description:"退款单号"`
|
||||
OrderID uint `json:"order_id" description:"关联订单ID"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"`
|
||||
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
|
||||
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
|
||||
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
|
||||
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// RefundListResponse 退款申请列表分页响应
|
||||
|
||||
@@ -62,6 +62,7 @@ type UpdateShopRequest struct {
|
||||
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
|
||||
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
|
||||
Status int `json:"status" validate:"oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"`
|
||||
ClientLoginDisabled *bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录;不传保持不变"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 解析更新店铺请求并保留业务员字段“缺失”和“显式 null”的差异。
|
||||
@@ -103,6 +104,7 @@ type ShopResponse struct {
|
||||
Address string `json:"address" description:"详细地址"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package dto
|
||||
type GetUploadURLRequest struct {
|
||||
FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名(如:cards.csv)"`
|
||||
ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型(如:text/csv),留空则自动推断"`
|
||||
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment" required:"true" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件)"`
|
||||
Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配CSV)"`
|
||||
}
|
||||
|
||||
type GetUploadURLResponse struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// SystemConfigListRequest 是系统配置分页查询参数。
|
||||
type SystemConfigListRequest struct {
|
||||
Module string `json:"module" query:"module" validate:"omitempty,max=100" description:"模块筛选"`
|
||||
Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment" enum:"carrier_callback,c2b.payment" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type SystemConfigItem struct {
|
||||
ConfigKey string `json:"config_key" description:"稳定配置 Key"`
|
||||
Value string `json:"value" description:"配置值;敏感值按注册策略脱敏"`
|
||||
ValueType string `json:"value_type" description:"值类型 (string:字符串, int:整数, bool:布尔, json:JSON)"`
|
||||
Module string `json:"module" description:"所属模块"`
|
||||
Module string `json:"module" enum:"carrier_callback,c2b.payment" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"`
|
||||
Description string `json:"description" description:"中文说明"`
|
||||
Readonly bool `json:"readonly" description:"是否只读"`
|
||||
Sensitive bool `json:"sensitive" description:"是否敏感"`
|
||||
|
||||
72
internal/model/dto/wecom_dto.go
Normal file
72
internal/model/dto/wecom_dto.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// SaveWeComApplicationRequest 保存企业微信自建应用请求。
|
||||
type SaveWeComApplicationRequest struct {
|
||||
CorpID string `json:"corp_id" validate:"required,min=1,max=64" description:"企业微信企业 ID"`
|
||||
AgentID int64 `json:"agent_id" validate:"required,gt=0" description:"企业微信自建应用 AgentID"`
|
||||
Name string `json:"name" validate:"required,min=1,max=100" description:"应用展示名称"`
|
||||
Secret string `json:"secret" validate:"required,min=1,max=512" description:"应用 Secret,管理端使用明文传入,服务端加密保存"`
|
||||
CallbackToken string `json:"callback_token" validate:"required,min=1,max=512" description:"回调 Token,管理端使用明文传入,服务端加密保存"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" validate:"required,len=43" description:"回调 EncodingAESKey,管理端使用明文传入,服务端加密保存"`
|
||||
Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"`
|
||||
}
|
||||
|
||||
// WeComApplicationListRequest 查询企业微信应用配置列表请求。
|
||||
type WeComApplicationListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" default:"1" description:"页码,默认 1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" default:"20" description:"每页数量,默认 20,最大 100"`
|
||||
}
|
||||
|
||||
// WeComApplicationResponse 企业微信应用配置响应,仅允许超级管理员读取明文凭据。
|
||||
type WeComApplicationResponse struct {
|
||||
ID uint `json:"id" description:"应用配置 ID"`
|
||||
CorpID string `json:"corp_id" description:"企业微信企业 ID"`
|
||||
AgentID int64 `json:"agent_id" description:"企业微信自建应用 AgentID"`
|
||||
Name string `json:"name" description:"应用展示名称"`
|
||||
Secret string `json:"secret" description:"应用 Secret 明文,仅超级管理员可见"`
|
||||
CallbackToken string `json:"callback_token" description:"回调 Token 明文,仅超级管理员可见"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" description:"回调 EncodingAESKey 明文,仅超级管理员可见"`
|
||||
DefaultCreatorUserID string `json:"default_creator_userid" description:"代理等非企微账号发起审批时使用的默认企微成员 userid"`
|
||||
DefaultCreatorName string `json:"default_creator_name" description:"默认企微审批发起人姓名快照"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
CredentialsSet bool `json:"credentials_set" description:"Secret、回调 Token 和 EncodingAESKey 是否均已配置"`
|
||||
LastConnectedAt *time.Time `json:"last_connected_at" description:"最近一次成功取得 access_token 的时间"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// SaveWeComDefaultCreatorRequest 保存企业微信应用默认审批发起人请求。
|
||||
type SaveWeComDefaultCreatorRequest struct {
|
||||
UserID string `json:"userid" validate:"required,min=1,max=64" description:"从应用当前可见成员中选择的默认发起人 userid"`
|
||||
}
|
||||
|
||||
// SaveWeComDefaultCreatorParams 保存默认审批发起人的路径与请求参数。
|
||||
type SaveWeComDefaultCreatorParams struct {
|
||||
ID uint `path:"id" required:"true" description:"企业微信应用配置 ID"`
|
||||
SaveWeComDefaultCreatorRequest
|
||||
}
|
||||
|
||||
// WeComApplicationListResponse 企业微信应用列表响应。
|
||||
type WeComApplicationListResponse struct {
|
||||
Items []WeComApplicationResponse `json:"items" description:"企业微信应用配置列表"`
|
||||
Total int64 `json:"total" description:"总数量"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
}
|
||||
|
||||
// WeComConnectionTestResponse 企业微信连接测试响应。
|
||||
type WeComConnectionTestResponse struct {
|
||||
Success bool `json:"success" description:"是否成功取得 access_token"`
|
||||
}
|
||||
|
||||
// WeComApprovalCallbackRequest 企业微信审批回调路径与签名参数。
|
||||
type WeComApprovalCallbackRequest struct {
|
||||
ApplicationID uint `path:"application_id" required:"true" minimum:"1" description:"企业微信应用配置 ID"`
|
||||
MsgSignature string `query:"msg_signature" required:"true" description:"企业微信回调消息签名"`
|
||||
Timestamp string `query:"timestamp" required:"true" description:"企业微信回调时间戳"`
|
||||
Nonce string `query:"nonce" required:"true" description:"企业微信回调随机串"`
|
||||
EchoStr string `query:"echostr" description:"GET 回调地址验证使用的加密随机串"`
|
||||
}
|
||||
41
internal/model/dto/wecom_member_dto.go
Normal file
41
internal/model/dto/wecom_member_dto.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// WeComMemberListRequest 查询企业微信应用可见成员请求。
|
||||
type WeComMemberListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" default:"1" description:"页码,默认 1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" default:"20" description:"每页数量,默认 20,最大 100"`
|
||||
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按姓名或 userid 模糊搜索"`
|
||||
}
|
||||
|
||||
// WeComMemberListParams 企业微信应用可见成员分页路径与查询参数。
|
||||
type WeComMemberListParams struct {
|
||||
IDReq
|
||||
WeComMemberListRequest
|
||||
}
|
||||
|
||||
// WeComMemberResponse 企业微信应用可见成员响应。
|
||||
type WeComMemberResponse struct {
|
||||
ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"`
|
||||
CorpID string `json:"corp_id" description:"企业微信企业 ID"`
|
||||
UserID string `json:"userid" description:"企业微信成员 userid"`
|
||||
Name string `json:"name" description:"企业微信成员姓名"`
|
||||
DepartmentIDs []int64 `json:"department_ids" description:"成员所属部门 ID 列表,仅作为选择辅助快照"`
|
||||
SyncedAt time.Time `json:"synced_at" description:"最近同步时间"`
|
||||
}
|
||||
|
||||
// WeComMemberListResponse 企业微信应用可见成员分页响应。
|
||||
type WeComMemberListResponse struct {
|
||||
Items []WeComMemberResponse `json:"items" description:"可见成员列表"`
|
||||
Total int64 `json:"total" description:"总数量"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
}
|
||||
|
||||
// WeComMemberSyncResponse 企业微信可见成员同步响应。
|
||||
type WeComMemberSyncResponse struct {
|
||||
ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"`
|
||||
SyncedCount int `json:"synced_count" description:"本次同步的可见成员数量"`
|
||||
SyncedAt time.Time `json:"synced_at" description:"同步完成时间"`
|
||||
}
|
||||
55
internal/model/dto/wecom_scene_dto.go
Normal file
55
internal/model/dto/wecom_scene_dto.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// WeComControlMappingItem 描述一个业务字段到企微模板控件的显式映射。
|
||||
type WeComControlMappingItem struct {
|
||||
BusinessField string `json:"business_field" validate:"required,min=1,max=64" description:"稳定业务字段编码"`
|
||||
ControlID string `json:"control_id" validate:"required,min=1,max=128" description:"企微模板控件 ID"`
|
||||
ControlType string `json:"control_type" validate:"required,min=1,max=64" description:"企微模板控件类型,必须与模板详情一致"`
|
||||
OptionMapping map[string]string `json:"option_mapping" description:"业务枚举值到企微选择项 key 的映射;非选择控件传空对象"`
|
||||
}
|
||||
|
||||
// SaveWeComApprovalSceneRequest 保存企业微信审批场景请求。
|
||||
type SaveWeComApprovalSceneRequest struct {
|
||||
ApplicationID uint `json:"application_id" validate:"required,gt=0" description:"企业微信应用配置 ID"`
|
||||
TemplateID string `json:"template_id" validate:"required,min=1,max=128" description:"企微后台已创建模板 ID"`
|
||||
ControlMapping []WeComControlMappingItem `json:"control_mapping" validate:"required,min=1,dive" description:"业务字段与模板控件映射"`
|
||||
Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"`
|
||||
}
|
||||
|
||||
// SaveWeComApprovalSceneParams 保存企业微信审批场景路径与请求参数。
|
||||
type SaveWeComApprovalSceneParams struct {
|
||||
BusinessType string `path:"business_type" required:"true" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批)"`
|
||||
SaveWeComApprovalSceneRequest
|
||||
}
|
||||
|
||||
// WeComApprovalSceneListRequest 查询企业微信审批场景请求。
|
||||
type WeComApprovalSceneListRequest struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码,默认 1"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,默认 20,最大 100"`
|
||||
}
|
||||
|
||||
// WeComApprovalSceneResponse 企业微信审批场景响应。
|
||||
type WeComApprovalSceneResponse struct {
|
||||
ID uint `json:"id" description:"场景配置 ID"`
|
||||
BusinessType string `json:"business_type" description:"业务类型"`
|
||||
BusinessTypeName string `json:"business_type_name" description:"业务类型名称(中文)"`
|
||||
ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"`
|
||||
TemplateID string `json:"template_id" description:"企微模板 ID"`
|
||||
TemplateName string `json:"template_name" description:"企微模板名称"`
|
||||
TemplateFingerprint string `json:"template_fingerprint" description:"模板控件最小快照 SHA-256 指纹"`
|
||||
ControlMapping []WeComControlMappingItem `json:"control_mapping" description:"已验证控件映射"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
LastVerifiedAt time.Time `json:"last_verified_at" description:"最近模板校验时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// WeComApprovalSceneListResponse 企业微信审批场景分页响应。
|
||||
type WeComApprovalSceneListResponse struct {
|
||||
Items []WeComApprovalSceneResponse `json:"items" description:"审批场景列表"`
|
||||
Total int64 `json:"total" description:"总数量"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type Order struct {
|
||||
// 关联资源
|
||||
IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"`
|
||||
DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)" json:"asset_identifier,omitempty"`
|
||||
|
||||
// 金额信息
|
||||
TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"`
|
||||
|
||||
@@ -18,7 +18,7 @@ type RefundRequest struct {
|
||||
OrderID uint `gorm:"column:order_id;index;not null;comment:关联订单ID" json:"order_id"`
|
||||
OrderNo string `gorm:"column:order_no;type:varchar(30);not null;default:'';comment:关联订单号快照" json:"order_no"`
|
||||
OrderType string `gorm:"column:order_type;type:varchar(20);not null;default:'';comment:订单类型快照(single_card/device)" json:"order_type,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:'';comment:下单时资产标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:'';comment:下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)" json:"asset_identifier,omitempty"`
|
||||
IotCardID *uint `gorm:"column:iot_card_id;comment:下单时IoT卡ID快照" json:"iot_card_id,omitempty"`
|
||||
DeviceID *uint `gorm:"column:device_id;comment:下单时设备ID快照" json:"device_id,omitempty"`
|
||||
PackageUsageID *uint `gorm:"column:package_usage_id;comment:关联套餐使用记录ID(可选)" json:"package_usage_id,omitempty"`
|
||||
@@ -32,14 +32,15 @@ type RefundRequest struct {
|
||||
|
||||
// 退款凭证、原因和状态
|
||||
RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表(jsonb存储[]string)" json:"refund_voucher_key"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`
|
||||
|
||||
// 审批信息
|
||||
ProcessorID *uint `gorm:"column:processor_id;comment:审批人ID" json:"processor_id,omitempty"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at;comment:审批时间" json:"processed_at,omitempty"`
|
||||
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"`
|
||||
Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"`
|
||||
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"`
|
||||
ProcessorID *uint `gorm:"column:processor_id;comment:审批人ID" json:"processor_id,omitempty"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at;comment:审批时间" json:"processed_at,omitempty"`
|
||||
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"`
|
||||
Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"`
|
||||
|
||||
// 后处理标记
|
||||
CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"`
|
||||
|
||||
@@ -20,6 +20,7 @@ type Shop struct {
|
||||
District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"`
|
||||
Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ClientLoginDisabled bool `gorm:"column:client_login_disabled;type:boolean;not null;default:false;comment:是否禁止该店铺资产发起新的C端登录" json:"client_login_disabled"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
29
internal/model/wecom_application.go
Normal file
29
internal/model/wecom_application.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WeComApplication 企业微信自建应用及加密连接凭据。
|
||||
type WeComApplication struct {
|
||||
gorm.Model
|
||||
CorpID string `gorm:"column:corp_id;type:varchar(64);not null;uniqueIndex:uq_wecom_application_identity,priority:1,where:deleted_at IS NULL" json:"corp_id"`
|
||||
AgentID int64 `gorm:"column:agent_id;type:bigint;not null;uniqueIndex:uq_wecom_application_identity,priority:2,where:deleted_at IS NULL" json:"agent_id"`
|
||||
Name string `gorm:"column:name;type:varchar(100);not null" json:"name"`
|
||||
SecretCiphertext []byte `gorm:"column:secret_ciphertext;type:bytea;not null" json:"-"`
|
||||
CallbackTokenCiphertext []byte `gorm:"column:callback_token_ciphertext;type:bytea;not null" json:"-"`
|
||||
EncodingAESKeyCiphertext []byte `gorm:"column:encoding_aes_key_ciphertext;type:bytea;not null" json:"-"`
|
||||
DefaultCreatorUserID string `gorm:"column:default_creator_userid;type:varchar(64);not null;default:''" json:"default_creator_userid"`
|
||||
DefaultCreatorName string `gorm:"column:default_creator_name;type:varchar(100);not null;default:''" json:"default_creator_name"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1" json:"status"`
|
||||
CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"`
|
||||
UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"`
|
||||
LastConnectedAt *time.Time `gorm:"column:last_connected_at;type:timestamptz" json:"last_connected_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定企业微信应用表名。
|
||||
func (WeComApplication) TableName() string {
|
||||
return "tb_wecom_application"
|
||||
}
|
||||
37
internal/model/wecom_approval_context.go
Normal file
37
internal/model/wecom_approval_context.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// WeComApprovalContext 保存通用审批实例对应的企微提交安全快照和提交结果。
|
||||
type WeComApprovalContext struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
ApprovalInstanceID uint `gorm:"column:approval_instance_id;not null;uniqueIndex" json:"approval_instance_id"`
|
||||
ApplicationID uint `gorm:"column:application_id;not null;index" json:"application_id"`
|
||||
BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"`
|
||||
TemplateID string `gorm:"column:template_id;type:varchar(128);not null" json:"template_id"`
|
||||
CreatorUserID string `gorm:"column:creator_userid;type:varchar(64);not null" json:"creator_userid"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null" json:"creator_name"`
|
||||
CreatorSource string `gorm:"column:creator_source;type:varchar(16);not null" json:"creator_source"`
|
||||
ControlMapping datatypes.JSON `gorm:"column:control_mapping;type:jsonb;not null" json:"control_mapping"`
|
||||
TemplateSnapshot datatypes.JSON `gorm:"column:template_snapshot;type:jsonb;not null" json:"template_snapshot"`
|
||||
TemplateFingerprint string `gorm:"column:template_fingerprint;type:char(64);not null" json:"template_fingerprint"`
|
||||
SubmissionStatus int `gorm:"column:submission_status;type:int;not null;default:0;index" json:"submission_status"`
|
||||
SubmissionAttemptedAt *time.Time `gorm:"column:submission_attempted_at;type:timestamptz" json:"submission_attempted_at,omitempty"`
|
||||
SPNo string `gorm:"column:sp_no;type:varchar(128);not null;default:'';uniqueIndex:uq_wecom_approval_context_sp_no,where:sp_no <> ''" json:"sp_no"`
|
||||
LastError string `gorm:"column:last_error;type:varchar(500);not null;default:''" json:"last_error"`
|
||||
LastRecoveryAt *time.Time `gorm:"column:last_recovery_at;type:timestamptz" json:"last_recovery_at,omitempty"`
|
||||
LatestSPStatus int `gorm:"column:latest_sp_status;type:int;not null;default:0" json:"latest_sp_status"`
|
||||
LatestDetailSnapshot datatypes.JSON `gorm:"column:latest_detail_snapshot;type:jsonb" json:"latest_detail_snapshot,omitempty"`
|
||||
LastSyncedAt *time.Time `gorm:"column:last_synced_at;type:timestamptz" json:"last_synced_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定企业微信审批渠道上下文表名。
|
||||
func (WeComApprovalContext) TableName() string {
|
||||
return "tb_wecom_approval_context"
|
||||
}
|
||||
30
internal/model/wecom_approval_scene.go
Normal file
30
internal/model/wecom_approval_scene.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// WeComApprovalScene 保存稳定业务类型到企微后台模板及控件映射的当前配置。
|
||||
type WeComApprovalScene struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
BusinessType string `gorm:"column:business_type;type:varchar(64);not null;uniqueIndex" json:"business_type"`
|
||||
ApplicationID uint `gorm:"column:application_id;not null;index" json:"application_id"`
|
||||
TemplateID string `gorm:"column:template_id;type:varchar(128);not null" json:"template_id"`
|
||||
TemplateName string `gorm:"column:template_name;type:varchar(255);not null;default:''" json:"template_name"`
|
||||
ControlMapping datatypes.JSON `gorm:"column:control_mapping;type:jsonb;not null" json:"control_mapping"`
|
||||
TemplateSnapshot datatypes.JSON `gorm:"column:template_snapshot;type:jsonb;not null" json:"template_snapshot"`
|
||||
TemplateFingerprint string `gorm:"column:template_fingerprint;type:char(64);not null" json:"template_fingerprint"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1" json:"status"`
|
||||
LastVerifiedAt time.Time `gorm:"column:last_verified_at;type:timestamptz;not null" json:"last_verified_at"`
|
||||
CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"`
|
||||
UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定企业微信审批场景配置表名。
|
||||
func (WeComApprovalScene) TableName() string {
|
||||
return "tb_wecom_approval_scene"
|
||||
}
|
||||
26
internal/model/wecom_member.go
Normal file
26
internal/model/wecom_member.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// WeComMember 是企业微信应用可见成员的本地选择快照,不承担组织模型职责。
|
||||
type WeComMember struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
ApplicationID uint `gorm:"column:application_id;not null;uniqueIndex:uq_wecom_member_identity,priority:1" json:"application_id"`
|
||||
CorpID string `gorm:"column:corp_id;type:varchar(64);not null" json:"corp_id"`
|
||||
UserID string `gorm:"column:userid;type:varchar(64);not null;uniqueIndex:uq_wecom_member_identity,priority:2" json:"userid"`
|
||||
Name string `gorm:"column:name;type:varchar(100);not null" json:"name"`
|
||||
DepartmentIDs datatypes.JSON `gorm:"column:department_ids;type:jsonb;not null;default:'[]'" json:"department_ids"`
|
||||
Visible bool `gorm:"column:visible;not null;default:true;index" json:"visible"`
|
||||
SyncedAt time.Time `gorm:"column:synced_at;type:timestamptz;not null" json:"synced_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定企业微信可见成员快照表名。
|
||||
func (WeComMember) TableName() string {
|
||||
return "tb_wecom_member"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user