七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
322
internal/application/wecom/scene.go
Normal file
322
internal/application/wecom/scene.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
stdErrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// TemplateControl 是模板详情中可供业务映射的最小控件结构。
|
||||
type TemplateControl struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
OptionKeys []string `json:"option_keys"`
|
||||
}
|
||||
|
||||
// TemplateDefinition 是企微 Adapter 返回的模板最小结构。
|
||||
type TemplateDefinition struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name"`
|
||||
Controls []TemplateControl `json:"controls"`
|
||||
}
|
||||
|
||||
// TemplateProvider 定义审批模板详情外部端口。
|
||||
type TemplateProvider interface {
|
||||
GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error)
|
||||
}
|
||||
|
||||
// SceneRepository 定义审批场景当前配置持久化边界。
|
||||
type SceneRepository interface {
|
||||
FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error)
|
||||
Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
|
||||
Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error
|
||||
List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error)
|
||||
}
|
||||
|
||||
// SceneService 保存经企微模板详情校验的业务场景当前映射。
|
||||
type SceneService struct {
|
||||
db *gorm.DB
|
||||
provider TemplateProvider
|
||||
repo SceneRepository
|
||||
audit systemconfigapp.AuditWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSceneService 创建企业微信审批场景配置用例。
|
||||
func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService {
|
||||
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
|
||||
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
|
||||
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
businessType = strings.TrimSpace(businessType)
|
||||
if !validApprovalBusinessType(businessType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型")
|
||||
}
|
||||
if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 ||
|
||||
(request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
|
||||
mappingJSON, err := sonic.Marshal(request.ControlMapping)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败")
|
||||
}
|
||||
snapshotJSON, err := sonic.Marshal(definition)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败")
|
||||
}
|
||||
fingerprintBytes := sha256.Sum256(snapshotJSON)
|
||||
fingerprint := hex.EncodeToString(fingerprintBytes[:])
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var saved *model.WeComApprovalScene
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := s.repo.FindForUpdate(ctx, tx, businessType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := map[string]any{"configured": false, "business_type": businessType}
|
||||
if existing == nil {
|
||||
existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now}
|
||||
} else {
|
||||
before = sceneAuditSnapshot(existing)
|
||||
}
|
||||
existing.ApplicationID = request.ApplicationID
|
||||
existing.TemplateID = definition.TemplateID
|
||||
existing.TemplateName = definition.Name
|
||||
existing.ControlMapping = mappingJSON
|
||||
existing.TemplateSnapshot = snapshotJSON
|
||||
existing.TemplateFingerprint = fingerprint
|
||||
existing.Status = request.Status
|
||||
existing.LastVerifiedAt = now
|
||||
existing.UpdatedBy = operatorID
|
||||
existing.UpdatedAt = now
|
||||
if existing.ID == 0 {
|
||||
if err := s.repo.Create(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.audit != nil {
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
|
||||
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
|
||||
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stdErrors.As(err, &appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败")
|
||||
}
|
||||
return sceneResponse(*saved)
|
||||
}
|
||||
|
||||
// List 分页查询企业微信审批场景当前配置。
|
||||
func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
if request.Page <= 0 {
|
||||
request.Page = constants.DefaultPage
|
||||
}
|
||||
if request.PageSize <= 0 {
|
||||
request.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
if request.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes))
|
||||
for _, scene := range scenes {
|
||||
item, err := sceneResponse(scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
|
||||
func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
|
||||
controlByID := make(map[string]TemplateControl, len(controls))
|
||||
for _, control := range controls {
|
||||
controlByID[control.ID] = control
|
||||
}
|
||||
mappedControls := make(map[string]struct{}, len(mapping))
|
||||
businessFields := make(map[string]struct{}, len(mapping))
|
||||
for _, item := range mapping {
|
||||
item.BusinessField = strings.TrimSpace(item.BusinessField)
|
||||
item.ControlID = strings.TrimSpace(item.ControlID)
|
||||
if item.BusinessField == "" || item.ControlID == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空")
|
||||
}
|
||||
if !allowedSceneBusinessField(businessType, item.BusinessField) {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段")
|
||||
}
|
||||
if _, exists := businessFields[item.BusinessField]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复")
|
||||
}
|
||||
if _, exists := mappedControls[item.ControlID]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射")
|
||||
}
|
||||
control, exists := controlByID[item.ControlID]
|
||||
if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效")
|
||||
}
|
||||
validOptions := make(map[string]struct{}, len(control.OptionKeys))
|
||||
for _, key := range control.OptionKeys {
|
||||
validOptions[key] = struct{}{}
|
||||
}
|
||||
for _, key := range item.OptionMapping {
|
||||
if _, exists := validOptions[key]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效")
|
||||
}
|
||||
}
|
||||
businessFields[item.BusinessField] = struct{}{}
|
||||
mappedControls[item.ControlID] = struct{}{}
|
||||
}
|
||||
for _, control := range controls {
|
||||
if control.Required {
|
||||
if _, exists := mappedControls[control.ID]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
allowed := map[string]struct{}{}
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeOfflineRecharge:
|
||||
allowed = map[string]struct{}{
|
||||
constants.ApprovalFieldRechargeNo: {},
|
||||
constants.ApprovalFieldShopID: {},
|
||||
constants.ApprovalFieldShopName: {},
|
||||
constants.ApprovalFieldAmount: {},
|
||||
constants.ApprovalFieldAmountCent: {},
|
||||
constants.ApprovalFieldPaymentVoucherKey: {},
|
||||
constants.ApprovalFieldRemark: {},
|
||||
constants.ApprovalFieldSubmitterID: {},
|
||||
constants.ApprovalFieldSubmitterName: {},
|
||||
}
|
||||
case constants.ApprovalBusinessTypeRefund:
|
||||
allowed = map[string]struct{}{
|
||||
constants.ApprovalFieldRefundNo: {},
|
||||
constants.ApprovalFieldOrderID: {},
|
||||
constants.ApprovalFieldOrderNo: {},
|
||||
constants.ApprovalFieldAssetIdentifier: {},
|
||||
constants.ApprovalFieldAssetType: {},
|
||||
constants.ApprovalFieldActualReceivedAmount: {},
|
||||
constants.ApprovalFieldRequestedRefundAmount: {},
|
||||
constants.ApprovalFieldRefundVoucherKey: {},
|
||||
constants.ApprovalFieldRefundReason: {},
|
||||
constants.ApprovalFieldPackageUsageID: {},
|
||||
constants.ApprovalFieldSubmitterID: {},
|
||||
constants.ApprovalFieldSubmitterName: {},
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
_, exists := allowed[businessField]
|
||||
return exists
|
||||
}
|
||||
|
||||
func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem {
|
||||
result := make([]dto.WeComControlMappingItem, 0, len(mapping))
|
||||
for _, item := range mapping {
|
||||
item.BusinessField = strings.TrimSpace(item.BusinessField)
|
||||
item.ControlID = strings.TrimSpace(item.ControlID)
|
||||
item.ControlType = strings.TrimSpace(item.ControlType)
|
||||
if item.OptionMapping == nil {
|
||||
item.OptionMapping = map[string]string{}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
|
||||
}
|
||||
|
||||
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
|
||||
return map[string]any{
|
||||
"business_type": scene.BusinessType, "application_id": scene.ApplicationID,
|
||||
"template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt,
|
||||
"template_fingerprint": scene.TemplateFingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
|
||||
var mapping []dto.WeComControlMappingItem
|
||||
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败")
|
||||
}
|
||||
name := "员工线下代充值审批"
|
||||
if scene.BusinessType == constants.ApprovalBusinessTypeRefund {
|
||||
name = "退款审批"
|
||||
}
|
||||
return &dto.WeComApprovalSceneResponse{
|
||||
ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: name,
|
||||
ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName,
|
||||
TemplateFingerprint: scene.TemplateFingerprint,
|
||||
ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status),
|
||||
LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user