新增代理注册申请管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m41s

This commit is contained in:
2026-09-21 16:51:37 +08:00
parent b063617153
commit 4a16eb0b1e
22 changed files with 927 additions and 179 deletions

View File

@@ -17,6 +17,7 @@ import (
"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"
)
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
@@ -33,24 +34,86 @@ type RegistrationService struct {
db *gorm.DB
verifier VerificationCodeVerifier
approval approvalapp.Port
recovery approvalapp.RecoveryPort
audit AuditWriter
}
// NewRegistrationService 创建公开扫码注册用例。
func NewRegistrationService(
db *gorm.DB,
verifier VerificationCodeVerifier,
approval approvalapp.Port,
audit AuditWriter,
) *RegistrationService {
func NewRegistrationService(db *gorm.DB, verifier VerificationCodeVerifier, approval approvalapp.Port, audit AuditWriter) *RegistrationService {
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
}
// Register 创建待审批注册记录
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
// SetRecoveryPort 注入通用审批原实例恢复接缝
func (s *RegistrationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
s.recovery = recovery
}
// RecoverApproval 恢复代理注册原审批提交事实,不创建新审批实例或业务实体。
func (s *RegistrationService) RecoverApproval(ctx context.Context, id uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if middleware.GetUserIDFromContext(ctx) == 0 {
return errors.New(errors.CodeUnauthorized)
}
if s == nil || s.db == nil || s.recovery == nil {
return errors.New(errors.CodeServiceUnavailable, "代理注册审批恢复能力尚未配置")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
registration, err := lockRegistrationForUpdate(ctx, tx, id)
if err != nil {
return err
}
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
return errors.New(errors.CodeConflict, "终态申请不可恢复")
}
var parent model.Shop
if err := tx.Where("id = ?", registration.ParentShopID).First(&parent).Error; err != nil || parent.Status != constants.StatusEnabled {
return errors.New(errors.CodeConflict, "上级店铺已停用,不能恢复审批")
}
if userType == constants.UserTypeAgent && (middleware.GetShopIDFromContext(ctx) == 0 || middleware.GetShopIDFromContext(ctx) != registration.ParentShopID) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID == 0 {
return errors.New(errors.CodeConflict, "注册申请未关联审批实例")
}
instanceID := *registration.ApprovalInstanceID
var instance model.ApprovalInstance
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution, registration.ID).First(&instance).Error; err != nil {
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
}
var wc model.WeComApprovalContext
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution).First(&wc).Error; err != nil {
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
}
branch := "active"
var recoveryErr error
if wc.SPNo != "" {
branch = "sp_no"
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instanceID)
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
branch = "unknown"
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instanceID)
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
branch = "replay"
_, recoveryErr = s.recovery.RecoverSubmissionEvent(ctx, tx, instanceID)
} else if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || wc.SubmissionStatus == constants.WeComSubmissionStatusReady || wc.SubmissionStatus == constants.WeComSubmissionStatusSending {
} else {
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
}
if recoveryErr != nil {
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
}
if s.audit != nil {
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{EventID: "agent-distribution:" + uintText(id) + ":recover:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "人工恢复代理注册审批提交", Registration: registration, ParentShop: &parent, BeforeData: map[string]any{"submission_status": wc.SubmissionStatus, "branch": branch}, AfterData: map[string]any{"result": "accepted"}}); err != nil {
return err
}
}
return nil
})
}
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
func (s *RegistrationService) Register(
ctx context.Context,

View File

@@ -267,7 +267,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
WithdrawalQualification: admin.NewWithdrawalQualificationHandler(
svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate,
),
AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate),
AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate),
AgentDistributionRegistration: admin.NewAgentDistributionRegistrationHandler(
svc.DistributionRegistration, distributionwithdrawalQuery.NewQuery(deps.DB), validate,
),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),

View File

