Files
junhong_cmp_fiber/internal/query/distributionwithdrawal/query.go
break 4a16eb0b1e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m41s
新增代理注册申请管理
2026-09-21 16:51:37 +08:00

439 lines
20 KiB
Go

// Package distributionwithdrawal 提供分销注册、提现资格与提现审批的只读投影查询。
// 查询不得修改任何状态;可见性由调用方在业务边界先行校验。
package distributionwithdrawal
import (
"context"
"encoding/json"
"strings"
"time"
"gorm.io/gorm"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"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"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// Query 提供提现资料资格与提现申请详情的只读投影。
type Query struct {
db *gorm.DB
}
// NewQuery 创建分销与提现审批只读查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// ListQualifications 按数据范围分页查询提现资料资格版本,证件号按脱敏值返回。
func (q *Query) ListQualifications(
ctx context.Context,
shopIDs []uint,
req *dto.WithdrawalQualificationListReq,
) (*dto.WithdrawalQualificationPageResult, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
}
page := req.Page
if page <= 0 {
page = constants.DefaultPage
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
query := q.db.WithContext(ctx).Model(&model.WithdrawalQualification{})
if len(shopIDs) == 1 {
query = query.Where("shop_id = ?", shopIDs[0])
} else if len(shopIDs) > 1 {
query = query.Where("shop_id IN ?", shopIDs)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计提现资料资格失败")
}
var versions []model.WithdrawalQualification
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).
Find(&versions).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
}
shopNames, err := q.shopNames(ctx, versions)
if err != nil {
return nil, err
}
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeWithdrawalQualification, instanceIDsOfQualifications(versions))
if err != nil {
return nil, err
}
items := make([]dto.WithdrawalQualificationItem, 0, len(versions))
for index := range versions {
version := &versions[index]
instanceID := uint(0)
if version.ApprovalInstanceID != nil {
instanceID = *version.ApprovalInstanceID
}
item := dto.WithdrawalQualificationItem{
ID: version.ID, ShopID: version.ShopID, ShopName: shopNames[version.ShopID],
SubjectType: version.SubjectType,
SubjectTypeName: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
SubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
LegalPersonIDCardMask: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
ContractFileKey: version.ContractFileKey,
IDCardFrontFileKey: version.IDCardFrontFileKey,
IDCardBackFileKey: version.IDCardBackFileKey,
BusinessLicenseFileKey: version.BusinessLicenseFileKey,
ShopFrontFileKey: version.ShopFrontFileKey,
InvoiceFileKey: version.InvoiceFileKey, InvoiceTitle: version.InvoiceTitle,
Status: version.Status, StatusName: constants.GetWithdrawalQualificationStatusName(version.Status),
InvalidReason: version.InvalidReason, ApprovalInstanceID: instanceID,
CreatedAt: version.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: version.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if version.InvalidatedAt != nil {
item.InvalidatedAt = version.InvalidatedAt.Format("2006-01-02 15:04:05")
}
if status, ok := instanceStatuses[instanceID]; ok {
item.ApprovalStatus = status
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
} else {
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
}
items = append(items, item)
}
return &dto.WithdrawalQualificationPageResult{
Items: items, Total: total, Page: page, Size: pageSize,
}, nil
}
// WithdrawalDetail 查询单笔提现申请详情与其全部审批尝试记录。
func (q *Query) WithdrawalDetail(
ctx context.Context,
requestID uint,
) (*dto.ShopWithdrawalRequestDetailResp, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "提现详情查询能力未配置")
}
var request model.CommissionWithdrawalRequest
if err := q.db.WithContext(ctx).First(&request, requestID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现申请失败")
}
var attempts []model.CommissionWithdrawalRequestAttempt
if err := q.db.WithContext(ctx).
Where("request_id = ?", request.ID).Order("attempt_no DESC").Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
}
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeCommissionWithdrawal, instanceIDsOfAttempts(attempts))
if err != nil {
return nil, err
}
shopNames, err := q.shopNames(ctx, []model.WithdrawalQualification{{ShopID: request.ShopID}})
if err != nil {
return nil, err
}
accountName, accountNumber := decodeAttemptAccount(request.AccountInfo)
processedAt := ""
if request.ProcessedAt != nil {
processedAt = request.ProcessedAt.Format("2006-01-02 15:04:05")
}
paidAt := ""
if request.PaidAt != nil {
paidAt = request.PaidAt.Format("2006-01-02 15:04:05")
}
detail := &dto.ShopWithdrawalRequestDetailResp{
ShopWithdrawalRequestItem: dto.ShopWithdrawalRequestItem{
ID: request.ID, WithdrawalNo: request.WithdrawalNo, Amount: request.Amount,
FeeRate: request.FeeRate, Fee: request.Fee, ActualAmount: request.ActualAmount,
Status: request.Status, StatusName: constants.GetWithdrawalStatusName(request.Status),
ShopID: request.ShopID, ShopName: shopNames[request.ShopID],
ApplicantID: request.ApplicantID, WithdrawalMethod: request.WithdrawalMethod,
PaymentType: request.PaymentType, AccountName: accountName, AccountNumber: accountNumber,
RejectReason: request.RejectReason, Remark: request.Remark,
CreatedAt: request.CreatedAt.Format("2006-01-02 15:04:05"),
ProcessedAt: processedAt, PaidAt: paidAt,
},
LatestAttemptID: request.LatestAttemptID,
LatestApprovalInstanceID: request.LatestApprovalInstanceID,
AnomalyFlag: request.AnomalyFlag,
AnomalyName: constants.GetWithdrawalAnomalyName(request.AnomalyFlag),
AnomalyReason: request.AnomalyReason,
Attempts: make([]dto.WithdrawalRequestAttemptItem, 0, len(attempts)),
}
if request.ApprovalInstanceID != nil {
detail.ApprovalInstanceID = *request.ApprovalInstanceID
}
for index := range attempts {
attempt := &attempts[index]
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
item := dto.WithdrawalRequestAttemptItem{
ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount,
Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount,
WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID,
ApprovalInstanceID: instanceID,
QualificationVersionID: attempt.QualificationVersionID,
QualificationPassed: attempt.QualificationPassed,
QualificationFailureReason: attempt.QualificationFailureReason,
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
}
if attempt.QualificationFailureReason != "" {
item.QualificationFailureName = constants.GetWithdrawalQualificationFailureReasonName(attempt.QualificationFailureReason)
}
if attempt.QualificationCheckedAt != nil {
item.QualificationCheckedAt = attempt.QualificationCheckedAt.Format("2006-01-02 15:04:05")
}
if status, ok := instanceStatuses[instanceID]; ok {
item.ApprovalStatus = status
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
} else {
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
}
if attempt.ReleasedAt != nil {
item.ReleasedAt = attempt.ReleasedAt.Format("2006-01-02 15:04:05")
}
detail.Attempts = append(detail.Attempts, item)
}
return detail, nil
}
// shopNames 批量读取店铺名称,缺失店铺留空。
func (q *Query) shopNames(ctx context.Context, versions []model.WithdrawalQualification) (map[uint]string, error) {
shopIDs := make([]uint, 0, len(versions))
seen := make(map[uint]struct{}, len(versions))
for _, version := range versions {
if version.ShopID == 0 {
continue
}
if _, exists := seen[version.ShopID]; exists {
continue
}
seen[version.ShopID] = struct{}{}
shopIDs = append(shopIDs, version.ShopID)
}
names := make(map[uint]string, len(shopIDs))
if len(shopIDs) == 0 {
return names, nil
}
var shops []model.Shop
if err := q.db.WithContext(ctx).Select("id", "shop_name").Where("id IN ?", shopIDs).Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺名称失败")
}
for _, shop := range shops {
names[shop.ID] = shop.ShopName
}
return names, nil
}
// approvalStatuses 批量读取通用审批实例状态,缺失实例不进入结果。
func (q *Query) approvalStatuses(
ctx context.Context,
businessType string,
instanceIDs []uint,
) (map[uint]int, error) {
statuses := make(map[uint]int, len(instanceIDs))
if len(instanceIDs) == 0 {
return statuses, nil
}
var instances []model.ApprovalInstance
if err := q.db.WithContext(ctx).Select("id", "status").
Where("business_type = ? AND id IN ?", businessType, instanceIDs).
Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例状态失败")
}
for _, instance := range instances {
statuses[instance.ID] = instance.Status
}
return statuses, nil
}
// instanceIDsOfQualifications 提取资料版本关联的审批实例 ID。
func instanceIDsOfQualifications(versions []model.WithdrawalQualification) []uint {
ids := make([]uint, 0, len(versions))
for index := range versions {
if versions[index].ApprovalInstanceID != nil {
ids = append(ids, *versions[index].ApprovalInstanceID)
}
}
return ids
}
// instanceIDsOfAttempts 提取提现审批尝试关联的审批实例 ID。
func instanceIDsOfAttempts(attempts []model.CommissionWithdrawalRequestAttempt) []uint {
ids := make([]uint, 0, len(attempts))
for index := range attempts {
if attempts[index].ApprovalInstanceID != nil {
ids = append(ids, *attempts[index].ApprovalInstanceID)
}
}
return ids
}
// decodeAttemptAccount 解析收款账户快照,解析失败时留空。
func decodeAttemptAccount(payload []byte) (string, string) {
var info map[string]string
if err := json.Unmarshal(payload, &info); err != nil {
return "", ""
}
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
}