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

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

View File

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

View 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
}

View 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)
}

View File

@@ -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,
)
}

View 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)
}

View 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)
}

View File

@@ -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"),
}
}

View File

@@ -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

View 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,
}
}

View 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
}

View 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
}

View File

@@ -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),
}
}

View 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))
}
}

View File

@@ -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,

View File

@@ -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),
}
}

View File

@@ -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
}

View File

@@ -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 客户端(可选)
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View 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)
}

View 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"`
}

View 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"`
}

View File

@@ -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 ""

View File

@@ -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 {

View 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 "否"
}

View 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 "未知"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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, "批量查询接口暂未实现")

View File

@@ -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 的内层参数

View File

@@ -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 {

View File

@@ -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)

View 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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View 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)
}

View File

@@ -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, "查询可购套餐失败")

View File

@@ -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":

View 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
}

View 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
}

View File

@@ -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}
}

View File

@@ -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: {},
},
},
}}
}

View 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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
package wecom
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
submissionOutcomeSuccess = "success"
submissionOutcomeFailed = "failed"
submissionOutcomeUnknown = "unknown"
)
// ApprovalSubmitRequest 是调用企微 applyevent 的安全请求。
type ApprovalSubmitRequest struct {
InstanceID uint
ApplicationID uint
TemplateID string
CreatorUserID string
CorrelationID string
Contents []map[string]any
}
// ApprovalSubmitResult 描述企微是否明确创建审批单。
type ApprovalSubmitResult struct {
Outcome string
SPNo string
Message string
SafeToRetry bool
}
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
type ApprovalSubmissionClient struct {
tokens DirectoryTokenProvider
integration TokenIntegrationLog
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewApprovalSubmissionClient 创建企业微信审批提交客户端。
func NewApprovalSubmissionClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalSubmissionClient {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &ApprovalSubmissionClient{
tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"), now: time.Now,
}
}
// Submit 使用企微后台模板流程提交审批申请,绝不在结果未知时自动重发。
func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSubmitRequest) (ApprovalSubmitResult, error) {
if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "企业微信审批提交客户端未配置", SafeToRetry: true}, nil
}
token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID)
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "取得企业微信 access_token 失败", SafeToRetry: true}, err
}
request, err := c.newSubmitRequest(ctx, token, input)
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "创建企业微信审批提交请求失败", SafeToRetry: true}, err
}
resourceID := strconv.FormatUint(uint64(input.InstanceID), 10)
correlationID := strings.TrimSpace(input.CorrelationID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
"application_id": input.ApplicationID, "template_id": input.TemplateID,
"creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents),
}, CorrelationID: optionalIntegrationString(correlationID),
})
if err != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err
}
startedAt := c.now()
response, err := c.httpClient.Do(request)
if err != nil {
message := "企业微信审批提交请求结果未知"
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: message,
ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
if readErr != nil || int64(len(responseBody)) > constants.WeComMaxResponseBodyBytes {
message := "企业微信审批提交响应无法确认"
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, HTTPStatus: response.StatusCode, ProviderCode: "invalid_response",
ProviderMessage: message, ResponseSummary: map[string]any{"success": false},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
}
var result struct {
ErrCode int64 `json:"errcode"`
ErrMsg string `json:"errmsg"`
SPNo string `json:"sp_no"`
}
if err := sonic.Unmarshal(responseBody, &result); err != nil {
result.ErrMsg = "企业微信审批提交响应格式无效"
result.ErrCode = int64(response.StatusCode)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.SPNo) == "" {
providerCode := strconv.FormatInt(result.ErrCode, 10)
if result.ErrCode == 0 {
providerCode = strconv.Itoa(response.StatusCode)
}
message := strings.TrimSpace(result.ErrMsg)
if message == "" {
message = "企业微信明确拒绝审批提交"
}
_, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode, ProviderCode: providerCode,
ProviderMessage: message, ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
})
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
}
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。
func (c *ApprovalSubmissionClient) newSubmitRequest(ctx context.Context, token string, input ApprovalSubmitRequest) (*http.Request, error) {
payload := map[string]any{
"creator_userid": input.CreatorUserID,
"template_id": input.TemplateID,
"use_template_approver": 1,
"apply_data": map[string]any{"contents": input.Contents},
"summary_list": []map[string]any{{"summary_info": []map[string]string{{"text": "业务审批申请", "lang": "zh_CN"}}}},
}
body, err := sonic.Marshal(payload)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批提交请求失败")
}
endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/applyevent")
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
}
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批提交请求失败")
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
func optionalIntegrationString(value string) *string {
if value == "" {
return nil
}
return &value
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

@@ -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"`

View 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)
}

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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:"创建时间"`

View File

@@ -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"`

View 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"`
}

View File

@@ -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:"当前主套餐过期时间"`

View File

@@ -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:小程序)"`
}

View File

@@ -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:支付宝)"`
}
// ========================================

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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:"任务状态名称(中文)"`

View File

@@ -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
}

View File

@@ -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 为配置Keyintegration_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 是个人客户通知的简化分页结果。

View File

@@ -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:设备)"`
}

View File

@@ -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:"同一筛选与权限范围内的卡、设备数量汇总"`
}

View File

@@ -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 退款申请列表分页响应

View File

@@ -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:"更新时间"`
}

View File

@@ -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 {

View File

@@ -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:"是否敏感"`

View 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 回调地址验证使用的加密随机串"`
}

View 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:"同步完成时间"`
}

View 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:"每页数量"`
}

View File

@@ -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:下单时资产的标识符快照ICCIDVirtualNo" 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"`

View File

@@ -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:下单时资产标识符快照ICCIDVirtualNo" 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"`

View File

@@ -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 指定表名

View 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"
}

View 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"
}

View 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"
}

View 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