@@ -422,6 +422,7 @@ func initServices(s *stores, deps *Dependencies) *services {
distributionRegistration := distributionwithdrawalApp.NewRegistrationService(
deps.DB, deps.VerificationService, approvalCreationService, auditWriter,
)
distributionRegistration.SetRecoveryPort(recoveryPort)
withdrawalQualification := distributionwithdrawalApp.NewQualificationService(
deps.DB, approvalCreationService, auditWriter,
)

View File

@@ -11,86 +11,87 @@ import (
)
type Handlers struct {
Auth *authHandler.Handler
Account *admin.AccountHandler
Role *admin.RoleHandler
Permission *admin.PermissionHandler
PersonalCustomer *app.PersonalCustomerHandler
ClientAuth *app.ClientAuthHandler
ClientAsset *app.ClientAssetHandler
ClientWallet *app.ClientWalletHandler
ClientOrder *app.ClientOrderHandler
ClientExchange *app.ClientExchangeHandler
ClientRealname *app.ClientRealnameHandler
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
ClientPopup *app.ClientPopupHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
ShopCommission *admin.ShopCommissionHandler
WithdrawalQualification *admin.WithdrawalQualificationHandler
AgentDistribution *app.AgentDistributionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
EnterpriseCard *admin.EnterpriseCardHandler
EnterpriseDevice *admin.EnterpriseDeviceHandler
Authorization *admin.AuthorizationHandler
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
H5PopupConfiguration *admin.H5PopupConfigurationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
Storage *admin.StorageHandler
Carrier *admin.CarrierHandler
PackageSeries *admin.PackageSeriesHandler
Package *admin.PackageHandler
PackageUsage *admin.PackageUsageHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
CTCCRealnameCallback *callback.CTCCRealnameHandler
CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
PollingPriority *admin.PriorityPollingHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
EmployeeCollection *admin.EmployeeCollectionHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
BusinessUserGroup *admin.BusinessUserGroupHandler
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
OperationsReport *admin.OperationsReportHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler
Audit *admin.AuditHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler
Auth *authHandler.Handler
Account *admin.AccountHandler
Role *admin.RoleHandler
Permission *admin.PermissionHandler
PersonalCustomer *app.PersonalCustomerHandler
ClientAuth *app.ClientAuthHandler
ClientAsset *app.ClientAssetHandler
ClientWallet *app.ClientWalletHandler
ClientOrder *app.ClientOrderHandler
ClientExchange *app.ClientExchangeHandler
ClientRealname *app.ClientRealnameHandler
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
ClientPopup *app.ClientPopupHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
ShopCommission *admin.ShopCommissionHandler
WithdrawalQualification *admin.WithdrawalQualificationHandler
AgentDistribution *app.AgentDistributionHandler
AgentDistributionRegistration *admin.AgentDistributionRegistrationHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
EnterpriseCard *admin.EnterpriseCardHandler
EnterpriseDevice *admin.EnterpriseDeviceHandler
Authorization *admin.AuthorizationHandler
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
H5PopupConfiguration *admin.H5PopupConfigurationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
Storage *admin.StorageHandler
Carrier *admin.CarrierHandler
PackageSeries *admin.PackageSeriesHandler
Package *admin.PackageHandler
PackageUsage *admin.PackageUsageHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
CTCCRealnameCallback *callback.CTCCRealnameHandler
CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
PollingPriority *admin.PriorityPollingHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
EmployeeCollection *admin.EmployeeCollectionHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
BusinessUserGroup *admin.BusinessUserGroupHandler
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
OperationsReport *admin.OperationsReportHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler
Audit *admin.AuditHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler
}
// Middlewares 封装所有中间件

View File

@@ -0,0 +1,70 @@
package admin
import (
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"strconv"
)
// AgentDistributionRegistrationHandler 提供代理注册申请后台查询与原审批恢复。
type AgentDistributionRegistrationHandler struct {
service *distributionapp.RegistrationService
query *distributionquery.Query
validator *validator.Validate
}
// NewAgentDistributionRegistrationHandler 创建代理注册申请后台 Handler。
func NewAgentDistributionRegistrationHandler(service *distributionapp.RegistrationService, query *distributionquery.Query, validate *validator.Validate) *AgentDistributionRegistrationHandler {
return &AgentDistributionRegistrationHandler{service: service, query: query, validator: validate}
}
// List 查询代理注册申请列表。
// GET /api/admin/agent-distribution-registrations
func (h *AgentDistributionRegistrationHandler) List(c *fiber.Ctx) error {
var req dto.AgentDistributionRegistrationListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator != nil {
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "代理注册申请查询参数不合法")
}
}
result, err := h.query.ListAgentDistributionRegistrations(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// Get 查询代理注册申请详情。
// GET /api/admin/agent-distribution-registrations/:id
func (h *AgentDistributionRegistrationHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的注册申请 ID")
}
result, err := h.query.AgentDistributionRegistrationDetail(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// RecoverApproval 恢复代理注册原审批提交。
// POST /api/admin/agent-distribution-registrations/:id/recover-approval
func (h *AgentDistributionRegistrationHandler) RecoverApproval(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的注册申请 ID")
}
if err := h.service.RecoverApproval(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, &dto.RecoverAgentDistributionRegistrationResp{ID: uint(id), Message: "已受理原审批恢复"})
}

View File

@@ -26,11 +26,11 @@ func (r *RecoveryPort) EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, i
}
func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID uint) error {
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil {
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil || !recoverableBusinessType(instance.BusinessType) {
return gorm.ErrInvalidData
}
var context model.WeComApprovalContext
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted {
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted {
return gorm.ErrInvalidData
}
id := strconv.FormatUint(uint64(instanceID), 10)
@@ -40,11 +40,11 @@ func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID
}
func (r *RecoveryPort) enqueueUnknown(ctx context.Context, tx *gorm.DB, instanceID uint) error {
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown {
if err := tx.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil || !recoverableBusinessType(instance.BusinessType) || instance.Status != constants.ApprovalStatusSubmissionUnknown {
return gorm.ErrInvalidData
}
var context model.WeComApprovalContext
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" {
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" {
return gorm.ErrInvalidData
}
id := strconv.FormatUint(uint64(instanceID), 10)
@@ -59,11 +59,11 @@ func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB,
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
return false, err
}
if instance.Status != constants.ApprovalStatusSubmissionFailed {
if !recoverableBusinessType(instance.BusinessType) || instance.Status != constants.ApprovalStatusSubmissionFailed {
return false, gorm.ErrInvalidData
}
var context model.WeComApprovalContext
if err := tx.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" {
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" {
return false, gorm.ErrInvalidData
}
eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":submission"
@@ -86,4 +86,8 @@ func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB,
return result.RowsAffected == 1, result.Error
}
func recoverableBusinessType(value string) bool {
return value == constants.ApprovalBusinessTypeRefund || value == constants.ApprovalBusinessTypeAgentDistribution
}
var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil)

