All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭 - 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕 - tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213) - 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重 - 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护 - 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
347 lines
14 KiB
Go
347 lines
14 KiB
Go
// Package employeecollection 收口员工代收款账单、核销申请与线下收款方式字典的写用例。
|
||
// 写用例在事务内保存业务事实与审计事实,读取由 internal/query 提供。
|
||
package employeecollection
|
||
|
||
import (
|
||
"context"
|
||
stdErrors "errors"
|
||
"strconv"
|
||
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
|
||
"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"
|
||
)
|
||
|
||
// AuditWriter 接收员工代收款用例在业务事务内产生的配置审计事实。
|
||
type AuditWriter interface {
|
||
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error
|
||
}
|
||
|
||
// PaymentMethodService 维护线下收款方式字典。
|
||
// 已启用的字典项由其稳定编码对外,被核销申请引用后只可停用,不允许物理删除或改编码。
|
||
type PaymentMethodService struct {
|
||
db *gorm.DB
|
||
audit AuditWriter
|
||
}
|
||
|
||
// NewPaymentMethodService 创建线下收款方式字典事务脚本。
|
||
func NewPaymentMethodService(db *gorm.DB, audit AuditWriter) *PaymentMethodService {
|
||
return &PaymentMethodService{db: db, audit: audit}
|
||
}
|
||
|
||
// Create 创建线下收款方式,并在同一事务内写入配置审计。
|
||
func (s *PaymentMethodService) Create(
|
||
ctx context.Context,
|
||
request dto.CreateEmployeeCollectionPaymentMethodRequest,
|
||
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
|
||
operatorID, err := requireSuperAdmin(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s == nil || s.db == nil || s.audit == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||
}
|
||
status := constants.EmployeeCollectionPaymentMethodStatusDisabled
|
||
if request.Enabled != nil && *request.Enabled {
|
||
status = constants.EmployeeCollectionPaymentMethodStatusEnabled
|
||
}
|
||
var sortOrder int64
|
||
if request.Sort != nil {
|
||
sortOrder = *request.Sort
|
||
}
|
||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||
Code: request.Code, Name: request.Name, SortOrder: sortOrder, Status: status, Remark: request.Remark,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
paymentMethod := &model.EmployeeCollectionPaymentMethod{
|
||
Code: normalized.Code, Name: normalized.Name, SortOrder: normalized.SortOrder,
|
||
Status: normalized.Status, Remark: normalized.Remark,
|
||
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||
}
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, 0); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.WithContext(ctx).Create(paymentMethod).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下收款方式失败")
|
||
}
|
||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodCreate,
|
||
Description: "创建线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||
AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess,
|
||
})
|
||
})
|
||
if err != nil {
|
||
return nil, mapPaymentMethodCodeConflict(err)
|
||
}
|
||
return toPaymentMethodResponse(paymentMethod), nil
|
||
}
|
||
|
||
// Update 修改线下收款方式的名称、排序、启停与备注,并在未被引用时允许修改稳定编码。
|
||
func (s *PaymentMethodService) Update(
|
||
ctx context.Context,
|
||
id uint,
|
||
request dto.UpdateEmployeeCollectionPaymentMethodRequest,
|
||
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
|
||
operatorID, err := requireSuperAdmin(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s == nil || s.db == nil || s.audit == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||
}
|
||
if id == 0 {
|
||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||
}
|
||
var updated *model.EmployeeCollectionPaymentMethod
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
before := *paymentMethod
|
||
beforeData := paymentMethodAuditSnapshot(&before)
|
||
|
||
if request.Code != nil {
|
||
code := *request.Code
|
||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||
Code: code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
|
||
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if normalized.Code != paymentMethod.Code {
|
||
referenced, err := countPaymentMethodReferences(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if referenced > 0 {
|
||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced,
|
||
"线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码")
|
||
}
|
||
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
paymentMethod.Code = normalized.Code
|
||
}
|
||
if request.Name != nil {
|
||
paymentMethod.Name = *request.Name
|
||
}
|
||
if request.Sort != nil {
|
||
paymentMethod.SortOrder = *request.Sort
|
||
}
|
||
if request.Enabled != nil {
|
||
if *request.Enabled {
|
||
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusEnabled
|
||
} else {
|
||
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusDisabled
|
||
}
|
||
}
|
||
if request.Remark != nil {
|
||
paymentMethod.Remark = *request.Remark
|
||
}
|
||
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
|
||
Code: paymentMethod.Code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
|
||
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
paymentMethod.Code = normalized.Code
|
||
paymentMethod.Name = normalized.Name
|
||
paymentMethod.SortOrder = normalized.SortOrder
|
||
paymentMethod.Status = normalized.Status
|
||
paymentMethod.Remark = normalized.Remark
|
||
paymentMethod.Updater = operatorID
|
||
|
||
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
|
||
}
|
||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodUpdate,
|
||
Description: "更新线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||
BeforeData: beforeData, AfterData: paymentMethodAuditSnapshot(paymentMethod),
|
||
Result: constants.AuditResultSuccess,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
updated = paymentMethod
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, mapPaymentMethodCodeConflict(err)
|
||
}
|
||
return toPaymentMethodResponse(updated), nil
|
||
}
|
||
|
||
// Delete 物理删除未被任何核销申请引用的线下收款方式,并写入配置审计。
|
||
// 已被引用的字典项只允许停用,保证历史申请继续显示冻结名称。
|
||
func (s *PaymentMethodService) Delete(ctx context.Context, id uint) error {
|
||
operatorID, err := requireSuperAdmin(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if s == nil || s.db == nil || s.audit == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
|
||
}
|
||
if id == 0 {
|
||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||
}
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
referenced, err := countPaymentMethodReferences(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if referenced > 0 {
|
||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced)
|
||
}
|
||
beforeData := paymentMethodAuditSnapshot(paymentMethod)
|
||
paymentMethod.Updater = operatorID
|
||
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
|
||
}
|
||
if err := tx.WithContext(ctx).Delete(paymentMethod).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "删除线下收款方式失败")
|
||
}
|
||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodDelete,
|
||
Description: "删除线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
|
||
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
|
||
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
|
||
BeforeData: beforeData, Result: constants.AuditResultSuccess,
|
||
})
|
||
})
|
||
}
|
||
|
||
// requireSuperAdmin 校验当前调用者是超级管理员,并返回其账号 ID。
|
||
// 字典维护不对外开放,未授权一律返回同一禁止访问错误。
|
||
func requireSuperAdmin(ctx context.Context) (uint, error) {
|
||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||
if operatorID == 0 {
|
||
return 0, errors.New(errors.CodeUnauthorized)
|
||
}
|
||
return operatorID, nil
|
||
}
|
||
|
||
// lockPaymentMethod 以行锁读取线下收款方式,未找到返回稳定不存在错误。
|
||
func lockPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
|
||
var paymentMethod model.EmployeeCollectionPaymentMethod
|
||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
First(&paymentMethod, id).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
|
||
}
|
||
return &paymentMethod, nil
|
||
}
|
||
|
||
// ensurePaymentMethodCodeAvailable 校验稳定编码在未删除记录中唯一,excludeID 用于更新自身。
|
||
func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code string, excludeID uint) error {
|
||
query := tx.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}).Where("code = ?", code)
|
||
if excludeID != 0 {
|
||
query = query.Where("id <> ?", excludeID)
|
||
}
|
||
var count int64
|
||
if err := query.Count(&count).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下收款方式编码失败")
|
||
}
|
||
if count > 0 {
|
||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。
|
||
// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。
|
||
func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) {
|
||
var applicationCount int64
|
||
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||
Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil {
|
||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败")
|
||
}
|
||
if applicationCount > 0 {
|
||
return applicationCount, nil
|
||
}
|
||
var rechargeCount int64
|
||
if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||
Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil {
|
||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败")
|
||
}
|
||
return rechargeCount, nil
|
||
}
|
||
|
||
// mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。
|
||
// 并发创建或改码时唯一索引是最终裁决,避免把约束冲突暴露成内部错误。
|
||
func mapPaymentMethodCodeConflict(err error) error {
|
||
var pgErr *pgconn.PgError
|
||
if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// toPaymentMethodResponse 将字典项投影为对外响应。
|
||
func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse {
|
||
if paymentMethod == nil {
|
||
return nil
|
||
}
|
||
return &dto.EmployeeCollectionPaymentMethodResponse{
|
||
ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name,
|
||
Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled,
|
||
Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark,
|
||
CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
// paymentMethodAuditConfigKey 生成字典项的审计配置键。
|
||
func paymentMethodAuditConfigKey(id uint) string {
|
||
return constants.EmployeeCollectionAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||
}
|
||
|
||
// paymentMethodAuditResourceID 生成字典项审计资源标识。
|
||
func paymentMethodAuditResourceID(id uint) *string {
|
||
value := strconv.FormatUint(uint64(id), 10)
|
||
return &value
|
||
}
|
||
|
||
// paymentMethodAuditIdentity 生成字典项审计身份快照,不含任何凭证内容。
|
||
func paymentMethodAuditIdentity(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
|
||
return map[string]any{
|
||
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
|
||
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder,
|
||
}
|
||
}
|
||
|
||
// paymentMethodAuditSnapshot 生成字典项审计前后值快照,不含任何凭证内容。
|
||
func paymentMethodAuditSnapshot(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
|
||
return map[string]any{
|
||
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
|
||
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder, "remark": paymentMethod.Remark,
|
||
}
|
||
}
|