feat(代理自充): AUG26-017 代理自充收款方式与线下预存款审批字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭 - 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕 - tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213) - 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重 - 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护 - 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
This commit is contained in:
@@ -24,7 +24,12 @@ type CreateOfflineCommand struct {
|
||||
RechargeNo string
|
||||
Amount int64
|
||||
PaymentVoucherKeys []string
|
||||
Remark string
|
||||
OtherVoucherKeys []string
|
||||
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
|
||||
OfflinePaymentMethodID uint
|
||||
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
|
||||
ExternalTransactionNo string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
|
||||
@@ -78,8 +83,20 @@ func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID
|
||||
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
|
||||
RechargeNo: record.RechargeNo, Amount: record.Amount,
|
||||
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
|
||||
OtherVoucherKeys: []string(record.OtherVoucherKeys),
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
|
||||
if record.ExternalTransactionNo != nil {
|
||||
command.ExternalTransactionNo = *record.ExternalTransactionNo
|
||||
}
|
||||
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
|
||||
var frozenCode, frozenName string
|
||||
if record.OfflinePaymentMethodCode != nil {
|
||||
frozenCode = *record.OfflinePaymentMethodCode
|
||||
}
|
||||
if record.OfflinePaymentMethodName != nil {
|
||||
frozenName = *record.OfflinePaymentMethodName
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
|
||||
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,
|
||||
}
|
||||
externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo)
|
||||
var record *model.AgentRechargeRecord
|
||||
var approvalStatus int
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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),
|
||||
ExternalTransactionNo: &externalTransactionNo,
|
||||
OfflinePaymentMethodID: &paymentMethod.ID,
|
||||
OfflinePaymentMethodCode: &paymentMethod.Code,
|
||||
OfflinePaymentMethodName: &paymentMethod.Name,
|
||||
OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys),
|
||||
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
|
||||
}
|
||||
@@ -218,17 +246,71 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
|
||||
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 个支付凭证")
|
||||
if command.OfflinePaymentMethodID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
|
||||
}
|
||||
for _, key := range command.PaymentVoucherKeys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
|
||||
}
|
||||
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
|
||||
}
|
||||
|
||||
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
|
||||
func validateRechargeTransactionNo(value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
|
||||
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVoucherKeys 校验凭证对象键数量与内容,minCount 为 0 时允许为空。
|
||||
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
|
||||
if len(keys) < minCount || len(keys) > maxCount {
|
||||
return errors.New(errors.CodeInvalidParam, message)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
|
||||
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
|
||||
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
|
||||
}
|
||||
var paymentMethod model.EmployeeCollectionPaymentMethod
|
||||
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
|
||||
}
|
||||
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
|
||||
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
|
||||
}
|
||||
return &paymentMethod, nil
|
||||
}
|
||||
|
||||
func (s *OfflineCreationService) loadHistoricalFacts(
|
||||
ctx context.Context, record *model.AgentRechargeRecord,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
|
||||
@@ -291,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": command.SubmitterAccountID, "account_name": submitterName,
|
||||
"user_type": command.SubmitterUserType,
|
||||
@@ -300,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
|
||||
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,
|
||||
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,
|
||||
constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName,
|
||||
constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode,
|
||||
constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo),
|
||||
constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")
|
||||
|
||||
@@ -53,6 +53,12 @@ type OnlineCreationService struct {
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
audit PaymentAuditWriter
|
||||
policy *OnlinePaymentMethodPolicy
|
||||
}
|
||||
|
||||
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略。
|
||||
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
|
||||
s.policy = policy
|
||||
}
|
||||
|
||||
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
|
||||
@@ -62,7 +68,7 @@ func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoade
|
||||
|
||||
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
|
||||
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
@@ -81,9 +87,14 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
|
||||
}
|
||||
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
|
||||
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
|
||||
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -127,15 +138,25 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。
|
||||
// AvailablePaymentMethods 按允许范围与可用商户池交集返回在线支付方式。
|
||||
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
|
||||
result := AvailablePaymentMethodsResult{
|
||||
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
|
||||
}
|
||||
if userType != constants.UserTypeAgent {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
|
||||
if s == nil || s.db == nil || s.runtime == nil {
|
||||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
for _, method := range []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay} {
|
||||
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
|
||||
}
|
||||
if s.policy == nil {
|
||||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置")
|
||||
}
|
||||
allowed, err := s.policy.AllowedMethods(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, method := range allowed {
|
||||
var merchants []model.PaymentMerchant
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&model.PaymentMerchant{}).
|
||||
|
||||
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// OnlinePaymentMethodConfigReader 提供代理在线自充允许范围的严格读取能力。
|
||||
type OnlinePaymentMethodConfigReader interface {
|
||||
GetStrict(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// OnlinePaymentMethodPolicy 将受控配置值映射为对外可见的线上支付方式集合。
|
||||
type OnlinePaymentMethodPolicy struct {
|
||||
reader OnlinePaymentMethodConfigReader
|
||||
}
|
||||
|
||||
// NewOnlinePaymentMethodPolicy 创建代理在线自充允许范围策略。
|
||||
func NewOnlinePaymentMethodPolicy(reader OnlinePaymentMethodConfigReader) *OnlinePaymentMethodPolicy {
|
||||
return &OnlinePaymentMethodPolicy{reader: reader}
|
||||
}
|
||||
|
||||
// AllowedMethods 严格读取允许范围;配置缺失使用注册默认值,非法值失败关闭。
|
||||
func (p *OnlinePaymentMethodPolicy) AllowedMethods(ctx context.Context) ([]string, error) {
|
||||
if p == nil || p.reader == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围未配置")
|
||||
}
|
||||
value, err := p.reader.GetStrict(ctx, constants.SystemConfigAgentSelfRechargeAllowedMethods)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "读取代理在线充值允许范围失败")
|
||||
}
|
||||
switch value {
|
||||
case constants.AgentSelfRechargeAllowedWechatOnly:
|
||||
return []string{constants.RechargeMethodWechat}, nil
|
||||
case constants.AgentSelfRechargeAllowedAlipayOnly:
|
||||
return []string{constants.RechargeMethodAlipay}, nil
|
||||
case constants.AgentSelfRechargeAllowedBoth:
|
||||
return []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}, nil
|
||||
default:
|
||||
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "代理在线充值允许范围值非法")
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed 判断业务支付方式是否在当前受控允许范围内。
|
||||
func (p *OnlinePaymentMethodPolicy) IsAllowed(ctx context.Context, method string) error {
|
||||
methods, err := p.AllowedMethods(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, allowed := range methods {
|
||||
if allowed == method {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付方式不在代理在线充值允许范围内")
|
||||
}
|
||||
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
// PaymentVoucherObjectStore 提供付款凭证附件的元数据与内容读取能力。
|
||||
type PaymentVoucherObjectStore interface {
|
||||
Stat(ctx context.Context, key string) (*storage.ObjectMetadata, error)
|
||||
Download(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// PaymentVoucherRecognizer 是付款凭证识别的外部能力接缝,只暴露支付单号。
|
||||
type PaymentVoucherRecognizer interface {
|
||||
ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error)
|
||||
}
|
||||
|
||||
// PaymentVoucherRecognitionResult 是识别结果中本系统消费的唯一字段。
|
||||
type PaymentVoucherRecognitionResult struct {
|
||||
// ExternalTransactionNo 是识别出的支付单号,仅作交易流水号表单预填值。
|
||||
ExternalTransactionNo string
|
||||
}
|
||||
|
||||
// PaymentVoucherOCRService 按附件对象键识别付款凭证,只返回交易流水号预填值。
|
||||
// 识别不创建申请、不写入任何资金事实字段;其余识别字段一律不返回、不落库。
|
||||
//
|
||||
// ENG-AUDIT-001 事实决定:识别调用不产生状态变更、不涉及资金与权限,因此
|
||||
// 不写 Audit Event、Domain Ledger、Integration Log 与 Outbox;调用记录由 Access Log 与
|
||||
// Gateway 客户端的路径级日志承载,识别载荷与原始结果不进入任何一类事实。
|
||||
type PaymentVoucherOCRService struct {
|
||||
objects PaymentVoucherObjectStore
|
||||
recognizer PaymentVoucherRecognizer
|
||||
}
|
||||
|
||||
// NewPaymentVoucherOCRService 创建付款凭证识别用例。
|
||||
func NewPaymentVoucherOCRService(objects PaymentVoucherObjectStore, recognizer PaymentVoucherRecognizer) *PaymentVoucherOCRService {
|
||||
return &PaymentVoucherOCRService{objects: objects, recognizer: recognizer}
|
||||
}
|
||||
|
||||
// Recognize 校验附件为图片后调用识别能力,只返回交易流水号预填值。
|
||||
// 非图片、对象不存在、内容为空或识别失败都返回明确失败,不阻断人工填写。
|
||||
func (s *PaymentVoucherOCRService) Recognize(ctx context.Context, objectKey string) (*PaymentVoucherRecognitionResult, error) {
|
||||
if s == nil || s.objects == nil || s.recognizer == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "付款凭证识别能力未配置")
|
||||
}
|
||||
key := strings.TrimSpace(objectKey)
|
||||
if key == "" || len([]rune(key)) > constants.AgentRechargeVoucherKeyMaxLength {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象键无效")
|
||||
}
|
||||
metadata, err := s.objects.Stat(ctx, key)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "付款凭证对象不存在或不可读")
|
||||
}
|
||||
if metadata == nil || metadata.Size <= 0 {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
|
||||
}
|
||||
if metadata.Size > constants.AgentRechargeVoucherMaxBytes {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
|
||||
}
|
||||
reader, err := s.objects.Download(ctx, key)
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证对象失败")
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
content, err := io.ReadAll(io.LimitReader(reader, constants.AgentRechargeVoucherMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证内容失败")
|
||||
}
|
||||
if int64(len(content)) > constants.AgentRechargeVoucherMaxBytes {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
|
||||
}
|
||||
if !isPaymentVoucherImage(metadata.ContentType, content) {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证必须是图片文件")
|
||||
}
|
||||
// base64 编码只存在于本次调用内存中,禁止写入日志、审计或错误信息。
|
||||
orderNumber, err := s.recognizer.ExtractPaymentVoucherOrderNumber(ctx, base64.StdEncoding.EncodeToString(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(orderNumber) == "" {
|
||||
return nil, apperrors.New(apperrors.CodeGatewayInvalidResp, "未从付款凭证中识别出交易流水号")
|
||||
}
|
||||
return &PaymentVoucherRecognitionResult{ExternalTransactionNo: strings.TrimSpace(orderNumber)}, nil
|
||||
}
|
||||
|
||||
// isPaymentVoucherImage 校验对象声明的类型为图片,并用内容嗅探拦截被改名的非图片文件。
|
||||
// 嗅探结果为空或 application/octet-stream 表示未知容器(如 webp),交由识别服务判定;
|
||||
// 明确识别为其他类型的(PDF、压缩包、文本等)直接拒绝。
|
||||
func isPaymentVoucherImage(declaredContentType string, content []byte) bool {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(declaredContentType)), "image/") {
|
||||
return false
|
||||
}
|
||||
sniffed := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
|
||||
if sniffed == "" || sniffed == "application/octet-stream" || strings.HasPrefix(sniffed, "image/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (s *PaymentMethodService) Update(
|
||||
}
|
||||
if referenced > 0 {
|
||||
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced,
|
||||
"线下收款方式已被核销申请引用,不能修改稳定编码")
|
||||
"线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码")
|
||||
}
|
||||
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil {
|
||||
return err
|
||||
@@ -276,14 +276,23 @@ func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code str
|
||||
return nil
|
||||
}
|
||||
|
||||
// countPaymentMethodReferences 统计引用该收款方式的核销申请数量。
|
||||
// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。
|
||||
// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。
|
||||
func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) {
|
||||
var count int64
|
||||
var applicationCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
|
||||
Where("payment_method_id = ?", id).Count(&count).Error; err != nil {
|
||||
Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败")
|
||||
}
|
||||
return count, nil
|
||||
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 把稳定编码唯一索引冲突映射为稳定业务错误。
|
||||
|
||||
@@ -317,6 +317,10 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
{Code: constants.ApprovalFieldOfflinePaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式名称快照"},
|
||||
{Code: constants.ApprovalFieldOfflinePaymentMethodCode, Name: "线下收款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式稳定编码快照"},
|
||||
{Code: constants.ApprovalFieldExternalTransactionNo, Name: "交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验;与在线渠道交易号无关"},
|
||||
{Code: constants.ApprovalFieldOtherVoucherKey, Name: "其他凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的其他凭证列表"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeRefund:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
@@ -110,6 +111,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
|
||||
}
|
||||
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
|
||||
if svc.AgentRechargeOnline != nil {
|
||||
svc.AgentRechargeOnline.SetPaymentMethodPolicy(agentrechargeApp.NewOnlinePaymentMethodPolicy(systemConfigReader))
|
||||
}
|
||||
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
|
||||
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
|
||||
@@ -304,6 +308,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
|
||||
handler.SetOnlineCreationService(svc.AgentRechargeOnline)
|
||||
handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB))
|
||||
handler.SetPaymentVoucherOCRService(svc.AgentRechargeVoucherOCR)
|
||||
handler.SetSystemConfigUpdateService(systemConfigUpdate)
|
||||
return handler
|
||||
}(),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
|
||||
@@ -15,6 +15,7 @@ func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, log
|
||||
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},
|
||||
{Key: constants.SystemConfigAgentSelfRechargeAllowedMethods, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.AgentSelfRechargeAllowedBoth, Description: "代理在线自充允许的支付方式范围", Control: "payment_methods", EnumValues: []string{constants.AgentSelfRechargeAllowedWechatOnly, constants.AgentSelfRechargeAllowedAlipayOnly, constants.AgentSelfRechargeAllowedBoth}},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if existing, exists := registry.Get(definition.Key); exists {
|
||||
|
||||
@@ -129,6 +129,7 @@ type services struct {
|
||||
AgentRecharge *agentRechargeSvc.Service
|
||||
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
|
||||
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
|
||||
AgentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
|
||||
PackageActivation *packageSvc.ActivationService
|
||||
Refund *refundSvc.Service
|
||||
TrafficQuery *trafficSvc.QueryService
|
||||
@@ -303,6 +304,11 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
|
||||
auditWriter,
|
||||
)
|
||||
// 付款凭证识别需要对象存储与 Gateway;任一缺失时不装配,接口统一返回能力未配置。
|
||||
var agentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
|
||||
if deps.StorageService != nil && deps.GatewayClient != nil {
|
||||
agentRechargeVoucherOCR = agentrechargeApp.NewPaymentVoucherOCRService(deps.StorageService.Provider(), deps.GatewayClient)
|
||||
}
|
||||
refundService := refundSvc.New(
|
||||
deps.DB,
|
||||
s.RefundRequest,
|
||||
@@ -465,6 +471,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
AgentRecharge: agentRechargeService,
|
||||
AgentRechargeOnline: agentRechargeOnline,
|
||||
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
|
||||
AgentRechargeVoucherOCR: agentRechargeVoucherOCR,
|
||||
PackageActivation: packageActivation,
|
||||
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
|
||||
OperationPassword: operationPassword,
|
||||
|
||||
@@ -99,8 +99,6 @@ func (c *Client) WithRetry(maxRetries int) *Client {
|
||||
// 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码
|
||||
// params: 请求参数结构体,内部自动包装为 {"params": <JSON>} 格式
|
||||
func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 将参数包装为 {"params": ...} 格式后序列化
|
||||
wrapper := requestWrapper{Params: params}
|
||||
dataBytes, err := sonic.Marshal(wrapper)
|
||||
@@ -115,6 +113,28 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.executeWithRetry(ctx, path, encryptedData, true)
|
||||
}
|
||||
|
||||
// doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。
|
||||
// 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。
|
||||
func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
|
||||
dataBytes, err := sonic.Marshal(requestWrapper{Params: params})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
|
||||
}
|
||||
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.executeWithRetry(ctx, path, encryptedData, false)
|
||||
}
|
||||
|
||||
// executeWithRetry 按现有重试语义发送一次已加密请求。
|
||||
// logPayload 为 false 时不记录响应体,只记录路径、耗时与结果字节数摘要。
|
||||
func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData string, logPayload bool) (json.RawMessage, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 带重试的 HTTP 请求
|
||||
var lastErr error
|
||||
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
|
||||
@@ -160,11 +180,19 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
|
||||
|
||||
// 成功
|
||||
duration := time.Since(startTime)
|
||||
c.logger.Debug("Gateway 请求成功",
|
||||
zap.String("path", path),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Any("result", result),
|
||||
)
|
||||
if logPayload {
|
||||
c.logger.Debug("Gateway 请求成功",
|
||||
zap.String("path", path),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Any("result", result),
|
||||
)
|
||||
} else {
|
||||
c.logger.Debug("Gateway 请求成功",
|
||||
zap.String("path", path),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Int("result_bytes", len(result)),
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
60
internal/gateway/payment_voucher.go
Normal file
60
internal/gateway/payment_voucher.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package gateway 提供付款凭证识别能力,仅用于交易流水号表单预填。
|
||||
// 识别结果不是资金事实,也不代表系统已完成任何资金动作。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// paymentVoucherRecognitionPath 是付款凭证识别接口路径。
|
||||
const paymentVoucherRecognitionPath = "/ai/ocr/extract-payment"
|
||||
|
||||
// PaymentVoucherExtractionRequest 是付款凭证识别请求,只传图片内容。
|
||||
type PaymentVoucherExtractionRequest struct {
|
||||
// ImageBase64 是付款凭证图片的 base64 编码内容。
|
||||
ImageBase64 string `json:"image_base64"`
|
||||
}
|
||||
|
||||
// paymentVoucherExtraction 只解码本系统消费的支付单号。
|
||||
// 识别响应还含 amount(数值)、payee、payment_method、payment_time 与 remark,
|
||||
// 这些字段刻意不声明、不解码:既不进入响应、不预填、不落库,也不被本进程持有,
|
||||
// 同时避免上游字段类型漂移(实测 amount 为 JSON 数值)导致整条响应解析失败。
|
||||
type paymentVoucherExtraction struct {
|
||||
OrderNumber string `json:"order_number"`
|
||||
}
|
||||
|
||||
// ExtractPaymentVoucherOrderNumber 识别付款凭证图片并只返回识别出的支付单号。
|
||||
// 该能力刻意不使用记录完整请求体与响应体的泛型入口,日志只含路径、耗时与结果摘要;
|
||||
// 返回空字符串表示识别服务未给出支付单号,由调用方决定失败口径。
|
||||
func (c *Client) ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error) {
|
||||
if strings.TrimSpace(imageBase64) == "" {
|
||||
return "", errors.New(errors.CodeInvalidParam, "付款凭证图片内容不能为空")
|
||||
}
|
||||
data, err := c.doRequestWithoutPayloadLog(ctx, paymentVoucherRecognitionPath, PaymentVoucherExtractionRequest{
|
||||
ImageBase64: imageBase64,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var extraction paymentVoucherExtraction
|
||||
if err := sonic.Unmarshal(data, &extraction); err != nil {
|
||||
// 刻意不记录 err.Error():sonic 的类型错误消息会内嵌响应 JSON 原文片段,
|
||||
// 一旦进入日志或错误上下文就等于记录识别原始结果(金额、单号等)。
|
||||
// 只记录可诊断且非敏感的摘要:路径、响应字节数与错误类型名,
|
||||
// 足以区分语法错、类型错与空响应,又不携带任何载荷内容。
|
||||
c.logger.Warn("付款凭证识别响应解析失败",
|
||||
zap.String("path", paymentVoucherRecognitionPath),
|
||||
zap.Int("result_bytes", len(data)),
|
||||
zap.String("err_kind", reflect.TypeOf(err).String()),
|
||||
)
|
||||
return "", errors.New(errors.CodeGatewayInvalidResp, "解析付款凭证识别结果失败")
|
||||
}
|
||||
return strings.TrimSpace(extraction.OrderNumber), nil
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
|
||||
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||
@@ -24,6 +26,8 @@ type AgentRechargeHandler struct {
|
||||
service *agentRechargeSvc.Service
|
||||
online *agentrechargeapp.OnlineCreationService
|
||||
status *agentrechargequery.PaymentStatusQuery
|
||||
ocr *agentrechargeapp.PaymentVoucherOCRService
|
||||
config *systemconfigapp.UpdateService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
@@ -37,6 +41,16 @@ func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.P
|
||||
h.status = query
|
||||
}
|
||||
|
||||
// SetPaymentVoucherOCRService 注入付款凭证识别用例。
|
||||
func (h *AgentRechargeHandler) SetPaymentVoucherOCRService(service *agentrechargeapp.PaymentVoucherOCRService) {
|
||||
h.ocr = service
|
||||
}
|
||||
|
||||
// SetSystemConfigUpdateService 注入受控系统配置写服务,用于代理自充允许范围修改。
|
||||
func (h *AgentRechargeHandler) SetSystemConfigUpdateService(service *systemconfigapp.UpdateService) {
|
||||
h.config = service
|
||||
}
|
||||
|
||||
// NewAgentRechargeHandler 创建代理预充值 Handler
|
||||
func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler {
|
||||
return &AgentRechargeHandler{service: service, validator: validator}
|
||||
@@ -73,8 +87,9 @@ func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRec
|
||||
if h.online == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" {
|
||||
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注")
|
||||
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || len(req.OtherVoucherKey) > 0 ||
|
||||
req.OfflinePaymentMethodID != 0 || strings.TrimSpace(req.ExternalTransactionNo) != "" || strings.TrimSpace(req.Remark) != "" {
|
||||
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、收款方式、交易流水号、支付凭证或运营备注")
|
||||
}
|
||||
result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{
|
||||
AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()),
|
||||
@@ -108,6 +123,69 @@ func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SelfRechargePaymentMethods 查询代理自充实际可用支付方式。
|
||||
// GET /api/admin/agent-self-recharge-payment-methods
|
||||
// 响应只含交集结果,不含允许范围、商户身份或凭证。
|
||||
func (h *AgentRechargeHandler) SelfRechargePaymentMethods(c *fiber.Ctx) error {
|
||||
return h.PaymentMethods(c)
|
||||
}
|
||||
|
||||
// UpdateSelfRechargePaymentMethods 修改代理在线自充允许范围。
|
||||
// PUT /api/admin/agent-self-recharge-payment-methods
|
||||
// 复用受控系统配置写服务,获得超级管理员限定、咨询锁串行与前后值审计。
|
||||
func (h *AgentRechargeHandler) UpdateSelfRechargePaymentMethods(c *fiber.Ctx) error {
|
||||
var req dto.AgentSelfRechargePaymentMethodsUpdateRequest
|
||||
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if h.config == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "代理自充允许范围维护能力未配置")
|
||||
}
|
||||
item, err := h.config.Execute(c.UserContext(), constants.SystemConfigAgentSelfRechargeAllowedMethods,
|
||||
dto.UpdateSystemConfigRequest{Value: req.AllowedMethods})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updatedAt := ""
|
||||
if item.UpdatedAt != nil {
|
||||
updatedAt = item.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return response.Success(c, &dto.AgentSelfRechargePaymentMethodsUpdateResponse{
|
||||
AllowedMethods: item.Value, AllowedMethodsName: constants.GetAgentSelfRechargeAllowedMethodsName(item.Value),
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// PaymentVoucherOCR 识别付款凭证,只返回交易流水号预填值。
|
||||
// POST /api/admin/agent-recharges/payment-voucher-ocr
|
||||
// 识别失败返回明确失败,不影响提交人人工填写交易流水号后创建申请。
|
||||
func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
|
||||
var req dto.AgentRechargePaymentVoucherOCRRequest
|
||||
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if h.ocr == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "付款凭证识别能力未配置")
|
||||
}
|
||||
result, err := h.ocr.Recognize(c.UserContext(), req.PaymentVoucherKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, &dto.AgentRechargePaymentVoucherOCRResponse{
|
||||
ExternalTransactionNo: result.ExternalTransactionNo,
|
||||
})
|
||||
}
|
||||
|
||||
// List 查询代理充值订单列表
|
||||
// GET /api/admin/agent-recharges
|
||||
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
|
||||
|
||||
@@ -74,30 +74,35 @@ func (AgentWalletTransaction) TableName() string {
|
||||
// AgentRechargeRecord 代理充值记录模型
|
||||
// 记录所有代理充值操作
|
||||
type AgentRechargeRecord struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"`
|
||||
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"`
|
||||
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
|
||||
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"`
|
||||
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"`
|
||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
|
||||
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-已退款 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"`
|
||||
RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"`
|
||||
RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"`
|
||||
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"`
|
||||
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
|
||||
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"`
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"`
|
||||
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"`
|
||||
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
|
||||
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"`
|
||||
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"`
|
||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
|
||||
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"`
|
||||
ExternalTransactionNo *string `gorm:"column:external_transaction_no;type:varchar(128);comment:线下充值交易流水号(人工确认,独立于在线渠道第三方交易号)" json:"external_transaction_no,omitempty"`
|
||||
OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id;index;comment:线下收款方式字典ID(关联tb_employee_collection_payment_method.id)" json:"offline_payment_method_id,omitempty"`
|
||||
OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64);comment:线下收款方式稳定编码快照" json:"offline_payment_method_code,omitempty"`
|
||||
OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100);comment:线下收款方式名称快照" json:"offline_payment_method_name,omitempty"`
|
||||
OtherVoucherKeys StringJSONBArray `gorm:"column:other_voucher_keys;type:jsonb;comment:其他凭证对象存储Key列表(可选,最多5个)" json:"other_voucher_keys"`
|
||||
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-已退款 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"`
|
||||
RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"`
|
||||
RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"`
|
||||
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"`
|
||||
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
|
||||
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 指定表名
|
||||
|
||||
@@ -2,12 +2,15 @@ package dto
|
||||
|
||||
// CreateAgentRechargeRequest 创建代理充值请求
|
||||
type CreateAgentRechargeRequest struct {
|
||||
ShopID *uint `json:"shop_id,omitempty" 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 alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"`
|
||||
RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"`
|
||||
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:"运营备注(可选,创建后只读)"`
|
||||
ShopID *uint `json:"shop_id,omitempty" 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 alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"`
|
||||
RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,在线支付时禁止传入)"`
|
||||
OtherVoucherKey []string `json:"other_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"其他凭证对象存储Key列表(线下代充可选,最多5个,在线支付时禁止传入)"`
|
||||
OfflinePaymentMethodID uint `json:"offline_payment_method_id" description:"线下收款方式字典ID(payment_method=offline 时必填,须取自启用中的线下收款方式字典)"`
|
||||
ExternalTransactionNo string `json:"external_transaction_no" validate:"omitempty,max=128" maxLength:"128" description:"交易流水号(payment_method=offline 时必填,由付款凭证识别预填并经人工确认;系统不做跨记录去重)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
|
||||
// AgentRechargeOnlineResponse 代理在线扫码充值创建响应。
|
||||
@@ -45,6 +48,29 @@ type AgentRechargePaymentStatusResponse struct {
|
||||
CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"`
|
||||
}
|
||||
|
||||
// AgentSelfRechargePaymentMethodsUpdateRequest 修改代理在线自充允许范围请求。
|
||||
// 枚举取值必须在 description 与 enum 标签两处与 pkg/constants 的 AgentSelfRechargeAllowed* 保持一致。
|
||||
type AgentSelfRechargePaymentMethodsUpdateRequest struct {
|
||||
AllowedMethods string `json:"allowed_methods" validate:"required,oneof=wechat_only alipay_only both" required:"true" enum:"wechat_only,alipay_only,both" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
|
||||
}
|
||||
|
||||
// AgentSelfRechargePaymentMethodsUpdateResponse 修改代理在线自充允许范围响应。
|
||||
type AgentSelfRechargePaymentMethodsUpdateResponse struct {
|
||||
AllowedMethods string `json:"allowed_methods" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
|
||||
AllowedMethodsName string `json:"allowed_methods_name" description:"允许范围名称(中文)"`
|
||||
UpdatedAt string `json:"updated_at" description:"最近更新时间,带时区 RFC3339 格式"`
|
||||
}
|
||||
|
||||
// AgentRechargePaymentVoucherOCRRequest 付款凭证识别请求。
|
||||
type AgentRechargePaymentVoucherOCRRequest struct {
|
||||
PaymentVoucherKey string `json:"payment_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"付款凭证对象存储Key,必须指向已上传的图片类型附件"`
|
||||
}
|
||||
|
||||
// AgentRechargePaymentVoucherOCRResponse 付款凭证识别响应,只返回交易流水号预填值。
|
||||
type AgentRechargePaymentVoucherOCRResponse struct {
|
||||
ExternalTransactionNo string `json:"external_transaction_no" description:"识别出的交易流水号预填值,必须经人工确认或更正后提交;金额、备注、付款人、支付方式与支付时间一律不返回"`
|
||||
}
|
||||
|
||||
// AgentOfflinePayRequest 代理线下充值确认请求
|
||||
type AgentOfflinePayRequest struct {
|
||||
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
|
||||
@@ -58,33 +84,39 @@ type AgentOfflinePayParams struct {
|
||||
|
||||
// AgentRechargeResponse 代理充值记录响应
|
||||
type AgentRechargeResponse struct {
|
||||
ID uint `json:"id" description:"充值记录ID"`
|
||||
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
|
||||
ShopID uint `json:"shop_id" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"店铺名称"`
|
||||
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
|
||||
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"`
|
||||
RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
|
||||
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
|
||||
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
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:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"充值记录ID"`
|
||||
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
|
||||
ShopID uint `json:"shop_id" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"店铺名称"`
|
||||
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
|
||||
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"`
|
||||
RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
|
||||
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
|
||||
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号(在线渠道权威值,仅在线支付有值)"`
|
||||
// ExternalTransactionNo 是线下充值人工确认的交易流水号,独立于在线渠道第三方交易号。
|
||||
ExternalTransactionNo string `json:"external_transaction_no" description:"交易流水号(线下充值人工确认值,独立于在线渠道第三方交易号)"`
|
||||
OfflinePaymentMethodID uint `json:"offline_payment_method_id,omitempty" description:"线下收款方式字典ID,仅线下充值有值"`
|
||||
OfflinePaymentMethodCode string `json:"offline_payment_method_code,omitempty" description:"线下收款方式稳定编码快照,仅线下充值有值"`
|
||||
OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式名称快照,仅线下充值有值"`
|
||||
OtherVoucherKey []string `json:"other_voucher_key" description:"其他凭证对象存储Key列表,仅线下充值有值;不返回凭证内容"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
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:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// AgentRechargeRejectRequest 驳回代理充值订单请求
|
||||
|
||||
@@ -135,6 +135,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
registerEmployeeCollectionApplicationRoutes(authGroup, handlers.EmployeeCollection, doc, basePath)
|
||||
}
|
||||
if handlers.AgentRecharge != nil {
|
||||
registerAgentSelfRechargePaymentMethodRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
|
||||
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
|
||||
}
|
||||
if handlers.Refund != nil {
|
||||
|
||||
@@ -45,6 +45,15 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "POST", "/payment-voucher-ocr", handler.PaymentVoucherOCR, RouteSpec{
|
||||
Summary: "识别付款凭证中的交易流水号",
|
||||
Description: "按付款凭证对象键调用识别能力,只返回交易流水号预填值供人工确认或更正;识别结果不写入任何资金事实,失败不阻断人工填写。",
|
||||
Tags: []string{"代理预充值"},
|
||||
Input: new(dto.AgentRechargePaymentVoucherOCRRequest),
|
||||
Output: new(dto.AgentRechargePaymentVoucherOCRResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "GET", "/:id/payment-status", handler.PaymentStatus, RouteSpec{
|
||||
Summary: "查询代理充值本地支付与到账状态",
|
||||
Tags: []string{"代理预充值"},
|
||||
|
||||
49
internal/routes/agent_self_recharge_payment_method.go
Normal file
49
internal/routes/agent_self_recharge_payment_method.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerAgentSelfRechargePaymentMethodRoutes 注册代理自充允许范围读取与修改路由。
|
||||
// 读取对代理与平台账号开放,返回的是允许范围与可用商户池的交集;修改仅超级管理员可用。
|
||||
// 该路径不在代理充值业务组内,因此读取与写入的角色门禁在此显式声明。
|
||||
func registerAgentSelfRechargePaymentMethodRoutes(router fiber.Router, handler *admin.AgentRechargeHandler, doc *openapi.Generator, basePath string) {
|
||||
group := router.Group("/agent-self-recharge-payment-methods", func(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if c.Method() == fiber.MethodPut {
|
||||
if userType != constants.UserTypeSuperAdmin {
|
||||
return errors.New(errors.CodeForbidden, "仅超级管理员可以修改代理自充允许范围")
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅代理或平台账号可以查询代理自充可用支付方式")
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
groupPath := basePath + "/agent-self-recharge-payment-methods"
|
||||
|
||||
Register(group, doc, groupPath, "GET", "", handler.SelfRechargePaymentMethods, RouteSpec{
|
||||
Summary: "查询代理自充实际可用支付方式",
|
||||
Description: "返回超级管理员允许范围与当前可用商户池支付方式的交集,固定顺序;交集为空时返回空列表且不报错。响应不含允许范围本身、商户身份或凭证。",
|
||||
Tags: []string{"代理预充值"},
|
||||
Output: new(dto.AgentRechargePaymentMethodsResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "PUT", "", handler.UpdateSelfRechargePaymentMethods, RouteSpec{
|
||||
Summary: "修改代理在线自充允许范围",
|
||||
Description: "仅超级管理员可修改,取值仅限仅微信、仅支付宝、同时支持;每次修改记录操作者、修改前后值与时间,且只影响后续新单。",
|
||||
Tags: []string{"代理预充值"},
|
||||
Input: new(dto.AgentSelfRechargePaymentMethodsUpdateRequest),
|
||||
Output: new(dto.AgentSelfRechargePaymentMethodsUpdateResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
@@ -132,13 +132,16 @@ func (s *Service) createOffline(
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
|
||||
}
|
||||
result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{
|
||||
SubmitterAccountID: userID,
|
||||
SubmitterUserType: userType,
|
||||
ShopID: *req.ShopID,
|
||||
RechargeNo: rechargeNo,
|
||||
Amount: req.Amount,
|
||||
PaymentVoucherKeys: req.PaymentVoucherKey,
|
||||
Remark: req.Remark,
|
||||
SubmitterAccountID: userID,
|
||||
SubmitterUserType: userType,
|
||||
ShopID: *req.ShopID,
|
||||
RechargeNo: rechargeNo,
|
||||
Amount: req.Amount,
|
||||
PaymentVoucherKeys: req.PaymentVoucherKey,
|
||||
OtherVoucherKeys: req.OtherVoucherKey,
|
||||
OfflinePaymentMethodID: req.OfflinePaymentMethodID,
|
||||
ExternalTransactionNo: req.ExternalTransactionNo,
|
||||
Remark: req.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -602,6 +605,7 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
|
||||
RechargeSource: rechargeSource,
|
||||
RechargeSourceName: rechargeSourceName,
|
||||
PaymentVoucherKey: []string(record.PaymentVoucherKey),
|
||||
OtherVoucherKey: []string(record.OtherVoucherKeys),
|
||||
Remark: record.Remark,
|
||||
Status: record.Status,
|
||||
StatusName: constants.GetRechargeStatusName(record.Status),
|
||||
@@ -621,6 +625,18 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
|
||||
if record.PaymentTransactionID != nil {
|
||||
resp.PaymentTransactionID = *record.PaymentTransactionID
|
||||
}
|
||||
if record.ExternalTransactionNo != nil {
|
||||
resp.ExternalTransactionNo = *record.ExternalTransactionNo
|
||||
}
|
||||
if record.OfflinePaymentMethodID != nil {
|
||||
resp.OfflinePaymentMethodID = *record.OfflinePaymentMethodID
|
||||
}
|
||||
if record.OfflinePaymentMethodCode != nil {
|
||||
resp.OfflinePaymentMethodCode = *record.OfflinePaymentMethodCode
|
||||
}
|
||||
if record.OfflinePaymentMethodName != nil {
|
||||
resp.OfflinePaymentMethodName = *record.OfflinePaymentMethodName
|
||||
}
|
||||
if record.PaidAt != nil {
|
||||
t := record.PaidAt.Format("2006-01-02 15:04:05")
|
||||
resp.PaidAt = &t
|
||||
|
||||
Reference in New Issue
Block a user