View File

@@ -153,3 +153,68 @@ type ShopWithdrawalRequestDetailResp struct {
AnomalyReason string `json:"anomaly_reason,omitempty" description:"异常原因,供人工处理"`
Attempts []WithdrawalRequestAttemptItem `json:"attempts" description:"审批尝试记录,按提交次序倒序;历史尝试与审批结果不被覆盖"`
}
// AgentDistributionRegistrationListReq 代理注册申请列表查询请求。
type AgentDistributionRegistrationListReq struct {
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `query:"status" validate:"omitempty,oneof=0 1 2" enum:"0,1,2" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"`
StartTime string `query:"start_time" description:"申请开始时间RFC3339闭区间"`
EndTime string `query:"end_time" description:"申请结束时间RFC3339闭区间"`
DecidedStartTime string `query:"decided_start_time" description:"决定开始时间RFC3339闭区间"`
DecidedEndTime string `query:"decided_end_time" description:"决定结束时间RFC3339闭区间"`
Phone string `query:"phone" validate:"omitempty,len=11" description:"手机号精确值"`
Keyword string `query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称、编号、用户名或联系人关键字"`
ParentShopID *uint `query:"parent_shop_id" validate:"omitempty,min=1" description:"直接上级店铺ID仅平台账号可用"`
SubmissionAnomaly *bool `query:"submission_anomaly" description:"是否仅筛选提交失败或结果未知"`
}
// AgentDistributionRegistrationItem 代理注册申请后台投影。
// 该投影不包含密码哈希、验证码及企业微信内部响应。
type AgentDistributionRegistrationItem struct {
ID uint `json:"id" description:"注册申请ID"`
Status int `json:"status" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"`
StatusName string `json:"status_name" description:"注册业务状态名称"`
ShopName string `json:"shop_name" description:"申请店铺名称"`
ShopCode string `json:"shop_code" description:"申请店铺编号"`
Username string `json:"username" description:"代理主账号用户名"`
ContactName string `json:"contact_name" description:"联系人"`
Phone string `json:"phone" description:"完整注册手机号"`
Province string `json:"province" description:"省份"`
City string `json:"city" description:"城市"`
District string `json:"district" description:"区县"`
Address string `json:"address" description:"详细注册地址"`
ParentShopID uint `json:"parent_shop_id" description:"直接上级店铺ID"`
ParentShopName string `json:"parent_shop_name" description:"直接上级店铺名称"`
DistributionCode string `json:"distribution_code" description:"分销码快照"`
CreatedAt string `json:"created_at" description:"申请时间"`
DecidedAt string `json:"decided_at,omitempty" description:"决定时间"`
RejectReason string `json:"reject_reason,omitempty" description:"驳回原因"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"通用审批实例ID"`
ApprovalStatus int `json:"approval_status" description:"通用审批状态"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称"`
SubmissionStatus int `json:"submission_status" description:"企微提交状态 (0:待提交, 1:发送中, 2:已提交, 3:失败, 4:结果未知)"`
SubmissionStatusName string `json:"submission_status_name" description:"企微提交状态名称"`
FailureSummary string `json:"failure_summary,omitempty" description:"安全失败摘要"`
Recoverable bool `json:"recoverable" description:"是否允许恢复原审批"`
LastRecoveryAt string `json:"last_recovery_at,omitempty" description:"最近恢复时间"`
}
// AgentDistributionRegistrationPageResult 代理注册申请分页响应。
type AgentDistributionRegistrationPageResult struct {
Items []AgentDistributionRegistrationItem `json:"items" description:"代理注册申请列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// AgentDistributionRegistrationDetailReq 代理注册申请详情路径参数。
type AgentDistributionRegistrationDetailReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"注册申请ID"`
}
// RecoverAgentDistributionRegistrationResp 代理注册审批恢复响应。
type RecoverAgentDistributionRegistrationResp struct {
ID uint `json:"id" description:"注册申请ID"`
Message string `json:"message" description:"恢复处理结果"`
}

View File

@@ -5,6 +5,8 @@ package distributionwithdrawal
import (
"context"
"encoding/json"
"strings"
"time"
"gorm.io/gorm"
@@ -13,6 +15,8 @@ import (
"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"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// Query 提供提现资料资格与提现申请详情的只读投影。
@@ -286,3 +290,149 @@ func decodeAttemptAccount(payload []byte) (string, string) {
}
return info["account_name"], info["account_number"]
}
// ListAgentDistributionRegistrations 查询代理注册申请列表,并按调用方固定数据范围过滤。
func (q *Query) ListAgentDistributionRegistrations(ctx context.Context, req *dto.AgentDistributionRegistrationListReq) (*dto.AgentDistributionRegistrationPageResult, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "代理注册申请查询能力未配置")
}
if req == nil {
req = &dto.AgentDistributionRegistrationListReq{}
}
page, size := req.Page, req.PageSize
if page <= 0 {
page = constants.DefaultPage
}
if size <= 0 {
size = constants.DefaultPageSize
}
if size > constants.MaxPageSize {
size = constants.MaxPageSize
}
query, err := q.registrationScope(ctx, req.ParentShopID)
if err != nil {
return nil, err
}
if req.Status != nil {
query = query.Where("r.status = ?", *req.Status)
}
if req.Phone != "" {
query = query.Where("r.phone = ?", req.Phone)
}
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("(r.shop_name ILIKE ? OR r.shop_code ILIKE ? OR r.username ILIKE ? OR r.contact_name ILIKE ?)", like, like, like, like)
}
for _, f := range []struct{ start, end, columnStart, columnEnd string }{{req.StartTime, req.EndTime, "r.created_at >= ?", "r.created_at <= ?"}, {req.DecidedStartTime, req.DecidedEndTime, "r.decided_at >= ?", "r.decided_at <= ?"}} {
start, end, err := utils.ParseTimeRange(f.start, f.end)
if err != nil {
return nil, err
}
if start != nil {
query = query.Where(f.columnStart, *start)
}
if end != nil {
query = query.Where(f.columnEnd, *end)
}
}
if req.SubmissionAnomaly != nil && *req.SubmissionAnomaly {
query = query.Where("(wc.submission_status IN ? OR ai.status IN ?)", []int{constants.WeComSubmissionStatusFailed, constants.WeComSubmissionStatusUnknown}, []int{constants.ApprovalStatusSubmissionFailed, constants.ApprovalStatusSubmissionUnknown})
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理注册申请失败")
}
var rows []registrationProjection
if err := query.Order("r.created_at DESC, r.id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理注册申请失败")
}
items := make([]dto.AgentDistributionRegistrationItem, 0, len(rows))
for _, row := range rows {
items = append(items, projectRegistration(row))
}
return &dto.AgentDistributionRegistrationPageResult{Items: items, Total: total, Page: page, Size: size}, nil
}
// AgentDistributionRegistrationDetail 查询单笔代理注册申请详情。
func (q *Query) AgentDistributionRegistrationDetail(ctx context.Context, id uint) (*dto.AgentDistributionRegistrationItem, error) {
query, err := q.registrationScope(ctx, nil)
if err != nil {
return nil, err
}
var row registrationProjection
if err := query.Where("r.id = ?", id).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "代理注册申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理注册申请详情失败")
}
item := projectRegistration(row)
return &item, nil
}
type registrationProjection struct {
ID uint `gorm:"column:id"`
Status int `gorm:"column:status"`
ShopName string `gorm:"column:shop_name"`
ShopCode string `gorm:"column:shop_code"`
Username string `gorm:"column:username"`
ContactName string `gorm:"column:contact_name"`
Phone string `gorm:"column:phone"`
Province string `gorm:"column:province"`
City string `gorm:"column:city"`
District string `gorm:"column:district"`
Address string `gorm:"column:address"`
ParentShopID uint `gorm:"column:parent_shop_id"`
ParentShopName string `gorm:"column:parent_shop_name"`
DistributionCode string `gorm:"column:distribution_code"`
CreatedAt time.Time `gorm:"column:created_at"`
DecidedAt *time.Time `gorm:"column:decided_at"`
RejectReason string `gorm:"column:reject_reason"`
ApprovalInstanceID uint `gorm:"column:approval_instance_id"`
ApprovalStatus int `gorm:"column:approval_status"`
SubmissionStatus int `gorm:"column:submission_status"`
FailureSummary string `gorm:"column:failure_summary"`
LastRecoveryAt *time.Time `gorm:"column:last_recovery_at"`
ParentShopStatus int `gorm:"column:parent_shop_status"`
}
func (q *Query) registrationScope(ctx context.Context, parentShopFilter *uint) (*gorm.DB, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
return nil, errors.New(errors.CodeForbidden, "无权限访问该资源或资源不存在")
}
query := q.db.WithContext(ctx).Table("tb_agent_distribution_registration r").Select("r.id, r.status, r.shop_name, r.shop_code, r.username, r.contact_name, r.phone, r.province, r.city, r.district, r.address, r.parent_shop_id, COALESCE(s.shop_name, '') AS parent_shop_name, r.distribution_code, r.created_at, r.decided_at, r.reject_reason, COALESCE(r.approval_instance_id, 0) AS approval_instance_id, COALESCE(ai.status, -1) AS approval_status, COALESCE(wc.submission_status, 0) AS submission_status, COALESCE(wc.last_error, '') AS failure_summary, wc.last_recovery_at, COALESCE(s.status, 0) AS parent_shop_status").Joins("LEFT JOIN tb_shop s ON s.id = r.parent_shop_id").Joins("LEFT JOIN tb_approval_instance ai ON ai.id = r.approval_instance_id AND ai.business_type = ?", constants.ApprovalBusinessTypeAgentDistribution).Joins("LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = r.approval_instance_id AND wc.business_type = ?", constants.ApprovalBusinessTypeAgentDistribution)
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return query.Where("1 = 0"), nil
}
query = query.Where("r.parent_shop_id = ?", shopID)
} else if parentShopFilter != nil {
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return query, nil
}
query = query.Where("r.parent_shop_id = ?", *parentShopFilter)
}
return query, nil
}
func projectRegistration(row registrationProjection) dto.AgentDistributionRegistrationItem {
failureSummary := ""
if strings.TrimSpace(row.FailureSummary) != "" {
failureSummary = "审批提交失败,请检查审批模板与渠道状态"
}
item := dto.AgentDistributionRegistrationItem{ID: row.ID, Status: row.Status, StatusName: constants.GetAgentDistributionRegistrationStatusName(row.Status), ShopName: row.ShopName, ShopCode: row.ShopCode, Username: row.Username, ContactName: row.ContactName, Phone: row.Phone, Province: row.Province, City: row.City, District: row.District, Address: row.Address, ParentShopID: row.ParentShopID, ParentShopName: row.ParentShopName, DistributionCode: row.DistributionCode, CreatedAt: row.CreatedAt.Format("2006-01-02 15:04:05"), RejectReason: row.RejectReason, ApprovalInstanceID: row.ApprovalInstanceID, ApprovalStatus: row.ApprovalStatus, ApprovalStatusName: constants.GetApprovalStatusName(row.ApprovalStatus), SubmissionStatus: row.SubmissionStatus, FailureSummary: failureSummary}
if row.DecidedAt != nil {
item.DecidedAt = row.DecidedAt.Format("2006-01-02 15:04:05")
}
if row.LastRecoveryAt != nil {
item.LastRecoveryAt = row.LastRecoveryAt.Format("2006-01-02 15:04:05")
}
item.SubmissionStatusName = map[int]string{0: "待提交", 1: "发送中", 2: "已提交", 3: "失败", 4: "结果未知"}[row.SubmissionStatus]
if item.SubmissionStatusName == "" {
item.SubmissionStatusName = "未知"
}
item.Recoverable = row.Status == constants.AgentDistributionRegistrationStatusPending && row.ParentShopStatus == 1 && row.ApprovalInstanceID > 0 && (row.ApprovalStatus == constants.ApprovalStatusSubmitting || row.ApprovalStatus == constants.ApprovalStatusPending || row.ApprovalStatus == constants.ApprovalStatusSubmissionFailed || row.ApprovalStatus == constants.ApprovalStatusSubmissionUnknown || row.SubmissionStatus == constants.WeComSubmissionStatusFailed || row.SubmissionStatus == constants.WeComSubmissionStatusUnknown)
return item
}

View File

@@ -42,6 +42,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.ShopCommission != nil {
registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath)
}
if handlers.AgentDistributionRegistration != nil {
registerAgentDistributionRegistrationRoutes(authGroup, handlers.AgentDistributionRegistration, doc, basePath)
}
if handlers.WithdrawalQualification != nil {
registerWithdrawalQualificationRoutes(authGroup, handlers.WithdrawalQualification, doc, basePath)
}

View File

@@ -0,0 +1,18 @@
package routes
import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
"github.com/gofiber/fiber/v2"
)
// registerAgentDistributionRegistrationRoutes 注册代理注册申请后台路由。
func registerAgentDistributionRegistrationRoutes(router fiber.Router, handler *admin.AgentDistributionRegistrationHandler, doc *openapi.Generator, basePath string) {
if handler == nil {
return
}
Register(router, doc, basePath, "GET", "/agent-distribution-registrations", handler.List, RouteSpec{Summary: "查询代理注册申请列表", Description: "平台账号查看全部,代理仅查看直接上级店铺申请;返回完整手机号与注册地址,不返回密码哈希及内部集成字段。", Input: new(dto.AgentDistributionRegistrationListReq), Output: new(dto.AgentDistributionRegistrationPageResult), Tags: []string{"代理注册管理"}, Auth: true})
Register(router, doc, basePath, "GET", "/agent-distribution-registrations/:id", handler.Get, RouteSpec{Summary: "查询代理注册申请详情", Input: new(dto.AgentDistributionRegistrationDetailReq), Output: new(dto.AgentDistributionRegistrationItem), Tags: []string{"代理注册管理"}, Auth: true})
Register(router, doc, basePath, "POST", "/agent-distribution-registrations/:id/recover-approval", handler.RecoverApproval, RouteSpec{Summary: "恢复代理注册原审批", Input: new(dto.AgentDistributionRegistrationDetailReq), Output: new(dto.RecoverAgentDistributionRegistrationResp), Tags: []string{"代理注册管理"}, Auth: true})
}